Blog

Go 的 sync、竞态检测器和 atomic

两个 goroutine 不加协调地修改同一个变量,就会产生数据竞争,悄悄丢失更新。学会用 sync.Mutex、RWMutex、atomic 和 Once 避免它,再用 go run -race 把它找出来。

goroutine 如果只读自己的数据,事情很简单。麻烦从两个 goroutine 修改同一个变量开始。程序不会崩溃,也没有任何警告,但有些修改就这样消失了。

本文先展示这个 bug,再介绍 Go 提供的预防和发现工具:sync.Mutexsync.RWMutexsync/atomicsync.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 的这三步可能交错执行,一旦交错,一次自增就会覆盖另一次。

G1 count G2 准备 count++ 读到 0 写入 1 准备 count++ 已加锁,读到 0 写入 1,已解锁 准备 count++ 读到 0 写入 1 等待锁 已加锁,读到 1 写入 2,已解锁 0 1 0 1 2 mutex 更新丢失:自增了两次,count 却是 1 没有丢失更新:count 是 2 没有锁:每个 goroutine 读出、加 1、写回 用 sync.Mutex:只有持锁者能碰 count count 是 0,G1 和 G2 各执行一次 count++ G1 读到 0。G1 写入前,G2 也读到 0 G1 在读到的 0 上加 1,写入 1 G2 在它读到的 0 上加 1,写入 1:G1 的更新丢了 有互斥锁:G1 加锁、读到 0、写入 1、解锁 G2 加锁、读到 1、写入 2、解锁:count 是 2

两个 goroutine 各自对共享的 count 执行一次 count++。没有锁时,两者都读到 0、都写入 1,于是丢了一次自增。有互斥锁时,只有持锁的 goroutine 能读写,所以第二个看到 1,写入 2。图中无锁的顺序只是一种可能的交错:别的顺序会得到正确结果,这正是 bug 难以发现的原因。

如果动画没有播放,下面用文字把这几步说一遍:

  1. count 是 0。G1 和 G2 各执行一次 count++
  2. G1 读到 0。G1 还没写入,G2 也读到了 0。
  3. G1 在读到的 0 上加 1,写入 1。
  4. G2 在它自己读到的 0 上加 1,也写入 1。自增执行了两次,count 却是 1。G1 的更新丢了。
  5. 现在加上互斥锁。G1 加锁,读到 0,写入 1,然后解锁。G2 必须等锁。
  6. 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 声明完就能用。

LockUnlock 之间的代码叫临界区。让它尽量小。你持有锁的时候,其他想要这把锁的 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 个梨,一个都没丢。调用方根本看不到互斥锁。他们只管调用 AddGet,结构体自己照顾好自己。

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

RLockRUnlock 占用读的一侧。八个 goroutine 可以在同一时刻读 mode,谁也不阻塞谁。LockUnlock 占用写的一侧,它会等所有读者离开,并在写的过程中挡住新来的读者。

每个 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 类型,以及 Int32Uint64BoolPointer[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.Oncesync.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 之前输出。

loadConfigsync.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 工具链里,加一个标志就能打开。runtestbuild 都支持:

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.Mutexsync.RWMutex,有类型,更容易理解,还往往更快。只有性能分析显示恰好在这种模式上有锁竞争时,才用 sync.Map

要点

  • 数据竞争是两个 goroutine 同时使用同一个变量,至少有一次写,而且没有同步。count++ 是读、加、写三步,重叠的写会丢失。
  • sync.Mutex 一次只让一个 goroutine 进入临界区。加锁,用 defer 解锁,临界区要小,互斥锁要紧挨着它保护的字段。
  • sync.RWMutex 允许多个读者或一个写者。用在读多写少的数据上,不要默认使用。
  • atomic.Int64 等类型让单个操作变安全。先检查后操作需要 CompareAndSwap 或互斥锁,而竞态检测器抓不到这种逻辑 bug。
  • sync.Oncesync.OnceValue 让初始化恰好执行一次,不管有多少个 goroutine 请求。
  • 随时跑 go test -race ./...。它只能在运行到的代码里找到竞争,但报出来的从不是误报。
  • 永远不要复制互斥锁。用指针接收者,让 go vet 来把关。

如果两个 goroutine 能碰到同一个变量,而且其中一个会写,就必须有东西来决定谁先来。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。