Go并发读写map引发的崩溃

965 阅读2分钟

问题

做PHP项目迁移Go时为了提升响应速度所以将之前的串行请求使用sync.WaitGroup进行了并发请求,在压测时会偶现崩溃的情况。

错误信息:fatal error: concurrent map read and map write

业务伪代码如下:

res := make(map[string]interface{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
	defer wg.Done()
	res["a"]=rpcwork1()
}()
wg.Add(1)
go func() {
	defer wg.Done()
	res["b"]=rpcwork2()
}()
wg.Wait()

可以看到代码中会对res这个map进行并发的写入。

原因

map在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 报错。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。

源代码:

//runtime/map.go
hashWriting  = 4 // a goroutine is writing to the map
if h.flags&hashWriting != 0 { //检测写标志
	fatal("concurrent map read and map write")
}
h.flags ^= hashWriting //设置写标志

解决方案

加读写锁:扩展 map,支持并发读写

使用嵌入struct为map增加一个读写锁。伪代码如下:

type RWMap struct { // 一个读写锁保护的线程安全的map
    sync.RWMutex // 读写锁保护下面的map字段
    m map[int]int
}
// 新建一个RWMap
func NewRWMap(n int) *RWMap {
    return &RWMap{
        m: make(map[int]int, n),
    }
}
func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值
    m.RLock()
    defer m.RUnlock()
    v, existed := m.m[k] // 在锁的保护下从map中读取
    return v, existed
}


func (m *RWMap) Set(k int, v int) { // 设置一个键值对
    m.Lock()              // 锁保护
    defer m.Unlock()
    m.m[k] = v
}


func (m *RWMap) Delete(k int) { //删除一个键
    m.Lock()                   // 锁保护
    defer m.Unlock()
    delete(m.m, k)
}


func (m *RWMap) Len() int { // map的长度
    m.RLock()   // 锁保护
    defer m.RUnlock()
    return len(m.m)
}


func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map
    m.RLock()             //遍历期间一直持有读锁
    defer m.RUnlock()


    for k, v := range m.m {
        if !f(k, v) {
            return
        }
    }
}

分片加锁:更高效的并发 map

使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的竞争会非常激烈。在这种情况下,我们能做的就是,尽量减少锁的粒度和锁的持有时间。

减少锁的粒度常用的方法就是分片(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))%uint(SHARD_COUNT)]
  }

使用方法如下

    // Create a new map.
	m := cmap.New[string]()


	// Sets item within map, sets "bar" under key "foo"
	m.Set("foo", "bar")


	// Retrieve item from map.
	bar, ok := m.Get("foo")


	// Removes item under key "foo"
	m.Remove("foo")

sync.Map

Go 1.9 中增加了一个线程安全的 map,也就是 sync.Map。但是,这个 sync.Map 并不是用来替换内建的 map 类型的,它只能被应用在一些特殊的场景里。

官方的文档中指出,在以下两个场景中使用 sync.Map,会比使用 map+RWMutex 的方式,性能要好得多:

  • 只会增长的缓存系统中,一个 key 只写入一次而被读很多次;
  • 多个 goroutine 为不相交的键集读、写和重写键值对。

源码地址:sync/map.go

使用方法:

package main
import (
    "fmt"
    "sync"
)
//声明sync.Map
var syncmap sync.Map
func main() {
    //Store方法将键值对保存到sync.Map
    syncmap.Store("zhangsan", 97)
    syncmap.Store("lisi", 100)
    syncmap.Store("wangmazi", 200)
    // LoadOrStore key不存在
    v, ok := syncmap.LoadOrStore(3, "three")
    fmt.Println(v, ok) // three false
    // LoadOrStore key存在
    v, ok = syncmap.LoadOrStore(1, "thisOne")
    fmt.Println(v, ok) // one ture
    // Load方法获取sync.Map 键所对应的值
    fmt.Println(syncmap.Load("lisi"))
    // Delete方法键删除对应的键值对
    syncmap.Delete("lisi")
    var syncmap sync.Map
    // LoadAndDelete key不存在
    v, ok := syncmap.LoadAndDelete("xiaomi")
    fmt.Println(v, ok) // <nil> false
    syncmap.Store("xiaomi", "xiaomi")
    // LoadAndDelete key存在
    v, ok = syncmap.LoadAndDelete("xiaomi")
    fmt.Println(v, ok) // xiaomi true
    // Range遍历所有sync.Map中的键值对
    syncmap.Range(func(k, v interface{}) bool {
        fmt.Println(k, v)
        return true
    })
}