普通 map 没有并发安全保证,是 Go 留下的一条设计边界,不是遗漏。大多数 map 用不上内置锁;真遇到并发更新时,程序需要保护的范围往往又比一次 map 访问更大。Go 因而把同步范围交给调用代码决定。
给 map 的每次访问都加上锁,下面这段代码照样可能错:
hljs
if _, ok := users[id]; !ok {
users[id] = user
}
假设 map 已经替每一次查询和写入加好了锁。两个 goroutine 仍然可以按这个顺序执行:
hljs
goroutine A:查询 id,不存在
goroutine B:查询 id,也不存在
goroutine A:写入 userA
goroutine B:写入 userB
四次访问都没有撞车,两次写入却都越过了 !ok。最后留下谁取决于执行顺序,两个调用方还都会以为自己完成了"仅在不存在时插入"。
这段代码需要把查询和写入包进同一个临界区。拆成两次各自安全的操作,条件就会漏掉。内置 map 只看得见一次 users[id],它看不懂前面的 if 与后面的赋值原来是一件事,更猜不到有些操作还要连同另一个字段一起保护。
如果要给这层区别找个术语,一次操作看起来在调用与返回之间的某个瞬间完成,叫作可线性化;连续几次调用不会因此自动变成一笔事务。给 map 内置锁,最多替单次操作划线。业务上的那条线应该从哪里开始、在哪里结束,只有调用代码知道。
这不等于并发 map 做不出来,sync.Map 就在标准库里。它的 LoadOrStore 正好说明了边界应该怎样表达:key 已存在就返回旧值,不存在才存入新值。把一次 Load 和一次 Store 分开调用,得不到同样的保证。它的文档也明确说 sync.Map 是专用类型,适合某个条目只写一次、读取很多次,或者不同 goroutine 大多操作不相交的 key;多数代码仍适合普通 map 配一把单独的锁,因为这把锁还能顺手保护 map 以外的数据关系。sync.Map 文档
问题在于要不要把这份保证加到每一个普通 map 上。Go 官方 FAQ 明确提到,语言并不排斥实现原子的 map 更新;只是多数 map 根本不需要多个 goroutine 同时修改。真有这种需要时,map 往往已经属于一个更大的数据结构,而那个结构本来就要同步。若所有 map 操作都默认获取互斥锁,大多数程序会多出不需要的同步开销,组合操作仍要由外部锁保护。
普通 map 的边界也别记混。例如先完成初始化,再启动读取它的 goroutine;此后只要没人更新,查找和 range 可以并发进行。一旦赋值、删除或 clear 可能与其他访问重叠,就要用锁或 channel 把访问串起来;写入不同的 key 也不例外。Go 内存模型
上面的"仅在不存在时插入",用普通 map 可以这样收口:
hljs
type UserStore struct {
mu sync.Mutex
users map[int]*User
}
func NewUserStore() *UserStore {
return &UserStore{users: make(map[int]*User)}
}
func (s *UserStore) PutIfAbsent(id int, user *User) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[id]; ok {
return false
}
s.users[id] = user
return true
}
当 PutIfAbsent 返回 true 时,查询和写入已经一起走完。要是只在 s.users[id] = user 那一行上锁,锁确实加了,那个 if 还是会漏人过去。