原文
Beware of copying mutexes in Go
假设我们定义了一个包含一个映射表( map)的结构体,我们想要使用结构体的方法去修改映射表的内容,如下是一个例子:
1 | package main |
代码中 Container
包含了一个由计数器组成的映射表,使用计数器名称作为索引。Container
的 inc 方法会去增加指定计数器的值(假设指定计数器已经存在)。main 函数中使用 for 循环调用 inc 方法多次。
运行这段代码,输出如下:
1 | map[a:100000 b:0] |
现在我们想在两个 goroutine 中并发调用 inc 方法。由于我们担心竞争情况,我们使用一个互斥锁来保护关键区域:
1 | package main |
你预期的输出结果会是什么呢?我得到了如下输出:
1 | fatal error: concurrent map writes |
我们已经很小心地使用锁保护共享变量,那到底是哪里出错了?你能看出来如何去修复这个错误吗?提示:只需要一个字符的变动即可。
这段代码的问题在于 inc 方法定义在了 Container 上,而不是 _Container,因此每次调用 inc 方法 Container 实例都会被复制一次。换种说法,inc 是一个值接收器而不是指针接收器。因此 inc 的每次调用并不能真正修改最初的 Container 实例。
但是,等一下,既然如此,那为什么我们的第一个例子能够正常工作呢?在只有一个 goroutine 的例子中,我们也是按值传递了 c 到 inc 函数,但是它确实起作用了。main 函数确实观察到了 inc 函数对映射表的修改。这是因为映射表比较特殊:它是引用类型,而不是值类型。存在 Container 中的并不是真正的映射表的数据,而是一个指向映射表数据的指针。因此,就算我们创建了一个 Container 的副本,它的 counters 成员仍然保存了相同映射表数据的地址。
所以说第一个例子的代码也是错误的。尽管这段代码可以工作,但它显然违反了 Go 社区的编程指南:修改对象内容的方法应该定义在类型指针上,而不是类型值上。这里使用映射表给我造成了一个安全上的错觉。作为练习,尝试使用单个 int 类型的计数器替代最初例子中的映射表,然后注意 inc 方法只是修改了计数器的副本,所以在 main 函数中看不到这种修改的效果。
Mutex 是值类型(参见 Go 源码中的定义,注释中很清楚地写明了不要拷贝互斥锁),因此拷贝是错误的行为。拷贝只是创建了一个完全不同的新的互斥锁,因此它的互斥功能不再有效。
因此,针对上述代码的一个字符的修正就是在定义 inc 方法时在 Container 前添加一个 _ 号:
1 | func (c *Container) inc(name string) { |
这样, 就会将 c 的指针传入 inc 方法,实际上是对调用方持有的内存中同一个 Container 实例的引用。
这不是一个少见的错误。实际上, go vet 会对此发出警告:
1 | $ go tool vet method-mutex-value-receiver.go |
这种情况经常出现在 HTTP 处理函数中,这些处理函数在不需要开发人员显示使用 go 语句的情况下被并发地调用。我会在随后的博文中详述这一点。
我觉得这个问题对于认识 Go 语言中的值接收器和指针接收器的区别有很大的帮助。为了把问题说清楚,下面给出一个和之前代码无关的示例。这个例子用到了 Go 中使用 & 操作符创建指针和使用 %p 格式化输出指针值的功能:
1 | package main |
输出结果如下(这是在我的电脑上的输出,和你电脑上的变量地址可能不一样,但是地址之间的关系是一样的):
1 | in main &c=0xc00000a060, &(c.s)=0xc00000a068 |
main 函数创建了一个 Container 实例并打印出了实例的地址及实例成员变量 s 的地址,接着调用了实例的两个方法。
byValMethod 方法有一个值接收器,它打印出的地址和 main 函数中打印出的不一样因为接收到的是 c 的一个副本。另一方面,byPtrMethod 方法有一个指针接收器并且它观察到的地址和 main 函数中的一致,因为它获取到的是最初的 c,而不是一个副本。
译者补充
读写锁
Go 语言中还有一种锁:读写锁(RWMutex),读写锁也是值类型,也不能拷贝。