两个 goroutine 不加协调地修改同一个变量,就会产生数据竞争,悄悄丢失更新。学会用 sync.Mutex、RWMutex、atomic 和 Once 避免它,再用 go run -race 把它找出来。
goroutine 如果只读自己的数据,事情很简单。麻烦从两个 goroutine 修改同一个变量开始。程序不会崩溃,也没有任何警告,但有些修改就这样消失了。
本文先展示这个 bug,再介绍 Go 提供的预防和发现工具:sync.Mutex、sync.RWMutex、sync/atomic、sync.Once、竞态检测器和 go vet。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
数据竞争:两个 goroutine,一个计数器
两个 goroutine 同时访问同一块内存,而且至少有一个在写,这就是数据竞争。下面有两个 goroutine,各自给一个共享计数器加 1,各加一千次:
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for range 2 {
wg.Go(func() {
for range 1000 {
count++
}
})
}
wg.Wait()
fmt.Println("done")
}
用 go run -race . 运行,会输出类似下面的报告(... 代表那些含内存地址和 goroutine 编号、每次运行都不同的行):
WARNING: DATA RACE
...
done
exit status 66
报告指出了出问题的行 main.go:14,也就是 count++。它显示一个 goroutine 在另一个写过 count 之后又读或写了它,而两者之间没有任何协调。程序照样跑完,输出 done。随后竞态检测器让它以状态码 66 退出,这样测试或 CI 任务会失败,而不是悄悄通过。(wg.Go 启动一个 goroutine 并计数,wg.Wait 等待它们全部结束。后面有一段简短回顾。)
这个程序故意不输出 count。你会预期是 2000,某次运行也许真能看到。但也可能更少,下次又是另一个数。每次运行结果都不同,就没法作为验证过的输出贴出来,而这正是问题所在:你不能信任它。
在 Go 1.26 上运行时,有件事让我们意外。有几次,报告说一个 goroutine 碰 count 时,另一个已经结束了。两者根本没有重叠,检测器仍然报告了竞争。它不会等到真正撞上才报。它注意到的是:没有任何东西,既没有锁,也没有通道(channel),规定哪个 goroutine 先走。
计数为什么会出错
count++ 看起来是一步,机器却分三步做:读出值,加 1,把结果写回去。两个 goroutine 的这三步可能交错执行,一旦交错,一次自增就会覆盖另一次。
两个 goroutine 各自对共享的 count 执行一次 count++。没有锁时,两者都读到 0、都写入 1,于是丢了一次自增。有互斥锁时,只有持锁的 goroutine 能读写,所以第二个看到 1,写入 2。图中无锁的顺序只是一种可能的交错:别的顺序会得到正确结果,这正是 bug 难以发现的原因。
如果动画没有播放,下面用文字把这几步说一遍:
count是 0。G1 和 G2 各执行一次count++。- G1 读到 0。G1 还没写入,G2 也读到了 0。
- G1 在读到的 0 上加 1,写入 1。
- G2 在它自己读到的 0 上加 1,也写入 1。自增执行了两次,
count却是 1。G1 的更新丢了。 - 现在加上互斥锁。G1 加锁,读到 0,写入 1,然后解锁。G2 必须等锁。
- G2 加锁,读到 1,写入 2,然后解锁。
count是 2。
无锁时的顺序有很多种,上面只是其中一种。如果 G1 在 G2 读取之前就做完了,结果就是对的。实际出现哪种顺序取决于调度器和机器,所以这个 bug 时有时无。
用十岁孩子能懂的话说
两个孩子共用一块白板,上面写着一个数。每个孩子的任务是给它加 1。
孩子 A 看了一眼白板,看到 5,开始在心里算 5 + 1。孩子 B 在同一时刻也看了,也看到 5,也算出 6。孩子 A 擦掉 5,写上 6。孩子 B 擦掉那个 6,又写上 6。两个孩子都完成了任务,数却只加了 1。有时他们还会把对方的字蹭花,花到根本认不出是几。
互斥锁就是唯一的一支白板笔。只有拿着笔的孩子才能看白板、往上写。其他人都得等笔放下。这样慢一些,因为孩子们要站着等,但数永远是对的。
准确的说法
count++ 编译后是三步:从存放 count 的内存读出值,在 CPU 寄存器里做加法,再写回内存。这三步不是一个不可分割的操作。两个 goroutine 不加同步地执行它们时,调度器(在多核机器上还有硬件本身)可以把它们按任意顺序交错。一次“读取-修改-写回”如果和另一次重叠,就会覆盖掉对方。
Go 内存模型把这叫作数据竞争:两个 goroutine 并发访问同一个变量,至少一次访问是写,而且没有任何同步规定它们的先后。有数据竞争的程序,结果没有任何保证。丢失更新还算轻的。对于占多个字的值,比如字符串、切片头或接口,读的一方可能看到一半旧值、一半新值。
这个比喻的局限:孩子们能看到对方伸手去够白板,goroutine 看不到。count++ 里没有任何东西会去留意别的 goroutine,只有你加了锁它才会等。而且真实的竞争不会留下看得见的涂痕。你得到的是一个看起来完全正常、实际上错误的数。
sync.Mutex:一次只让一个 goroutine 进来
sync.Mutex 是一把锁,有两个方法。Lock 等到互斥锁空闲时拿到它,Unlock 释放它。两者之间的代码一次只在一个 goroutine 里运行:
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var mu sync.Mutex
var wg sync.WaitGroup
for range 2 {
wg.Go(func() {
for range 1000 {
mu.Lock()
count++
mu.Unlock()
}
})
}
wg.Wait()
fmt.Println("count:", count)
}
输出:
count: 2000
我们带 -race 和不带 -race 一共跑了二十次,每次都是 count: 2000,也没有竞争报告。它就是动画里修好的版本,只是每个 goroutine 做一千次。sync.Mutex 的零值是未加锁状态,所以 var mu sync.Mutex 声明完就能用。
Lock 和 Unlock 之间的代码叫临界区。让它尽量小。你持有锁的时候,其他想要这把锁的 goroutine 都在等,所以网络调用、读文件这类慢操作要放在加锁之前做。
保护结构体的字段
互斥锁的常见用法,是把它放进结构体,紧挨着它保护的字段,并把所有加锁操作都放在方法里:
package main
import (
"fmt"
"sync"
)
// Stock counts items by name. It's safe to use from many goroutines.
type Stock struct {
mu sync.Mutex // guards counts
counts map[string]int
}
func NewStock() *Stock {
return &Stock{counts: make(map[string]int)}
}
func (s *Stock) Add(name string, n int) {
s.mu.Lock()
defer s.mu.Unlock()
s.counts[name] += n
}
func (s *Stock) Get(name string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.counts[name]
}
func main() {
s := NewStock()
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
s.Add("apples", 2)
s.Add("pears", 1)
})
}
wg.Wait()
fmt.Println(s.Get("apples"), s.Get("pears"))
}
输出:
100 50
五十个 goroutine 各加了 2 个苹果和 1 个梨,一个都没丢。调用方根本看不到互斥锁。他们只管调用 Add 和 Get,结构体自己照顾好自己。
在 Lock 之后紧接着写 defer s.mu.Unlock() 是标准写法。不管方法以什么方式返回,哪怕是 panic,解锁都会执行,所以提前 return 不会让锁一直被占着。锁会一直持有到函数结束,这也是这类方法要写短的又一个理由。
还有两条惯例。把互斥锁放在它保护的字段正上方,并加注释说明。另外要用指针接收者 func (s *Stock):值接收者锁住的是互斥锁的副本,什么也保护不了,go vet 那一节会演示。没有互斥锁的话,这个程序还可能以 fatal error: concurrent map writes 崩溃,这是运行时自己做的检查,不带 -race 也会触发。
sync.RWMutex:读远多于写的数据
sync.RWMutex 允许任意多个读者同时持有它,写者却只能单独持有。读很频繁、写很少时就用它,比如请求处理函数每次请求都要读、管理员一天才改一次的配置:
package main
import (
"fmt"
"sync"
)
type Config struct {
mu sync.RWMutex
settings map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.settings[key]
}
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.settings[key] = value
}
func main() {
c := &Config{settings: map[string]string{"mode": "fast"}}
var wg sync.WaitGroup
results := make([]string, 8)
for i := range 8 {
wg.Go(func() {
results[i] = c.Get("mode")
})
}
wg.Wait()
fmt.Println(results)
c.Set("mode", "safe")
fmt.Println(c.Get("mode"))
}
输出:
[fast fast fast fast fast fast fast fast]
safe
RLock 和 RUnlock 占用读的一侧。八个 goroutine 可以在同一时刻读 mode,谁也不阻塞谁。Lock 和 Unlock 占用写的一侧,它会等所有读者离开,并在写的过程中挡住新来的读者。
每个 goroutine 写入的是自己那个元素 results[i],所以它们从不共享同一个变量。这就是切片本身不需要锁的原因,也是输出能保持固定顺序的原因。
不要默认就用 RWMutex。它比 Mutex 多做不少簿记工作,所以在读写混杂或临界区很小时,往往没有任何收益。先用 Mutex,等性能分析显示读者在排队时再换。
sync/atomic:无锁计数器
sync/atomic 包里的类型,其操作都作为单一步骤完成,由 CPU 保证不会被交错打断。对计数器来说,这就够了:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for range 1000 {
count.Add(1)
}
})
}
wg.Wait()
fmt.Println("count:", count.Load())
}
输出:
count: 4000
count.Add(1) 把读、加、写作为一个不可分割的步骤完成,别的 goroutine 没有插进来的机会。Load 读取当前值。零值是 0,直接可用。
atomic.Int64 类型,以及 Int32、Uint64、Bool 和 Pointer[T],是 Go 1.19 加入的。老代码是对普通的 int64 调用 atomic.AddInt64(&n, 1)。这些类型更安全:除了通过 Load,你没法读到值;复制它们时 go vet 也会报警。
atomic 不够用的时候
atomic 让一个操作变安全,却不能让一串操作变安全。下面这个函数在还有空位时订一个座位,里面每个调用都是原子的,可它仍然是错的:
func reserveBroken(seats *atomic.Int64) bool {
if seats.Load() > 0 { // check...
seats.Add(-1) // ...then act: another goroutine can run in between
return true
}
return false
}
只剩一个座位时,两个 goroutine 可能都 Load 到 1,都认为它大于 0,然后都执行 Add(-1)。最后一个座位卖了两次,seats 变成 -1。竞态检测器也不会报出来,因为每次访问都经过了 atomic。这是逻辑竞态,不是数据竞争。
修复方法是让检查和更新成为一步。CompareAndSwap(old, new) 只在值仍然是 old 时才设置新值,并返回是否设置成功:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
// reserve takes one seat if any are left. The check and the update
// happen in a single CompareAndSwap, so no one can slip in between.
func reserve(seats *atomic.Int64) bool {
for {
n := seats.Load()
if n <= 0 {
return false
}
if seats.CompareAndSwap(n, n-1) {
return true
}
// someone else changed seats since we loaded it: try again
}
}
func main() {
var seats atomic.Int64
seats.Store(10)
var booked atomic.Int64
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
if reserve(&seats) {
booked.Add(1)
}
})
}
wg.Wait()
fmt.Println("booked:", booked.Load(), "left:", seats.Load())
}
输出:
booked: 10 left: 0
一百个 goroutine 抢十个座位,恰好十个抢到。这个循环能用,但已经比用互斥锁难读了。实用的规则是:atomic 用于单个数值,比如计数器、标志或度量值。一旦需要让两个值保持一致,或者先检查一样东西再改另一样,就用互斥锁。
sync.Once 和 sync.OnceValue:只做一次
sync.Once 让一个函数恰好执行一次,不管有多少个 goroutine 调用它、调用怎样重叠。这是在并发代码里做延迟初始化的安全方式。Go 1.21 加入了 sync.OnceValue,它包装一个返回值的函数,并缓存结果:
package main
import (
"fmt"
"sync"
)
var (
setupOnce sync.Once
ready bool
)
func setup() {
fmt.Println("setting up")
ready = true
}
var loadConfig = sync.OnceValue(func() map[string]string {
fmt.Println("loading config")
return map[string]string{"region": "eu"}
})
func main() {
var wg sync.WaitGroup
regions := make([]string, 5)
for i := range 5 {
wg.Go(func() {
setupOnce.Do(setup)
regions[i] = loadConfig()["region"]
})
}
wg.Wait()
fmt.Println(ready, regions)
}
输出:
setting up
loading config
true [eu eu eu eu eu]
五个 goroutine 调用了 setupOnce.Do(setup),setting up 只输出了一次。如果 setup 还在运行时第二个 goroutine 到了,Do 会让它等到 setup 返回。所以之后读 ready 是安全的,setting up 也总是在 loading config 之前输出。
loadConfig 是 sync.OnceValue 返回的函数。第一次调用会执行被包装的函数,之后每次调用都直接返回同一个 map,不再执行。sync.OnceValues 对返回一个值加一个 error 的函数做同样的事。
手写的“如果是 nil 就创建”代码,和订座例子一样有先检查后操作的 bug。sync.Once 就是解决办法。
简单说说 sync.WaitGroup
sync.WaitGroup 等待一组 goroutine 结束,本文每个程序都依赖它。讲 goroutine 的那一部分已经完整介绍过。简单地说:wg.Go(f) 在新的 goroutine 里启动 f 并计数,wg.Wait() 阻塞到它们全部返回。Go 1.25 之前,你得手写 wg.Add(1)、go func() { defer wg.Done(); ... }(),现有代码里大多仍是这种写法。WaitGroup 只负责等待。它不保护任何数据,所以代替不了互斥锁。
竞态检测器:-race
竞态检测器内置在 Go 工具链里,加一个标志就能打开。run、test 和 build 都支持:
go run -race .
go test -race ./...
go build -race -o app .
-race 编译程序时会加入额外的插桩,记录每一次内存访问,以及每一次锁、通道和 WaitGroup 操作。两个 goroutine 访问同一块内存、没有同步规定先后、并且有一个在写时,它会输出一份 WARNING: DATA RACE 报告,附上双方的调用栈。最后输出 Found N data race(s),并以状态码 66 退出。
它找的是这次执行中实际运行到的代码里的数据竞争。正如第一个例子所示,两次访问不必在同一瞬间相撞,只要没有先后顺序就会被发现。
它找不到的:
- 没有运行到的代码里的竞争。如果有竞争的路径只在请求带某个请求头时才会执行,而你的测试从不发送这个请求头,
-race就什么也不报。-race跑得干净,意思是“运行到的代码里没有竞争”,而不是“没有竞争”。 - 逻辑竞态。重复订座的例子只用了 atomic 调用,所以没有数据竞争可报。bug 在你的逻辑里。
- 死锁。那是另一类故障,运行时自己会报告其中一部分。
它有实实在在的开销。-race 下的程序内存用量是平时的好几倍,运行也慢好几倍,所以不要用它构建生产环境的二进制文件。每次改动都在本地和 CI 里跑 go test -race ./...。检测器从不误报:它说有竞争,就一定有。
复制互斥锁是 bug,go vet 能抓到
互斥锁在第一次使用后就不能被复制。副本是一把独立的锁,锁住它什么也保护不了。最容易无意间复制它的地方是值接收者:
type Counter struct {
mu sync.Mutex
n int
}
func (c Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
Inc 拿到的是整个 Counter 的副本,互斥锁也在其中。它锁住副本,给副本的 n 加 1,然后把两者一起扔掉。对一个新的计数器调用 c.Inc() 再打印 c.n,输出 0。编译器一声不吭,但 go vet 不放行。在名为 example.com/counter 的模块里,go vet . 输出:
main.go:13:9: Inc passes lock by value: example.com/counter.Counter contains sync.Mutex
并以状态码 1 退出。这项检查叫 copylocks。它还会标出这些情况:把含互斥锁的结构体按值传给函数,把它赋给新变量,以及按值遍历这类结构体的切片。这里的修复方法是用指针接收者 func (c *Counter) Inc()。
别指望 go test 帮你抓到这个问题。它只运行 vet 检查中的一小部分。我们在 Go 1.26 上给这个包加了一个测试文件,go test 照样通过。自己跑 go vet ./...,CI 里也要跑。
用互斥锁还是通道?
Go 有句格言:“不要通过共享内存来通信,而要通过通信来共享内存”。通道就是做这件事的工具,讲通道和 select 的那一部分专门介绍过。这是一条指导原则,并不禁止使用互斥锁。标准库两者都大量使用。
一个合理的选择方式:
- 在 goroutine 之间传递数据的所有权,或者协调步骤时,用通道:工作池、流水线、交回结果、发出停止信号。
- 多个 goroutine 共享一份待在原处的状态时,用互斥锁:缓存、计数器、会话 map。一个带互斥锁和几个方法的结构体,通常比一个独占 map、通过通道响应请求的 goroutine 更短也更清楚。
- 很多 goroutine 更新同一个数值或标志时,用 atomic。
如果代码写得很别扭,就换另一种试试。
sync.Map,以及你通常为什么用不着它
sync.Map 是一种不用自己加锁就能并发使用的 map。听起来是理所当然的选择,但它是专用的。它没有类型(键和值都是 any),没有 len,而且只针对两种情况做了优化:键写入一次之后只读,比如只增不减的缓存;以及很多 goroutine 各自操作互不相交的键。其他情况,包括 REST API 里的大多数 map,用普通的 map 旁边配一个 sync.Mutex 或 sync.RWMutex,有类型,更容易理解,还往往更快。只有性能分析显示恰好在这种模式上有锁竞争时,才用 sync.Map。
要点
- 数据竞争是两个 goroutine 同时使用同一个变量,至少有一次写,而且没有同步。
count++是读、加、写三步,重叠的写会丢失。 sync.Mutex一次只让一个 goroutine 进入临界区。加锁,用defer解锁,临界区要小,互斥锁要紧挨着它保护的字段。sync.RWMutex允许多个读者或一个写者。用在读多写少的数据上,不要默认使用。atomic.Int64等类型让单个操作变安全。先检查后操作需要CompareAndSwap或互斥锁,而竞态检测器抓不到这种逻辑 bug。sync.Once和sync.OnceValue让初始化恰好执行一次,不管有多少个 goroutine 请求。- 随时跑
go test -race ./...。它只能在运行到的代码里找到竞争,但报出来的从不是误报。 - 永远不要复制互斥锁。用指针接收者,让
go vet来把关。
如果两个 goroutine 能碰到同一个变量,而且其中一个会写,就必须有东西来决定谁先来。