\345\210\206\347\211\207\345\212\240\351\224\201

\345\210\206\347\211\207\345\212\240\351\224\201

Content #

虽然使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的 竞争会非常激烈。我在第 4 讲中提到过,锁是性能下降的万恶之源之一。

在并发编程中,我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应 用(比如 Redis 等),基本上不需要使用锁去解决并发线程访问的问题,所以 可以取得很高的性能。但是对于 Go 开发的应用程序来说,并发是常用的一个特 性,在这种情况下,我们能做的就是,尽量减少锁的粒度和锁的持有时间。

你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成 并行的子任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原 语的优化,所以这里我重点讲如何减少锁的粒度。

减少锁的粒度常用的方法就是分片(Shard),将一把锁分成几把锁,每个锁控 制一个分片。Go 比较知名的分片并发 map 的实现是orcaman/concurrent-map。

它默认采用 32 个分片,GetShard 是一个关键的方法,能够根据 key 计算出分 片索引。

  var SHARD_COUNT = 32

  // 分成SHARD_COUNT个分片的map
type ConcurrentMap []*ConcurrentMapShared

// 通过RWMutex保护的线程安全的分片,包含一个map
type ConcurrentMapShared struct {
  items        map[string]interface{}
  sync.RWMutex // Read Write mutex, guards access to internal map.
}

// 创建并发map
func New() ConcurrentMap {
  m := make(ConcurrentMap, SHARD_COUNT)
  for i := 0; i < SHARD_COUNT; i++ {
    m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
  }
  return m
}

// 根据key计算分片索引
func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
  return m[uint(fnv32(key))nt(SHARD_COUNT)]
}

增加或者查询的时候,首先根据分片索引得到分片对象,然后对分片对象加锁进行操作:

func (m ConcurrentMap) Set(key string, value interface{}) {
    // 根据key计算出对应的分片
    shard := m.GetShard(key)
    shard.Lock() //对这个分片加锁,执行业务操作
    shard.items[key] = value
    shard.Unlock()
}

func (m ConcurrentMap) Get(key string) (interface{}, bool) {
    // 根据key计算出对应的分片
    shard := m.GetShard(key)
    shard.RLock()
    // 从这个分片读取key的值
    val, ok := shard.items[key]
    shard.RUnlock()
    return val, ok
}

当然,除了 GetShard 方法,ConcurrentMap 还提供了很多其他的方法。这些方 法都是通过计算相应的分片实现的,目的是保证把锁的粒度限制在分片上。

好了,到这里我们就学会了解决 map 并发 panic 的两个方法:加锁和分片。

在我个人使用并发 map 的过程中,加锁和分片加锁这两种方案都比较常用,如 果是追求更高的性能,显然是分片加锁更好,因为它可以降低锁的粒度,进而提 高访问此 map 对象的吞吐。如果并发性能要求不是那么高的场景,简单加锁方 式更简单。

Viewpoints #

From #

09 | map:如何实现线程安全的map类型?