需要预先了解:


1. sync 包与原子操作

多个 goroutine 同时读写同一份数据,如果不加保护,就会产生竞态条件(race condition)。这一篇讲清楚竞态是怎么发生的、怎么用 -race 检测它,以及 sync.Mutex、sync.RWMutex、sync/atomic、sync.Map、sync.Pool 各自的适用场景。


1.1 竞态条件

var counter int

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++
		}()
	}
	wg.Wait()
	fmt.Println("counter =", counter)
}

期望值是 1000,实际运行可能得到:

counter = 968

原因在于 counter++ 不是一条指令,而是三步:

读取 counter 的值 → 加 1 → 写回 counter

两个 goroutine 交错执行时就会丢更新:

时间   goroutine A          goroutine B        counter
 |     读取 (0)                                0
 |                          读取 (0)           0
 |     加 1,准备写 1                            0
 |                          加 1,准备写 1       0
 |     写回 1                                   1
 |                          写回 1               1   ← 两次自增只加了一次

-race 检测器

Go 内置数据竞争检测器,加上 -race 就能让它在运行时报告:

go run -race .

输出:

==================
WARNING: DATA RACE
Read at 0x000140263a50 by goroutine 8:
  main.main.func1()
      .../race/main.go:16 +0x74
Previous write at 0x000140263a50 by goroutine 10:
  main.main.func1()
      .../race/main.go:16 +0x8c
==================

报告里会给出读写发生的地址、goroutine 编号和代码位置。-race 有性能开销(内存和速度都有影响),适合在开发和 CI 中开启:

go test -race ./...

注意:竞态检测器只能发现实际发生的竞争,不能证明"没有竞争"。测试用例没覆盖到的路径上的竞态不会被报出来。


1.2 sync.Mutex

最直接的解决办法是加锁,把"读—改—写"包成一个临界区(critical section):

type SafeCounter struct {
	mu sync.Mutex
	n  int
}

func (c *SafeCounter) Incr() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *SafeCounter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}
go run -race .
counter = 1000

几点注意事项:

  1. Mutex 的零值可用,不需要 sync.NewMutex();
  2. 用 defer 解锁,避免函数中途 return 或 panic 时忘记解锁;
  3. 临界区尽量小,只保护共享数据的读写,不要在里面做 IO 或耗时计算;
  4. 不要把 Mutex 拷贝出去。sync.Mutex 拷贝后会变成两把独立的锁,保护不了同一份数据。含 Mutex 的结构体应该用指针传递,或者作为结构体字段时只用指针接收者方法。

忘记解锁会让其他 goroutine 永久阻塞,程序看起来"卡住",最后可能报:

fatal error: all goroutines are asleep - deadlock!

1.3 sync.RWMutex

读多写少的场景用读写锁:多个读者可以同时进入,写者独占。

var rw sync.RWMutex

rw.RLock()   // 读锁,可以多个同时持有
defer rw.RUnlock()

rw.Lock()    // 写锁,独占
defer rw.Unlock()
for i := 0; i < 5; i++ {
	go func(id int) {
		rw.RLock()
		defer rw.RUnlock()
		fmt.Println("读者", id, "读到:", data["x"])
	}(i)
}

go func() {
	rw.Lock()
	defer rw.Unlock()
	data["x"] = 42
}()

读锁之间不互斥,写锁与任何锁互斥。RWMutex 比 Mutex 复杂,读写比例不悬殊时反而更慢;读远多于写(比如读占比 90% 以上)时才值得用。

注:Go 的 RWMutex 对写者有优先照顾(写者到来后新的读者会排队),但仍然不能保证绝对的公平。持续不断的读请求可能让写者等很久,这就是"写饥饿"。需要严格公平性时要换用其他方案。


1.4 死锁

死锁是多个 goroutine 互相等待对方持有的资源,永远无法继续。最简单的形式是自锁:

var mu sync.Mutex
mu.Lock()
fmt.Println("第一次加锁成功")
mu.Lock() // 自己等自己
第一次加锁成功
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(...)

Mutex 不可重入——同一个 goroutine 重复 Lock 会死锁。互等的形式是 A 持有锁 1 等锁 2、B 持有锁 2 等锁 1,解决办法是约定所有代码按同一顺序获取锁。

运行时只能在"所有 goroutine 都阻塞"时检测出死锁;如果还有别的 goroutine 在干活,死锁可能表现为请求超时而不是 fatal error。


1.5 sync/atomic

对于简单的计数、标志位,用**原子操作(atomic operation)**可以避免锁的开销:

var n int64
atomic.AddInt64(&n, 1)      // 原子加
atomic.LoadInt64(&n)        // 原子读
atomic.StoreInt64(&n, 0)    // 原子写

Go 1.19 起提供了类型化的原子类型,不用再传指针:

var c atomic.Int64
c.Add(1)
c.Load()
c.Store(0)
c.Swap(100)
c.CompareAndSwap(100, 200)

泛型的原子指针 atomic.Pointer[T] 也在这个版本引入:

type Config struct {
	Debug bool
}

var p atomic.Pointer[Config]
p.Store(&Config{Debug: true})
fmt.Println(p.Load().Debug) // true

CAS 循环

**CAS(Compare-And-Swap)**是"如果当前值等于期望值就替换成新值",返回是否成功。它可以用来自旋地实现无锁更新:

var m int64
for {
	old := atomic.LoadInt64(&m)
	if atomic.CompareAndSwapInt64(&m, old, old+10) {
		break
	}
	// 失败说明有人改过,重读再试
}

读—比较—写之间可能有其他 goroutine 修改,所以必须循环重试。CAS 适合"更新逻辑简单"的场景;逻辑复杂时 CAS 循环会很难写对,直接用锁更稳妥。

使用约束

原子操作只能保证单个变量的原子性。下面这种组合并不安全:

atomic.AddInt64(&a, 1)
atomic.AddInt64(&b, 1)

两行之间其他 goroutine 可能看到 a 已经加过而 b 没加的状态。需要多个变量一起保持一致时,用 Mutex 保护整个临界区。

另外,原子操作和普通读写混用也会产生竞态——要么全用原子操作,要么全用锁。


1.6 sync.Map

sync.Map 是并发安全的 map,用法与普通 map 类似:

var m sync.Map

m.Store("a", 1)
v, ok := m.Load("a")
v, ok = m.LoadOrStore("c", 3) // 不存在则存入并返回存入的值
m.Delete("b")
m.Range(func(k, v any) bool {
	fmt.Println(k, v)
	return true // 返回 false 则停止遍历
})

它的适用场景比较窄:

  • key 集合基本稳定(写入一次、之后只读);
  • 多个 goroutine 读多写少,且各自访问的 key 比较分散。

其他场景下"普通 map + RWMutex"往往更简单、更快。一个读密集的并发基准测试:

BenchmarkSyncMapRead-24     	255427569	         0.8787 ns/op
BenchmarkMutexMapRead-24    	 5672698	        37.44 ns/op

(100 个 key,读同一个 key,24 个并行 goroutine;数字随机器不同。)

sync.Map 的类型是 any,取出来要断言,也失去了编译期类型检查。能用泛型 + 锁封装的场景,优先自己封装。


1.7 sync.Pool

sync.Pool 用来复用临时对象,减少分配和 GC 压力:

pool := sync.Pool{
	New: func() any {
		buf := make([]byte, 0, 1024)
		return &buf
	},
}

b1 := pool.Get().(*[]byte)
*b1 = append(*b1, 'a')
pool.Put(b1)

b2 := pool.Get().(*[]byte)
fmt.Printf("从池中取出: len=%d cap=%d\n", len(*b2), cap(*b2))
从池中取出: len=1 cap=1024

注意两点:

  1. Get 不保证返回什么——池里的对象可能被 GC 清空,此时会调用 New 新建;
  2. GC 会清空池,所以不能把 Pool 当作缓存用,它只适合"临时对象的回收站";
  3. 放回池前要重置对象状态(比如把切片截断成 [:0]),否则会读到上一次的数据。

标准库的 fmt、encoding/json 内部都用 Pool 复用缓冲区。


1.8 选型总结

场景 推荐
保护一段共享数据的读写 sync.Mutex
读远多于写 sync.RWMutex
单个计数器、标志位的更新 sync/atomic
key 稳定、读多写少的并发 map sync.Map
复用临时对象 sync.Pool
在 goroutine 间传递数据/所有权 channel
取消、超时、传递请求元数据 context(见 context)

注:能不用锁就不用锁——先想清楚数据是不是可以只在单个 goroutine 里访问,或者用 channel 传递所有权。锁是最后的手段,不是第一选择。


1.9 练习

  1. 用 atomic.Int64 重写竞态示例里的计数器,再用 -race 验证没有告警。
  2. 写一个带读写锁的缓存,提供 Get、Set、Delete 三个方法,用并发测试验证正确性。
  3. 用 sync.Pool 复用 bytes.Buffer,写一个拼接大量字符串的函数,对比使用池与不使用池的分配次数(testing.B 的 b.ReportAllocs())。