问题
做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
})
}