写在前面

过去 Web 开发的工作比较少涉及到并发的问题,每个用户请求在独立的线程里面进行,偶尔涉及到异步任务但是线程间数据同步模型非常简单,因此并未深入探究过并发这一块。最近在写游戏相关的服务端代码时发现数据的并发同步场景非常多,因此花了一点时间来探索。这是一个系列文章,本文为第三篇。

本文简单介绍 Golang 中 map 类型的安全使用。

Golang 中 map 的使用

key-valueMap

不允许并发读写的 map

Maps are not safe for concurrent use: it's not defined what happens when you read and write to them simultaneously
map
package main

func main() {
	m := make(map[int]int)
	go func() {
		for {
			_ = m[1]
		}
	}()
	go func() {
		for {
			m[2] = 1
		}
	}()
	select {}
}

运行上面的代码可以得到下面类似的结果:

go run map/main.go 
# fatal error: concurrent map read and map write
# ....(省略异常堆栈)
mapintstring
// 运行下面的代码并不会异常退出,不同于上面 map 类型的 m 的使用
// go run main.go 
package main

func main() {
	var m int
	go func() {
		for {
			_ = m
		}
	}()
	go func() {
		for {
			m = 1
		}
	}()
	select {}
}
int

安全使用 map——显而易见地加锁

既然 Golang 在运行时不允许对 map 的并发读写,当需要在多个线程中读写 map 时,显而易见的方式是加锁(如《浅谈 Golang 中数据的并发同步问题(一)》所描述的)。

mapmstructstructsync.RWMutexmap
package main

import (
	"sync"
)

func main() {
	var counter = struct {
		sync.RWMutex
		m map[string]int
	}{m: make(map[string]int)}

	go func() {
		for {
			counter.RLock()
			_ = counter.m["some_key"]
			counter.RUnlock()
		}
	}()
	go func() {
		for {
			counter.Lock()
			counter.m["some_key"]++
			counter.Unlock()
		}
	}()
	select {}
}

为什么 map 并发读写时会在运行时异常退出

map
mapfunc mapassign()func mapaccess1()func mapdelete()mapmapassignkeymapaccess1mapaccess1keymapdeletekey

同时考虑到增删数据时底层数据的改变(比如扩容重分配,这一块还没深入研究,可以自行查看源码=。=),因此保持 map 的单纯变得很重要;为避免出现难以 debug 的异常,运行时环境显式地并发异常退出也就可以理解了。

小结

Golang 的运行时会在 map 的增改删查过程中检测是否有并发读写的情况,当发现并发读写时直接异常退出。相对于其他数据类型(比如 int、string、slice 等),map 的并发使用是比较严苛的(安全&性能的折中);可以认为 map 的这种严苛很大程度上降低了诡异 bug 的产生,增加代码的鲁棒性。

sync.Mapinterface{}sync.Map

参考


有疑问加站长微信联系(非本文作者)