热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

golang并发安全Map以及分段锁的实现方法

这篇文章主要介绍了golang并发安全Map以及分段锁的实现方法,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

涉及概念

  1. 并发安全Map
  2. 分段锁
  3. sync.Map
  4. CAS ( Compare And Swap )
  5. 双检查

分断锁

type SimpleCache struct {
  mu  sync.RWMutex
  items map[interface{}]*simpleItem
}

在日常开发中, 上述这种数据结构肯定不少见,因为golang的原生map是非并发安全的,所以为了保证map的并发安全,最简单的方式就是给map加锁。

之前使用过两个本地内存缓存的开源库, gcache, cache2go,其中存储缓存对象的结构都是这样,对于轻量级的缓存库,为了设计简洁(包含清理过期对象等 ) 再加上当需要缓存大量数据时有redis,memcache等明星项目解决。 但是如果抛开这些因素遇到真正数量巨大的数据量时,直接对一个map加锁,当map中的值越来越多,访问map的请求越来越多,大家都竞争这一把锁显得并发访问控制变重。 在go1.9引入sync.Map 之前,比较流行的做法就是使用分段锁,顾名思义就是将锁分段,将锁的粒度变小,将存储的对象分散到各个分片中,每个分片由一把锁控制,这样使得当需要对在A分片上的数据进行读写时不会影响B分片的读写。

分段锁的实现

// Map 分片
type ConcurrentMap []*ConcurrentMapShared

// 每一个Map 是一个加锁的并发安全Map
type ConcurrentMapShared struct {
  items map[string]interface{}
  sync.RWMutex  // 各个分片Map各自的锁
}

主流的分段锁,即通过hash取模的方式找到当前访问的key处于哪一个分片之上,再对该分片进行加锁之后再读写。分片定位时,常用有BKDR, FNV32等hash算法得到key的hash值。

func New() ConcurrentMap {
  // SHARD_COUNT 默认32个分片
  m := make(ConcurrentMap, SHARD_COUNT)
  for i := 0; i 

在初始化好分片后, 对分片上的数据进行读写时就需要用hash取模进行分段定位来确认即将要读写的分片。

获取段定位

func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
  return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}

// FNV hash
func fnv32(key string) uint32 {
  hash := uint32(2166136261)
  const prime32 = uint32(16777619)
  for i := 0; i 

之后对于map的GET SET 就简单顺利成章的完成

Set And Get

func (m ConcurrentMap) Set(key string, value interface{}) {
  shard := m.GetShard(key) // 段定位找到分片
  shard.Lock()       // 分片上锁
  shard.items[key] = value // 分片操作 
  shard.Unlock()       // 分片解锁
}

func (m ConcurrentMap) Get(key string) (interface{}, bool) {
  shard := m.GetShard(key)
  shard.RLock()
  val, ok := shard.items[key]
  shard.RUnlock()
  return val, ok
}

由此一个分段锁Map就实现了, 但是比起普通的Map, 常用到的方法比如获取所有key, 获取所有Val 操作是要比原生Map复杂的,因为要遍历每一个分片的每一个数据, 好在golang的并发特性使得解决这类问题变得非常简单

Keys

// 统计当前分段map中item的个数
func (m ConcurrentMap) Count() int {
  count := 0
  for i := 0; i 

这里写了一个benchMark来对该分段锁Map和原生的Map加锁方式进行压测, 场景为将一万个不重复的键值对同时以100万次写和100万次读,分别进行5次压测, 如下压测代码

func BenchmarkMapShared(b *testing.B) {
  num := 10000
  testCase := genNoRepetTestCase(num) // 10000个不重复的键值对
  m := New()
  for _, v := range testCase {
    m.Set(v.Key, v.Val)
  }
  b.ResetTimer()

  for i := 0; i <5; i++ {
    b.Run(strconv.Itoa(i), func(b *testing.B) {

      b.N = 1000000

      wg := sync.WaitGroup{}
      wg.Add(b.N * 2)
      for i := 0; i 

原生Map加锁压测结果

分段锁压测结果

可以看出在将锁的粒度细化后再面对大量需要控制并发安全的访问时,分段锁Map的耗时比原生Map加锁要快3倍有余

Sync.Map

go1.9之后加入了支持并发安全的Map sync.Map, sync.Map 通过一份只使用原子操作的数据和一份冗余了只读数据的加锁数据实现一定程度上的读写分离,使得大多数读操作和更新操作是原子操作,写入新数据才加锁的方式来提升性能。以下是 sync.Map源码剖析, 结构体中的注释都会在具体实现代码中提示相呼应

type Map struct {
  // 保护dirty的锁
  mu Mutex            
  // 只读数据(修改采用原子操作)
  read atomic.Value        
  // 包含只读中所有数据(冗余),写入新数据时也在dirty中操作
  dirty map[interface{}]*entry 
  // 当原子操作访问只读read时找不到数据时会去dirty中寻找,此时misses+1,dirty及作为存储新写入的数据,又冗余了只读结构中的数据,所以当misses > dirty 的长度时, 会将dirty升级为read,同时将老的dirty置nil
  misses int 
}

// Map struct 中的 read 就是readOnly 的指针
type readOnly struct {
  // 基础Map
  m  map[interface{}]*entry 
  // 用于表示当前dirty中是否有read中不存在的数据, 在写入数据时, 如果发现dirty中没有新数据且dirty为nil时,会将read中未被删除的数据拷贝一份冗余到dirty中, 过程与Map struct中的 misses相呼应
  amended bool 
}

// 数据项
type entry struct {
  p unsafe.Pointer 
}

// 用于标记数据项已被删除(主要保证数据冗余时的并发安全)
// 上述Map结构中说到有一个将read数据拷贝冗余至dirty的过程, 因为删除数据项是将*entry置nil, 为了避免冗余过程中因并发问题导致*entry改变而影响到拷贝后的dirty正确性,所以sync.Map使用expunged来标记entry是否被删除
var expunged = unsafe.Pointer(new(interface{}))

在下面sync.Map具体实现中将会看到很多“双检查”代码,因为通过原子操作获取的值可能在进行其他非原子操作过程中已改变,所以再非原子操作后需要使用之前原子操作获取的值需要再次进行原子操作获取。

compareAndSwap 交换并比较, 用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时导致数据不一致问题。

sync.Map Write

func (m *Map) Store(key, value interface{}) {
  // 先不上锁,而是从只读数据中按key读取, 如果已存在以compareAndSwap操作进行覆盖(update)
  read, _ := m.read.Load().(readOnly)
  if e, ok := read.m[key]; ok && e.tryStore(&value) {
    return
  }
  
  m.mu.Lock()
  // 双检查获取read
  read, _ = m.read.Load().(readOnly)
  // 如果data在read中,更新entry
  if e, ok := read.m[key]; ok {
    // 如果原子操作读到的数据是被标记删除的, 则视为新数据写入dirty
    if e.unexpungeLocked() {
      m.dirty[key] = e
    }
    // 原子操作写新数据
    e.storeLocked(&value)
  } else if e, ok := m.dirty[key]; ok {
    // 原子操作写新数据
    e.storeLocked(&value)
  } else {
    // 新数据 
    // 当dirty中没有新数据时,将read中数据冗余到dirty
    if !read.amended {
      m.dirtyLocked()
      m.read.Store(readOnly{m: read.m, amended: true})
    }
    
    m.dirty[key] = newEntry(value)
  }
  m.mu.Unlock()
}

func (e *entry) tryStore(i *interface{}) bool {
  p := atomic.LoadPointer(&e.p)
  if p == expunged {
    return false
  }
  for {
    if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
      return true
    }
    p = atomic.LoadPointer(&e.p)
    if p == expunged {
      return false
    }
  }
}


// 在dirty中没有比read多出的新数据时触发冗余
func (m *Map) dirtyLocked() {
  if m.dirty != nil {
    return
  }

  read, _ := m.read.Load().(readOnly)
  m.dirty = make(map[interface{}]*entry, len(read.m))
  for k, e := range read.m {
    // 检查entry是否被删除, 被删除的数据不冗余
    if !e.tryExpungeLocked() {
      m.dirty[k] = e
    }
  }
}

func (e *entry) tryExpungeLocked() (isExpunged bool) {
  p := atomic.LoadPointer(&e.p)
  for p == nil {
    // 将被删除(置nil)的数据以cas原子操作标记为expunged(防止因并发情况下其他操作导致冗余进dirty的数据不正确)
    if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
      return true
    }
    p = atomic.LoadPointer(&e.p)
  }
  return p == expunged
}

sync.Map Read

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
  read, _ := m.read.Load().(readOnly)
  e, ok := read.m[key]

  // 只读数据中没有,并且dirty有比read多的数据,加锁在dirty中找
  if !ok && read.amended {
    m.mu.Lock()
    // 双检查, 因为上锁之前的语句是非原子性的
    read, _ = m.read.Load().(readOnly)
    e, ok = read.m[key]
    if !ok && read.amended {
      // 只读中没有读取到的次数+1
      e, ok = m.dirty[key]
      // 检查是否达到触发dirty升级read的条件
      m.missLocked()
    }
    m.mu.Unlock()
  }
  if !ok {
    return nil, false
  }
  // atomic.Load 但被标记为删除的会返回nil
  return e.load()
}

func (m *Map) missLocked() {
  m.misses++
  if m.misses 

sync.Map DELETE

func (m *Map) Delete(key interface{}) {
  read, _ := m.read.Load().(readOnly)
  e, ok := read.m[key]
  // 只读中不存在需要到dirty中去删除
  if !ok && read.amended {
    m.mu.Lock() 
    // 双检查, 因为上锁之前的语句是非原子性的
    read, _ = m.read.Load().(readOnly)
    e, ok = read.m[key]
    if !ok && read.amended {
      delete(m.dirty, key)
    }
    m.mu.Unlock()
  }
  if ok {
    e.delete()
  }
}

func (e *entry) delete() (hadValue bool) {
  for {
    p := atomic.LoadPointer(&e.p)
    if p == nil || p == expunged {
      return false
    }
    if atomic.CompareAndSwapPointer(&e.p, p, nil) {
      return true
    }
  }
}

同样以刚刚压测原生加锁Map和分段锁的方式来压测sync.Map

压测平均下来sync.Map和分段锁差别不大,但是比起分段锁, sync.Map则将锁的粒度更加的细小到对数据的状态上,使得大多数据可以无锁化操作, 同时比分段锁拥有更好的拓展性,因为分段锁使用前总是要定一个分片数量, 在做扩容或者缩小时很麻烦, 但要达到sync.Map这种性能既好又能动态扩容的程度,代码就相对复杂很多。

还有注意在使用sync.Map时切忌不要将其拷贝, go源码中有对sync.Map注释到” A Map must not be copied after first use.”因为当sync.Map被拷贝之后, Map类型的dirty还是那个map 但是read 和 锁却不是之前的read和锁(都不在一个世界你拿什么保护我), 所以必然导致并发不安全(为了写博我把sync.Map代码复制出来一份把私有成员改成可外部访问的打印指针)

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。


推荐阅读
  • 初识java关于JDK、JRE、JVM 了解一下 ... [详细]
  • Oracle分析函数first_value()和last_value()的用法及原理
    本文介绍了Oracle分析函数first_value()和last_value()的用法和原理,以及在查询销售记录日期和部门中的应用。通过示例和解释,详细说明了first_value()和last_value()的功能和不同之处。同时,对于last_value()的结果出现不一样的情况进行了解释,并提供了理解last_value()默认统计范围的方法。该文对于使用Oracle分析函数的开发人员和数据库管理员具有参考价值。 ... [详细]
  • 加密、解密、揭秘
    谈PHP中信息加密技术同样是一道面试答错的问题,面试官问我非对称加密算法中有哪些经典的算法?当时我愣了一下,因为我把非对称加密与单项散列加 ... [详细]
  • 一、申明slice会产生什么1.1申明slice当咱们申明一个slice类型,它理论的值什么?{代码}如上咱们申明了一个[]int的slice切片类型输入如下:{代码 ... [详细]
  • 本文由编程笔记#小编为大家整理,主要介绍了logistic回归(线性和非线性)相关的知识,包括线性logistic回归的代码和数据集的分布情况。希望对你有一定的参考价值。 ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • 本文介绍了Python爬虫技术基础篇面向对象高级编程(中)中的多重继承概念。通过继承,子类可以扩展父类的功能。文章以动物类层次的设计为例,讨论了按照不同分类方式设计类层次的复杂性和多重继承的优势。最后给出了哺乳动物和鸟类的设计示例,以及能跑、能飞、宠物类和非宠物类的增加对类数量的影响。 ... [详细]
  • IjustinheritedsomewebpageswhichusesMooTools.IneverusedMooTools.NowIneedtoaddsomef ... [详细]
  • 欢乐的票圈重构之旅——RecyclerView的头尾布局增加
    项目重构的Git地址:https:github.comrazerdpFriendCircletreemain-dev项目同步更新的文集:http:www.jianshu.comno ... [详细]
  • EzPP 0.2发布,新增YAML布局渲染功能
    EzPP发布了0.2.1版本,新增了YAML布局渲染功能,可以将YAML文件渲染为图片,并且可以复用YAML作为模版,通过传递不同参数生成不同的图片。这个功能可以用于绘制Logo、封面或其他图片,让用户不需要安装或卸载Photoshop。文章还提供了一个入门例子,介绍了使用ezpp的基本渲染方法,以及如何使用canvas、text类元素、自定义字体等。 ... [详细]
  • Python使用Pillow包生成验证码图片的方法
    本文介绍了使用Python中的Pillow包生成验证码图片的方法。通过随机生成数字和符号,并添加干扰象素,生成一幅验证码图片。需要配置好Python环境,并安装Pillow库。代码实现包括导入Pillow包和随机模块,定义随机生成字母、数字和字体颜色的函数。 ... [详细]
  • 本文整理了Java中java.lang.NoSuchMethodError.getMessage()方法的一些代码示例,展示了NoSuchMethodErr ... [详细]
  • python中安装并使用redis相关的知识
    本文介绍了在python中安装并使用redis的相关知识,包括redis的数据缓存系统和支持的数据类型,以及在pycharm中安装redis模块和常用的字符串操作。 ... [详细]
  • 数据库技术:execute immediate
    首先在这里发发牢骚,指责下那些刻板的书写方式,不考虑读者理不理解,感觉就是给专业人员用来复习用的一样,没有前戏,直接就高潮,实在受不了!没基础或基础差的完全不知道发生了什么,一脸懵 ... [详细]
  • AndroidJetpackNavigation基本使用本篇主要介绍一下AndroidJetpack组件Navigation导航组件的基本使用当看到Navigation单词的时候应 ... [详细]
author-avatar
麦尔小哈PICA
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有