Go 语言 map 的并发安全问题

2021-07-10 ⏳2.1分钟(0.8千字) 🕸️

众所周知,Go 语言的 map 不是并发安全的数据结构。如果有多个协程并改读写同一个 map 会报错。说来惭愧,我之前一直以为这个错误可以用 recover 捕获。直到昨天有同学提出这个疑问,还给我发了一段演示代码。今天抽空研究了一下相关的知识,整理出来,分享给大家。

为什么要单说这个问题呢?那还得从 Sniper 框架的设计说起。我们线上的系统虽然是微服务架构,但用的还是巨石仓库,所有的业务共用一套代码,只会编译出一个二进制文件。框架会为每一个请求启动一个协程,并通过 recover 捕获所有错误/panic。如果业务代码出错误而没有被捕获,那整个进程就会退出,这样就会影响正在处理的所有请求,后果非常严重,完全不可接受。

Go 语言的错误分三种 error, panic 和 fatal error:

因为 fatal error 无法恢复,所以一旦出现,就会导致整个进程退出,进而影响当前的所有请求。而我们前面的说的并发读写 map 居然也是一种 fatal error。

通过查资料发现,在 go 1.6 之前,并发读写 map 并不会立即报错。只有并发读写导致了空指针等错误的时候才会触发 panic。效果如下:

unexpected fault address 0x0
fatal error: fault
[signal 0x7 code=0x80 addr=0x0 pc=0x40873b]

goroutine 97699 [running]:
runtime.throw(0x17f5cc0, 0x5)
    /usr/local/go/src/runtime/panic.go:527
runtime.sigpanic()
    /usr/local/go/src/runtime/sigpanic_unix.go:21
runtime.mapassign1(0x12c6fe0, 0xc88283b998, 0xc8c9b63c68, 0xc8c9b63cd8)
    /usr/local/go/src/runtime/hashmap.go:446

这种 panic 是可以通过 recover 捕获的。但这种错误时有时无,难以复现,不利于快速定位问题。所以在社区里反馈这一问题的情况特别多。为了解决这个问题,Go 1.6 版本引入了这次提交。思路也很简单粗暴,当有协程要更新 map 的时候打上一个标记,其他协程读取内容的时候发现有写入标记的直接报 fatal error。

	if h.flags&hashWriting != 0 {
		throw("concurrent map writes")
	}
	h.flags |= hashWriting
	// ...
	if h.flags&hashWriting != 0 {
		throw("concurrent map read and map write")
	}

讲道理我没明白为什么要用 fatal error。既然已经主动检测了,就不会产生脏数据。发现并发读写完全可以抛一个普通的 panic,这样框架就能 recover,就不会产生局部错误影响全局的问题。

虽然我没前意识到这个问题,但是我们线上的系统也没有出现过这样的问题。其主要原因是我们原则上不使用协程写业务逻辑。如果非用不可,也会在框架层面提供封装好的方法,避免直接使用 go 启协程。所以说我们的业务代码很少用并发问题。

并发编程是很难的。不要以为 go 语言内置协程就能简化并发编程,它只是并发编程的门槛。所以大家在决定使用协程的时候一定要慎重,尽量少用。我们要写明显没有错误的代码,不要写没有明显错误的代码

参考资料: