共享内存还是要用锁:sync 与竞态
上一章的谚语容易被念歪成教条:「Go 里必须用 channel,用锁就是不地道。」不对。「用通信代替共享内存」是一句主张,不是禁令 —— 有些场合,老老实实共享内存 + 加锁反而更简单、更清楚。这一章讲 sync 包、什么是数据竞态、Go 那个能救你命的 -race 检测器,以及最实际的问题:到底什么时候用锁、什么时候用 channel。
数据竞态:并发 bug 的根源
先说清要防的是什么。数据竞态(data race):两个或更多 goroutine 同时访问同一块内存,且至少有一个是写,还没有同步保护。经典的例子:
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter++ // 1000 个 goroutine 同时改同一个 counter —— 竞态!
}()
}
// 最后 counter 是多少?不一定是 1000,可能是 973、988…… 每次都不同
为什么会丢?因为 counter++ 不是一步完成的 —— 它是「读出当前值、加一、写回去」三步。两个 goroutine 可能同时读到 5,各自加一、各自写回 6 —— 两次自增只涨了一个。数据竞态导致结果不确定、时有时无、极难复现,是最折磨人的一类 bug。(它底层还牵扯 CPU 缓存、内存可见性、指令重排 —— 这些机制《同时》那本书讲得很透,这里我们聚焦「怎么防」。)
sync.Mutex:最基本的锁
最直接的防法:用一把锁,保证同一时刻只有一个 goroutine 能碰那块共享数据:
type Counter struct {
mu sync.Mutex // 零值可用,不用初始化(第 4 章)
n int
}
func (c *Counter) Inc() {
c.mu.Lock() // 上锁:别人得等
defer c.mu.Unlock() // 用 defer 保证一定解锁(第 16 章)
c.n++ // 这段临界区同一时刻只有一个 goroutine 在跑
}
Lock() / Unlock() 之间的代码叫临界区,被锁保护,串行执行,竞态消除。defer c.mu.Unlock() 是标配 —— 保证不管函数怎么返回都解锁,绝不会漏(漏解锁 = 死锁,全员卡死)。注意接收者是指针 *Counter(要改字段 + Mutex 不能被复制,回想第 10 章)。
如果读远多于写,用 sync.RWMutex —— 它区分读锁和写锁:多个读可以同时进行(读不改数据,不冲突),写则独占。读多写少的场景性能更好。
sync 包里另外几件常用工具
| 工具 | 干嘛的 |
|---|---|
sync.WaitGroup | 等一组 goroutine 全干完(第 17 章讲过) |
sync.Once | 保证某段代码只执行一次(并发安全的懒初始化,比如单例) |
sync.Map | 并发安全的 map(但普通 map + Mutex 通常就够,别默认用它) |
atomic.AddInt64 等 | 对单个数字的原子操作,比锁更轻,适合纯计数器 / 标志位 |
sync.Once 特别顺手 —— 上面那个 counter 竞态如果是「初始化只做一次」的场景,once.Do(func(){ ... }) 保证无论多少 goroutine 同时调,里面那段只跑一次。atomic 则是给「就一个 int 计数器」这种简单场景的轻量方案,连锁都省了。
救命的 -race:让竞态自己现形
数据竞态最可怕的是它隐蔽 —— 平时跑一万次都对,上线高并发时偶尔错一次,你根本抓不到。Go 给了一件神器:竞态检测器。跑测试或程序时加 -race:
$ go test -race ./... $ go run -race main.go
它会在运行时监视所有内存访问,一旦发现「两个 goroutine 没同步地访问同一块内存、且有写」,立刻打印一份详细报告 —— 哪两个 goroutine、在哪一行、访问了哪个变量:
==================
WARNING: DATA RACE
Write at 0x00c0000b4008 by goroutine 7:
main.main.func1()
/app/main.go:10 +0x30
Previous read at 0x00c0000b4008 by goroutine 6:
...
==================
-race 是 Go 送你的最好礼物之一 —— 它把「不确定、时有时无、生产才炸」的竞态 bug,变成了「开发时就当场报警、精确到行」的确定性错误。它能抓到你肉眼绝对看不出来的竞态。
代价是加了 -race 后程序会慢好几倍、吃更多内存(因为它在监视每次内存访问),所以不用在生产环境常开。标准做法:CI 里所有测试都用 go test -race 跑一遍。只要你的测试覆盖到了并发路径,它就能在合并代码前替你揪出竞态。这条几乎是 Go 工程的最佳实践红线 —— 别省这一步。
最实际的问题:锁还是 channel?
回到本章开头。谚语说「用通信代替共享内存」,但锁也没被删掉、就摆在标准库里。到底什么时候用哪个?一个务实的判断:
| 场景 | 更适合 |
|---|---|
| 保护一小块共享状态(计数器、缓存、配置) | 锁(Mutex / atomic)—— 简单直接,别为它套 channel |
| 在 goroutine 之间传递数据 / 所有权 | channel —— 数据流动,天然无竞态 |
| 编排流程:流水线、扇出扇入、事件 | channel —— 表达「谁把活交给谁」 |
| 只是一个「做没做过」的标志位 | atomic 或 Once |
一句话心法(也是 Go 官方 wiki 的说法):「要传递数据的所有权,用 channel;要保护一块共享状态,用锁。」别把「保护一个计数器」硬写成 channel(那反而绕、慢、难懂),也别用一堆锁去手工编排本该用 channel 表达的数据流水线。它们是两个工具,各有各的活。「用通信代替共享内存」是在说「优先考虑数据流动这种思路」,不是说「禁止用锁」。
为什么 counter++ 不原子、CPU 缓存行怎么导致可见性问题、指令重排和 happens-before、内存屏障 —— 这些「竞态到底在硬件层面怎么发生」的机制,是并发最硬核的部分。本书讲到「用锁 / atomic / channel 防住它」就够你写对代码了;想理解它为什么会发生、想看竞态被一步步复现出来,站内《同时》那本书有专门的竞态复现器和内存模型章节,围绕 Kotlin / Go / Python 三家讲。这是本书和《同时》分工的又一处:这里教你怎么写对,那里教你底层为什么。
Kotlin 对照
概念几乎一一对应:Kotlin/Java 的 synchronized / ReentrantLock → Go 的 sync.Mutex;ReadWriteLock → sync.RWMutex;AtomicInteger → sync/atomic;懒初始化的 by lazy / double-checked locking → sync.Once。你的锁知识基本能直接迁过来。
两个差别值得注意。一,Go 的 Mutex 不可重入 —— 同一个 goroutine 重复 Lock 同一把锁会死锁(Java 的 synchronized / ReentrantLock 是可重入的)。所以别在持锁时调用又会去抢同一把锁的方法。二,那个 -race 检测器是 Go 的一大优势 —— JVM 上抓数据竞态要靠专门工具、且没这么顺手;Go 把竞态检测做成了工具链一条命令,这是它「工具链即语言」哲学(第 3 章、第 23 章)的又一个甜头。
心态上最大的不同:在 JVM 上,「共享可变状态 + 锁」是并发的默认起手式,channel 类的东西是补充。在 Go 里,顺序反过来 —— 优先想「能不能用 goroutine + channel 让数据流动起来、避免共享」,实在是「保护一小块共享状态」才落到锁。不是不用锁,是把锁从「默认方案」降级成了「针对特定场景的工具」。这个优先级的调换,就是你从 JVM 并发迁到 Go 并发要完成的最后一步。
这一章的一句话
「用通信代替共享内存」是主张不是禁令——传递数据用 channel,保护一小块共享状态就老实用 sync.Mutex / atomic / Once;数据竞态是并发头号 bug,而 Go 的 -race 检测器能把它从「生产才炸」变成「开发就报警、精确到行」,务必进 CI;底层为什么会竞态,去看《同时》。
卷 V 结束 —— 你已经拿到了 Go 那份并发大礼的完整用法。最后一卷卷 VI,我们回到工程与落地:泛型(Go 忍了十年的那个)、测试与工具链、以及一张送你上路的 Kotlin→Go 迁移对照表。