09 sync 全家桶:Mutex 自旋与饥饿、Once、WaitGroup、atomic 内存序

开篇提问

先看一个几乎人人都写过的模式:

var mu sync.Mutex
var counter int
 
func inc() {
    mu.Lock()
    counter++
    mu.Unlock()
}

问题:当几十个 goroutine 同时抢这一把 mu.Lock() 时,没抢到的那些 goroutine,在干什么?

A. 空转(自旋),忙等 CPU B. 立刻睡过去,等唤醒 C. 先自旋一小会儿,抢不到再睡

答案是 C。但这里藏着一个更深的问题:为什么是”自旋一小会儿再睡”,而不是直接睡?自旋多久算合适?如果抢锁的 goroutine 太多、大家都不让,会发生什么?

这一章,我们拆开 Go 的 sync 包,从最底层的 Mutex,讲到 Once、WaitGroup、atomic,最后落到一个关键概念——内存序。


子主题一:Mutex 的三层策略——自旋、信号量、饥饿

Go 的 sync.Mutex 不是一把简单的”加锁/解锁”,它的内部是一个精心设计的状态机,有三种策略协作:

策略一:自旋(spin)。 当一个 goroutine 抢锁失败时,它不会立刻睡下,而是空转一会儿(反复检查锁是否释放)。为什么?因为”睡下再唤醒”要走操作系统调度,代价很高(微秒级);如果锁很快就被释放(临界区很短),空转几纳秒就等到了,比”睡下再醒来”便宜得多。

但自旋不能太久。 自旋在烧 CPU,如果锁迟迟不释放,空转就是浪费。所以 Go 的自旋有严格限制:只在”多核、且自旋次数有限(几十次)、且锁确实处于竞争态”时才自旋。

策略二:信号量(semaphore)。 自旋抢不到,goroutine 就”认怂”,把自己挂到操作系统的信号量上睡过去,让出 CPU。等锁释放时,再唤醒一个等待者。这对应”没抢到就睡”的阶段。

策略三:饥饿模式(starvation mode)。 这是 Go Mutex 最体现设计功力的一处。想象一个场景:一个 goroutine 睡了很久才被唤醒,它慢悠悠地醒来、准备抢锁;此时一个新来的 goroutine 正好在自旋,它”手快”,一把抢走了锁。这个睡醒的老实人,可能永远抢不过那些”新鲜、正在自旋”的新 goroutine——它被饿死了。

Go 1.9 引入了饥饿模式来解决这个问题:如果一个等待者等得太久(超过 1ms),Mutex 就切换到饥饿模式,把锁直接”递”给等待最久的那个 goroutine(FIFO),不再让新来的自旋者插队。等这个老实人拿到锁,再视情况退出饥饿模式。

这背后是一个经典的调度哲学:正常模式下,宁可牺牲公平换吞吐(让自旋者快速接手,减少切换);但当”不公平”导致某个 goroutine 饿死时,就要切回公平模式(FIFO)。 公平和吞吐,是一对永恒的张力,Go 的 Mutex 用”两段式”策略动态平衡。


子主题二:sync.Once——只执行一次的并发安全

sync.Once 解决的是”某个初始化只执行一次”的问题:

var once sync.Once
once.Do(func() { /* 初始化 */ })

不管多少个 goroutine 同时调 once.Do,传入的函数只会执行一次,其他 goroutine 会阻塞等它执行完。

它的实现精妙在哪?在于 “fast path + slow path” 的经典结构:

  • fast path:用一个 atomic 变量(done 标记)判断”是否已经执行过”。如果 done == 1,直接返回,无锁。
  • slow path:如果 done == 0,才加锁,再次检查(double-check),没执行过就执行,然后 atomic 置 done = 1。

这里有两个关键点:

第一,double-checked locking。 加锁后再检查一次 done,防止”两个 goroutine 都通过了第一次检查、都去加锁,结果执行了两次”。

第二,内存序保证。 done 用 atomic 写,且 once.Do 保证了”函数执行的所有副作用,对 Do 返回后的所有 goroutine 可见”。也就是说,任何 goroutine 看到 done==1 时,初始化函数的副作用一定已经完成——这是 Once 的 happens-before 保证,你不用担心”看到 done 了但初始化还没做完”。

Once 是”懒加载 + 并发安全”的教科书实现,它内部的 fast path(atomic 读)让”已经初始化后”的调用几乎零成本,这是它比”手动加锁”高明的地方。


子主题三:sync.WaitGroup——等待一组 goroutine 完成

WaitGroup 解决的是”等待一组 goroutine 全部完成”:

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 干活
    }()
}
wg.Wait()   // 等 10 个 goroutine 都 Done

它内部就是一个计数器(atomic)+ 信号量(等待者睡在信号量上,计数归零时唤醒)。

两个最常见的坑,务必记住:

坑一:Add 必须在 Wait 之前调用完(且不能在 goroutine 里并发 Add)。 正确用法是”先 Add 再 go”,或者至少保证”Add 发生在 Wait 观察到计数归零之前”。如果你在 goroutine 里 Add(1),可能 Wait 已经看到计数为 0 就返回了,新 Add 的 goroutine 没人等——这是数据竞争。

坑二:Done() 只能 Add 负数一次,不能把计数减到负数。 Add(-1) 使计数变负会 panic。所以 Add(1) 和 Done() 要严格配对。


子主题四:atomic 与内存序——真正的底层

sync/atomic 提供的是无锁的原子操作:atomic.AddInt64、atomic.LoadInt64、atomic.StoreInt64、atomic.CompareAndSwapInt64(CAS)等。

它的意义在两点:

第一,原子性。 一个 64 位变量的读写在 32 位平台上,可能被拆成两次 32 位读写,导致读到”撕裂”的值。atomic 保证读/写/加是不可分割的。

第二,内存序(memory ordering)。 这是更深的一层。现代 CPU 和编译器为了性能,会重排指令:你写的代码顺序,不等于实际执行顺序。atomic 操作提供了内存屏障,保证”某个 atomic 写之前的操作,不会被重排到它之后”(反之亦然)。

这里必须引入一个关键概念:Go 的 atomic 操作默认是”顺序一致(sequentially consistent)“的——它比 C++ 的 memory_order_relaxed 更严格。这意味着 Go 的 atomic 用起来更简单、更不易错,但代价是比”放松内存序”的 C++ atomic 略慢。Go 选择”默认强保证”,又是一次”用一点点性能换安全简单”的取舍。

什么时候用 atomic 而不是 Mutex? 经验法则:单个计数器的加减、单个标志位的读写、单个指针的 CAS,用 atomic;多个变量的组合操作、需要保护一段临界区,用 Mutex。 atomic 快(无锁、不阻塞),但只能保护”单个变量”;Mutex 慢一点,但能保护”一段逻辑”。选择的关键是”你要保护的是一个值,还是一段过程”。


子主题五:锁的本质——为什么锁也是”通信”

学完这些,回到一个哲学层面:锁和 channel,本质上是同一件事的两种表达——同步。

  • 锁:通过”互斥”来同步——大家约定”同一时刻只能一个人进临界区”。
  • channel:通过”交接”来同步——数据所有权随消息转移。

它们都要解决同一个底层问题:多个执行流之间,怎么保证”我对内存的修改,能被别人正确看到”(可见性)和”不会互相踩”(互斥)。

理解了这层,你就能跳出”锁 vs channel”的伪对立:它们都是”同步原语”,选哪个取决于你要表达的是”保护一块共享状态”(锁)还是”传递数据所有权”(channel)。 Go 同时给你两套工具,且都做得很扎实,这才是它并发能力强的原因——不是”channel 天下第一”,而是”该用什么用什么,且每个都足够好用”。


子主题六:Mutex 状态机——normal 与 starvation 到底怎么切

上一节我们说 Mutex 有”自旋→睡眠→饥饿”三层。现在把它剖开。先别急着看代码,先回答一个问题:

如果让你设计一把锁,你会怎么表达它的”当前状态”?至少需要几样信息?

  • 锁现在有没有人拿着?(locked)
  • 有没有人在排队?排了几个?(waiter 数量)
  • 现在是不是在处理”有人被饿死”的紧急状态?(starvation 标志)

就这三样。Go 的实现把这三样信息塞进同一个整型状态字里,用一次原子操作就能同时读改——这就是为什么 Mutex 是 8 字节、为什么 sync.Mutex 零值可用(零值 = 未加锁、无等待者、非饥饿,正好是”干净的新锁”)。

注意:我特意不去列举这个状态字里哪一位是 locked、哪几位是 waiter 计数。因为那是内部实现、随时可能变,你依赖它就是给自己埋雷。要记住的是语义,不是位布局。

除了这三个,实现上还需要一两样辅助信息,其中最重要的一样是:

“是否已经有等待者被唤醒、正在抢锁”(woken 语义)。 为什么需要它?想象解锁的瞬间:如果队首的等待者已经被唤醒、正在 CAS 抢锁,那解锁方还去唤醒第二个等待者干什么?两个醒着的人抢一把锁,只会让第二个白醒一次(一次徒劳的调度 + 一次失败的 CAS)。这个标志就是用来避免重复唤醒的。

好,状态清楚了,来看两套模式。

normal 模式:吞吐优先,允许”插队”

normal 模式下的规则是:新来的 goroutine 可以和队首的等待者竞争,而且新来的赢面更大。

凭什么新来的赢?这不是偏心,是物理规律决定的:

  1. 新来的 goroutine 正在 CPU 上跑(它可能刚自旋完,热乎的;或者根本没自旋,一次 CAS 直接命中);
  2. 队首的等待者还睡在信号量上,它要先被唤醒、被调度器放到某个 P 上、恢复上下文,然后才执行 CAS。

一个在跑,一个还在穿衣服。 谁先摸到锁,用脚趾头都想得出来。

这个”不公平”是有意为之的。因为让”正跑着的人”直接接手锁,可以省掉一次完整的线程/goroutine 切换——这一次切换的成本是微秒级,而整个临界区可能只有几百纳秒。如果每次都严格 FIFO,那么”锁传递”的开销会比”执行临界区”还贵,吞吐直接腰斩。

所以 normal 模式的哲学是:牺牲公平换吞吐。

切换条件:等太久 → 拉响警报

那”公平”就完全不要了吗?不要。

Go 的做法是给每个等待者计时。一个等待者被唤醒后,会算一下”我从开始等(或从上次被唤醒)到现在,多久了”。如果这个值超过了一个阈值(源码里是一个常量,约 1 毫秒),它就把状态字里的 starvation 标志打开。

一旦进入 starvation 模式,规则彻底反转:

  • 新来的 goroutine 不许自旋、不许抢。管你手多快,一进门发现是饥饿模式,直接去队尾排队睡觉。
  • 解锁方不再”释放锁让别人抢”,而是”把锁直接递给队首”(handoff)。实现上就是:解锁时不清除 locked 位,而是直接信号量唤醒一个等待者,那个等待者醒来就知道”这把锁现在是你的了”,连 CAS 都不用做。

这就是 FIFO。锁的所有权像接力棒一样被传递,不存在竞争窗口。

类比一下:normal 模式是地铁早高峰挤门——谁力气大谁先上,整体通过率最高;starvation 模式是银行叫号——你拿的是 37 号,前面还有 12 个人,你就老老实实坐着,不许插队。地铁模式在人流可控时效率最高,但真有人被挤到永远上不去时,就必须换成叫号。

退出条件:两个,满足其一

饥饿模式不能一直待着——它是”急救措施”,代价是没有并发、没有吞吐(锁只能一个人一个人地传,无法被”正在跑的人”接手)。所以有两个退出条件:

  1. 等待队列空了。 当前这个被递锁的等待者,发现自己是最后一个(waiter 计数为 0 了),那还 FIFO 个什么劲?退回 normal。
  2. 队首等待者的等待时间已经小于阈值(约 1ms)。 说明”积压”已经被消化完了,队伍不再饥渴,退回 normal 换吞吐。

于是整条状态机就是:

normal(抢,吞吐优先)
   │  等待者等待时间 > 约 1ms
   ▼
starvation(递,FIFO 公平优先)
   │  队列空  或  队首等待时间 < 约 1ms
   ▼
normal

注意这条曲线是个”滞回”结构:进入的门槛和退出的门槛不是同一个瞬间,中间有一段”粘性”。这是所有自适应系统的通用手法——如果进出的判定条件完全相同,状态就会在临界点上疯狂抖动,反而更糟。TCP 的拥塞控制、JVM 的 GC 启发式、你的空调温控,全都是这个套路。

waiter 计数的三重作用

现在回头看”等待者数量”这个字段,它至少干三件事:

  1. 决定解锁时是否要走”唤醒”这条慢路。 解锁的 fast path 是:一次原子操作清掉 locked 位,然后检查有没有等待者——没有,直接返回。这意味着无竞争场景下的 Unlock 根本不碰信号量、不做系统调用,成本低到只有几十纳秒。反之,如果有人等着,才走信号量唤醒。
  2. 决定能否退出饥饿模式。 队列空不空,就靠这个计数。
  3. 饥饿模式下判断”还有没有人等着被递锁”。 递锁是一个个来的,递完了就回归自由竞争。

一个反直觉的结论由此而来:读多写少、几乎不竞争的锁,加解锁接近免费的。 你真正要害怕的从来不是”加锁”,而是”抢锁”。

自旋的完整前置条件

补充一下自旋的准入条件(只说条件,不谈具体次数——那个数是内部调优参数,会变):

  • 必须多核(P 的数量 > 1)。 单核自旋是纯粹的自残:你占着唯一的 CPU 空转,持锁的人根本没机会运行去释放锁,等于白烧掉一个调度周期。
  • 锁确实处于竞争态,且持有者正在运行。 如果持锁的 goroutine 自己都睡了,你转晕了它也醒不来。
  • 同时自旋的线程数量有限。 runtime 会限制全局自旋的线程数(大致与 P 的数量挂钩),否则几十个线程一起空转,CPU 直接被锁的等待者吃光。
  • 自旋循环里会插入 CPU 的 PAUSE 一类指令,让出流水线、降低功耗,同时减少对锁变量所在 cache line 的无谓争抢(不然自旋者自己就成了持锁者释放锁的阻碍)。
  • 次数有限。 转几次发现没戏,立刻认怂去睡。

三件”文档里写了但你八成没读”的事

  1. Mutex 不是可重入锁。 同一个 goroutine 连续 Lock() 两次,第二次会把自己锁死。需要重入?说明你的抽象分层有问题,重新设计。
  2. Mutex 不绑定 goroutine。 文档明确写了:允许 A goroutine 加锁、B goroutine 解锁。技术上可行,但这是一次显式的所有权交接,只有当你非常清楚自己在做什么时才用(比如某些异步完成语义)。默认当它不存在。
  3. 解锁一把没持有的锁会 panic。 Unlock 一个未加锁或被别人持有的 Mutex,直接 sync: unlock of unlocked mutex。

TryLock:一把你多半不该用的钥匙

Go 1.18 给 Mutex 和 RWMutex 加了 TryLock——抢得到就返回 true,抢不到立刻返回 false,绝不阻塞。

它解决的是什么?是**“我本来就不打算等”**的场景:比如后台的定时清理任务路过看一眼,锁被占着我就下次再来,绝不能因为我让主流程卡住。

它的文档里有一句很克制但很重要的提醒:别把 TryLock 当成主同步手段,也别用它写”随机退避重试”的业务逻辑。 为什么?因为 TryLock 失败不代表锁被持有——它可能只是在饥饿模式下排了队,或者锁此刻恰好空闲但被 handoff 出去了。用 TryLock 写”我以为我没拿到锁所以我跳过这个任务”的逻辑,迟早漏数据。

一句话:TryLock 是逃生舱,不是正门。


子主题七:RWMutex——读共享写独占,以及它埋的四个坑

假设你在写一个服务,内存里缓存了一张配置表:一万个 goroutine 在读,运维一天改两次。

用 Mutex?那一万个读者也要排成一队,一个一个进。荒谬吗?太荒谬了——读操作根本不修改数据,读者之间没有任何冲突,凭什么互斥?

问题来了:能不能让所有读者同时进,只有写者独占? 这就是 sync.RWMutex 的命题。

它的语义很直白:

  • 读锁(RLock/RUnlock):可以被任意多个 goroutine 同时持有,读者之间不互斥。
  • 写锁(Lock/Unlock):完全独占,和所有读锁、所有其他写锁都互斥。

内部大致靠三样东西支撑:一个读者计数(当前有几个读者在里面)、一个写者互斥/信号量、以及一个”有写者在等”的信号。

听起来很美。但坑,也全在这三样东西上。

坑一:写者到来,会阻塞新来的读者

这是 RWMutex 最反直觉、也最重要的行为:一旦有写者在排队等待,后续所有的 RLock 都会被阻塞,直到这个写者拿到锁、做完事、释放锁为止。

官方文档原话:如果一个 goroutine 持有读锁,另一个 goroutine 调用 Lock,那么后续到来的 RLock 会被阻塞,直到先前的读锁释放。

为什么要这么设计? 反过来想:如果不阻止新读者会怎样?

想象一条无限流的读者长龙,每个人读 10 微秒,但每微秒都有新读者加入。写者站在门口等”所有读者走光”——这个时刻永远不会到来。写者被饿死了,配置和现实的偏差无限扩大。

生活类比:公共厕所要换纸。管理员站在门口举着牌子**“暂停进入”**——已经在里面的人可以出来,但新人别进了。等里面清空,管理员进去三秒钟换完纸,再放行。如果不拦新人,换纸这件事永远发生不了。

所以:RWMutex 是”写者优先”的(writer preference)。这是防止写饥饿的必要代价。

代价是什么?写者会制造一次”读停顿尖刺”。在写者等待期间,所有新读者都被卡住。如果你的系统对读延迟的 P99 很敏感、而写操作又比较频繁,RWMutex 会把延迟曲线撕出锯齿。

坑二:递归读锁会把自己锁死

这是 Go 面试的常青题,也是线上真会发生的 bug:

var mu sync.RWMutex
 
func load(key string) (string, bool) {   // 内部会 RLock
    mu.RLock()
    defer mu.RUnlock()
    v, ok := cache[key]
    return v, ok
}
 
func loadWithDefault(key string) string {
    mu.RLock()
    defer mu.RUnlock()
    if v, ok := load(key); ok {   // ← 又 RLock 一次!
        return v
    }
    return "default"
}

看起来人畜无害:读锁不是可共享的吗?嵌套两遍能有什么事?

出事的时机是:中间恰好插进来一个写者。

  1. loadWithDefault 拿到读锁(读者数 = 1);
  2. 某个写者调用 Lock(),开始等待,并且点亮”有写者在等”的信号;
  3. loadWithDefault 内部调用 load,再次 RLock()——因为有写者在等,这次 RLock 被阻塞;
  4. 写者要等”读者数归零”才进得去,而 loadWithDefault 的第一次读锁要等 load 返回才释放……

读者等写者,写者等读者。死锁。

而且它只在有写者恰好插入时才触发——所以它在你的测试环境里跑一万次都好好的,上线两周后在凌晨三点的流量低谷(恰好是配置刷新时段)崩一次。这种 bug 能让你怀疑人生。

怎么修?

  • 最根本的:锁的持有范围不要跨函数。锁应该在”知道全貌的那一层”加,被调函数写成”不加锁的裸实现”(通常命名为 xxxLocked),由调用方负责加锁。这是标准做法,看 Go 标准库源码里满是 fooLocked() 就是这个道理。
  • 如果确实需要分层,就用 RLocker() 把读锁传给下层,或者干脆重构掉嵌套。

顺带一提:LoadWithDefault 这种”先读、读不到再写”的模式,本身就是锁升级(upgrade)的诱惑——而 RWMutex 不支持从读锁升级成写锁。你不能在持有读锁时去 Lock(),那必然死锁。正确做法是:先 RUnlock,再 Lock,进去之后重新检查一遍条件(因为中间可能已经被别人改了)。这个模式叫 double-check,和 sync.Once 里那个是同一个思想。

坑三:RWMutex 不总是比 Mutex 快

这大概是最大的误解。很多人看到”读多写少”四个字,条件反射就把 Mutex 换成 RWMutex。别。

算笔账:

  • RLock 不是免费的。 它要原子地给”读者计数”加一,RUnlock 再减一。这个计数器是所有读者共享的一个 cache line。一百个核同时去加同一个数字,这就是教科书级的 cache line 争用(false sharing 的真共享版)——每一次加一都要独占那个缓存行,代价和一次 CAS 差不多。
  • Lock(写锁)比 Mutex 贵得多。 它要标记”有写者在等”、等所有读者退出、操作信号量、醒来再清理标记。RWMutex 的写路径明显长于 Mutex。
  • 如果临界区极短(比如一次 map 查找 + 返回),RWMutex 的额外簿记开销可能完全吃掉”读者并发”带来的收益,净结果是更慢。

粗略判据(别当圣旨,最终以 benchmark 为准):

  • 读写比高到 9:1 甚至更高,且读临界区不是几纳秒级别的操作(比如要遍历、要序列化、要拷贝),RWMutex 才划得来;
  • 临界区极短 → 优先 Mutex,甚至考虑 atomic;
  • 读写比没那么夸张 → Mutex 更简单、更可预测。

记住这条元规则:任何”我觉得 X 更快”都必须翻译成”我 benchmark 过 X 更快”。 写两个 Benchmark,一个 Mutex 一个 RWMutex,用 -cpu=1,4,8,16 各跑一遍。你会发现答案完全取决于核数和临界区长度,根本没有通用结论。

坑四:和 Mutex 共享的那几条铁律

  • RWMutex 同样不可复制,复制后语义全乱(go vet 的 copylocks 会抓)。
  • RUnlock 一个没持有的读锁、Unlock 一个没持有的写锁 → panic。
  • 写锁持有期间不要调用用户回调、不要做 IO、不要递归。
  • RLocker() 返回一个 sync.Locker 接口(只有 Lock/Unlock),方便你把读锁传给”只需要加解锁语义”的通用代码。

那 RWMutex 的正确打开方式是什么?

一句话:保护的粒度是”一份数据结构”,而读操作占比压倒性地高,且读操作本身有一定长度。 典型场景:内存配置中心的快照、路由表、元数据缓存。

而且——最有效的优化往往不是换锁,而是”不共享”。下一节讲 atomic,再下一节讲性能,你会看到:配置热更新用 atomic.Value 或 atomic.Pointer[T] 存一个不可变快照,读路径连锁都不用,这才是最优解。RWMutex 是”必须共享可变结构”时的次优选择。


子主题八:atomic 操作族的完整图谱——Add / Load / Store / Swap / CAS

sync/atomic 一共就那么几个操作,但绝大多数人只用过 AddInt64 和 LoadInt64,另外三个压根不知道存在的意义。这一节把这五个全讲透。

先建立一个心智模型:atomic 是一个”一次只能做一个动作、但绝不会被打断”的最小同步单元。

对照生活:Mutex 是”锁上门的车间”,你在里面可以干一整套活;atomic 是”一把只能拧一颗螺丝的电动起子”,一按下去,一颗螺丝到位,但你没法用它组装一台机器。

一、Add:计数,以及一个反直觉的用法

var total atomic.Int64
total.Add(1)        // 返回新值
total.Add(-1)

Add 的语义:原子地做 new = old + delta,并返回 new。

用途显然:计数器、累加器、统计指标(QPS、错误数、字节数)。

反直觉用法:无符号数的减法。 atomic.Uint64 没有 Sub,但你可以加一个”补码”:

var inflight atomic.Uint64
inflight.Add(1)                     // 加一
inflight.Add(^uint64(0))            // 减一:加一个"全 1",在无符号算术里等价于减 1
inflight.Add(^uint64(n - 1))        // 减 n

因为无符号整数是模 2^64 的环,加 2^64 - n 就等于减 n。这不是 hack,这是标准做法,Go 标准库内部就这么写。

Add 的一个陷阱: 它返回的是新值,不是旧值。想拿旧值?用 Swap(见下)或者 Add 完再 Add 回去(别,用 Swap)。

二、Load 与 Store:为什么”普通读写”不行

这是最需要讲清楚的一对。

var ready atomic.Bool
// goroutine A
ready.Store(true)
// goroutine B
if ready.Load() { /* ... */ }

反问:我直接用 var ready bool 然后 ready = true / if ready 不行吗?

三个理由,一个比一个致命:

理由 1:撕裂(torn read)。 在 32 位平台上,一个 int64 的读写要拆成两次 32 位操作。写的一半、读的一半,中间被另一个 goroutine 插进来,你读到的就是一个从来没有存在过的值(高 32 位是新的,低 32 位是旧的)。atomic 保证整个 64 位是原子的。

理由 2:编译器优化。 这个更隐蔽,而且在 64 位平台上照样中招。看这段代码:

var stop bool
 
func worker() {
    for !stop {   // 编译器:stop 在循环里没被改过?那我把它提到寄存器里,只读一次
        doWork()
    }
}

编译器完全有权利认为”这个循环里没人改 stop”,于是把它优化成”读一次,然后无限循环”。你在另一个 goroutine 里 stop = true,worker 永远看不到。 这不是理论——这是每个写过多线程代码的人都踩过的坑。

Go 编译器不会随便做这种激进优化(Go 的数据竞争是 undefined-ish,但 Go 团队刻意保持编译器的保守),可是一旦优化器变聪明、或者内联之后寄存器分配刚好这么做,你的程序就从”能跑”变成”偶尔卡死”。 依赖”编译器现在还比较笨”来写正确的并发代码,是把脑袋别在裤腰带上。

理由 3:内存序。 即使读写都是原子的、也没被优化掉,你还需要保证**“写这个标志之前做的所有事,对读到这个标志的人可见”。普通读写不提供任何顺序保证。atomic 操作提供——而且是顺序一致(seq_cst)**的强保证。

所以 Load/Store 的用途是:

  • 标志位:优雅退出开关、特性开关(feature flag)、熔断器状态;
  • 发布(publishing):把一个初始化好的对象”发布”出去,让其他 goroutine 安全地看到它;
  • 读取共享计数器:统计打点、监控采样。

记住这个组合: Store 用来发布,Load 用来订阅。发布方先把数据准备好,最后一步 Store(指针);订阅方 Load(指针),一旦拿到非空指针,数据必定完整可见。这就是一个最小可用的”发布-订阅”,连锁都不需要。

三、Swap:被严重低估的一个操作

old := ptr.Swap(newVal)   // 原子地:把新值写进去,把旧值交还给你

Swap = 一次原子完成 “读旧值 + 写新值”,中间不给你任何插队的缝隙。

它有什么用?凡是**“我要把某个槽位里的东西整个取走,换上新的”**的场景,Swap 都是最优解:

// 场景:抢占式地取走当前缓冲区,换一个新的空缓冲
buf := curBuf.Swap(make([]byte, 0, 4096))
// 现在 buf 是"我独占的旧缓冲",别人再来 Swap 拿到的就是新缓冲了
flush(buf)

这就是无锁日志/tracing 缓冲区的常见写法。用 Mutex 也能做,但 Swap 只要一条指令。

其他典型用途:

  • 标记”已处理”并拿回旧状态:if oldState.Swap(Closed) == Open { /* 只有第一个切换者能进来 */ } —— 这是一个一次性状态转换的原子实现,比 CAS 写起来还顺手;
  • 实现自旋锁 / 简单的无锁结构;
  • 把值”摘下来”:比如无锁栈的 pop 之后清理。

Swap 的心智模型:一手交钱、一手交货,而且是在同一个瞬间。

四、CAS:乐观并发的发动机

ok := val.CompareAndSwap(old, new)   // 只有"当前值 == old"时才写成 new

CAS 是所有无锁算法的原子积木。它的语义是:“我以为现在是 A,如果是,就改成 B;如果不是,说明有人动过,我认栽。”

它和锁的哲学差异在哪?

  • 锁(悲观):“我怕你改,所以先把你挡在外面,改完再放你进来。”
  • CAS(乐观):“我猜你不会改,我先把活干完,提交的时候检查一下——没被改过就成交,被改过就重做。”

在低竞争下 CAS 完胜(不用进内核、不用上下文切换);在高竞争下 CAS 惨败(所有人都在重试,白烧 CPU,这就是”活锁”的雏形)。所以”无锁”不等于”更快”,无锁只是”在高竞争下不会有人被挂起”,代价是大家一起烧 CPU。

CAS 循环的正确骨架——请背下来:

for {
    old := counter.Load()
    newVal := compute(old)          // 关键:基于 old 计算,不能基于"我以为的值"
    if counter.CompareAndSwap(old, newVal) {
        break                        // 成功
    }
    // 失败:回到循环顶部,重新 Load。禁止用上一次的 old!
}

三个要点:

  1. 失败后必须重新 Load。 不要用上一次的 old 重试——那已经是一个过期的值,你只会再次失败(或者更糟:在你没注意的时候它碰巧又变回 old 了,你以为成功,其实语义错了)。
  2. compute 必须无副作用、可重入。 因为它可能被执行很多次。
  3. 循环可能永远不结束(理论上)。实践中如果不收敛,说明你的竞争太激烈,应该改用锁或分片。

五、CAS 的阿喀琉斯之踵:ABA 问题

考虑一个无锁栈:栈顶是 A。

  1. 你想 pop:读到 old = A,算出 new = A.next = B;
  2. 在你 CAS 之前,别的 goroutine 把 A 弹走,又 push 了 C,又 push 了一个新的 A(可能是从对象池里复用的同一块内存!);
  3. 现在栈顶还是 A。你的 CAS 检查”当前是不是 A”——是! 于是成功,栈顶被设成 B。
  4. 但 B 早就不是”现在的栈顶的下一个”了,栈被你破坏了。

值从 A → B → A,CAS 看到”A 没变”,但世界已经变了。这就是 ABA。

怎么解?

  • 版本号(tagged pointer):把”指针”和”版本计数”打包进一个字,每次修改版本 +1。CAS 比较的是”指针+版本”的组合体。经典解法,但有 128 位 CAS 的对齐要求,实现起来很讲究。
  • 不复用内存 / 用 GC 兜底:在 Go 里,因为有 GC,只要你还持有那个对象的引用,它就不会被回收并复用成”新的 A”。Go 的 GC 天然缓解了很大一部分 ABA 问题(C/C++ 里那个经典的”我 free 了又被 malloc 回来”的场景,在有 GC 的语言里不成立)。这是 Go 写无锁代码比 C++ 轻松的一个隐性优势。
  • 最实用的答案:别写无锁数据结构。 用 atomic.Value / atomic.Pointer[T] 存不可变对象,整个替换,不做原地修改。不可变 = 没有中间态 = 没有 ABA。 99% 的业务场景,这才是正解。

六、Go 1.19 的类型化 atomic:一次”防呆设计”

老写法:

var n int64
atomic.AddInt64(&n, 1)
atomic.LoadInt64(&n)

问题在哪?n 本身是个普通的 int64,编译器不会阻止你写 n++ 或 fmt.Println(n)。 一旦有人手滑写了一句普通读,数据竞争就悄悄溜进来了,而且这种模式极难 review。

Go 1.19 引入了类型化的原子类型:

var n atomic.Int64
var p atomic.Pointer[Config]
var b atomic.Bool
var u atomic.Uint64

好处有三:

  1. 防呆:类型本身没有普通读写方法,你只能调用 Load/Store/Add/Swap/CAS。想写出数据竞争?写不出来。
  2. 自动对齐:32 位平台上,64 位原子操作要求 8 字节对齐。老写法里这是个著名陷阱——你必须把 int64 放在结构体的第一个字段,否则在 32 位 ARM 上直接 panic(“unaligned 64-bit atomic operation”)。类型化 atomic 自己保证对齐,坑被埋掉了。
  3. 可读性:n.Load() 明显比 atomic.LoadInt64(&n) 干净。

结论:新代码一律用类型化 atomic,别再用老的 atomic.LoadXxx(&v) 函数族。 那套 API 还在,但已经不推荐了。

七、atomic.Value:任意值的原子容器

var config atomic.Value   // 存 *Config
 
config.Store(&Config{Timeout: 3 * time.Second})   // 发布
c := config.Load().(*Config)                       // 订阅,零成本

规则只有两条:

  • 类型必须一致。 先 Store 一个 *Config,再 Store 一个 *Rules → panic。空的 atomic.Value 首次 Store 时的类型被”锁定”。
  • 不能存 nil(Store(nil) 会 panic)。

用途:配置热更新。这是它的杀手级场景。读路径只有一次原子加载,不加锁、不阻塞、不 syscall,然后你拿到的是一个不可变的配置快照——读的过程中配置被改了?没关系,你手上这个旧快照依然完整自洽,用完就扔。

对比 RWMutex 方案: RWMutex 保护一个可变 config 结构,读的时候加锁、读完解锁,写的时候独占。atomic.Value 方案是写时整体替换、读时无锁快照。后者读性能高一到两个数量级,而且读者永远不会阻塞写者——写者只是替换指针,读者拿着旧对象继续用,GC 负责回收没人引用的旧快照。

这就是”用不可变性换并发安全”的经典套路。 函数式编程讲了几十年的东西,在这里变成了实打实的性能。

八、atomic 的边界:它保护不了一致性

最后一个反问,很重要:

var hits  atomic.Int64
var total atomic.Int64
 
func rate() float64 {
    return float64(hits.Load()) / float64(total.Load())
}

两个操作各自都是原子的。这个 rate() 是对的吗?

不对。 你在 t1 时刻读了 hits,在 t2 时刻读了 total,中间两个值都可能变了。你算出来的比率是一个根本不存在的时刻的快照。如果要求”同一瞬间的 hits 和 total”,atomic 做不到——因为 atomic 的原子性单位是”一个变量的一次操作”,不是”一组变量的一个快照”。

那怎么办?

  • 如果允许近似(绝大多数监控场景都允许)→ 无所谓,用它;
  • 如果必须严格一致 → 用 Mutex 保护一个结构体里的两个字段,一次加锁同时读两个;
  • 或者更妙:把 hits 和 total 打包进一个不可变对象,用 atomic.Pointer 整体替换(写入方构造新对象,读方一次 Load 拿到自洽快照)。

这就是”atomic vs Mutex”的真正分界线:不是”快不快”,而是**“你要保护的,是一个值,还是一组值之间的不变量(invariant)”**。


子主题九:WaitGroup 与 Once 的实现细节补全

上一章讲过它们的基本用法,这里补三个层面的东西:内部是怎么搭的、fast path/slow path 在哪、以及那些文档里写了但你没注意的规则。

WaitGroup:一个 64 位字里塞两件事

WaitGroup 的内部结构极其精简,核心就两样:

  1. 一个 64 位的状态字,里面打包了两个计数:任务计数器(还剩几个任务没 Done)和等待者计数(有几个 goroutine 正卡在 Wait 上)。标准做法是把 64 位劈成两半各用一个(高 32 位 + 低 32 位,具体布局是实现细节,别依赖)。
  2. 一个信号量,给等待者睡觉用。

为什么要把两个计数塞进同一个字?因为对它们的修改必须是原子的、而且是同时的。 如果你用两个变量,就会出现”计数器归零了、但等待者计数还没加进去”的中间态,等待者就此错过唤醒信号、永远睡死。打包成一个字,就能用一次 CAS 同时更新两者,中间态不存在。

这个技巧值得记住:当你需要原子地更新一组相关的状态时,要么加锁,要么把它们打包进一个字用 CAS。 这是无锁编程的基本手法。

三个方法的 fast/slow path:

方法fast pathslow path
Add(delta)一次原子加,把 delta 加到计数器上计数器归零时,唤醒所有等待者(走信号量)
Done()就是 Add(-1)同上
Wait()原子读状态字,若计数器已为 0,直接返回(不睡!)增加等待者计数,睡到信号量上,等被唤醒

注意 Wait() 的 fast path:如果所有任务已经 Done 了,Wait 根本不进内核,就是一次原子读。这是”先跑完再等”这种场景几乎零成本的原因。

三条规则,以及它们为什么是规则

规则一:Add 的正 delta 调用,如果发生在计数器为 0 时,必须先于 Wait。

这不是洁癖,是硬性的正确性要求。回到 fast path:如果 Wait 看到计数器是 0,它立刻返回。此时另一个 goroutine 慢悠悠地 Add(1) 并启动了一个任务——没人等它了。等它跑完调用 Done(),计数器变成 -1,panic:sync: negative WaitGroup counter。

所以”在 goroutine 里 Add”是绝对禁止的:

// ❌ 错误:Add 在 goroutine 内部
for i := 0; i < 10; i++ {
    go func() {
        wg.Add(1)          // 主 goroutine 的 Wait 可能已经返回了!
        defer wg.Done()
        work()
    }()
}
wg.Wait()
 
// ✅ 正确:先 Add 再 go
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        work()
    }()
}
wg.Wait()
 
// ✅ 也正确:一次性 Add,代码更简洁、也更快(少 N 次原子操作)
wg.Add(10)
for i := 0; i < 10; i++ {
    go func() {
        defer wg.Done()
        work()
    }()
}
wg.Wait()

顺带说一句:最后一个写法是最好的,一次原子加 vs 十次原子加,还顺便杜绝了”循环体内 panic 导致少 Add”的问题。

规则二:计数不能减到负数。 Add(-1) 让计数器变负 → 直接 panic。Add 和 Done 必须严格配对,用 defer wg.Done() 是唯一可靠的姿势(panic 路径也能扣减)。

规则三:复用 WaitGroup 是允许的,但新一轮的 Add 必须在上一轮 Wait 全部返回之后。 理由和规则一同源:Wait 的 fast path 是靠”读一次状态”来判断的,如果旧的 Wait 还没真正醒来、新的 Add 就已经把计数改了,状态就混了。实践中别复用,直接 var wg sync.WaitGroup 新建一个——这东西才 12 字节,不值得你为省一次分配去冒这个险。

WaitGroup 的两个常见误用

误用一:把 wg 当参数值传进函数。

func run(wg sync.WaitGroup) { /* ... */ }   // ❌ 复制了!go vet 会报警

WaitGroup 内部含 noCopy,go vet 的 copylocks 检查会直接报 “passes lock by value”。复制一个 WaitGroup 意味着”两个独立的计数器”,Done 作用在副本上,主 goroutine 永远等不到。永远传 *sync.WaitGroup。

误用二:闭包捕获循环变量。 (Go 1.22 之后循环变量语义改了,这个坑基本消失;但如果你维护的是老代码、或者 GOEXPERIMENT 没跟上,还是要留意。)

WaitGroup 到底”等”的是什么?

一个容易混淆的点:WaitGroup 等的是”计数器归零”这个事件,不是一个”计数器的值”。 你不能用它实现”等到计数降到 5 以下”、“等到某个条件成立”——那是 sync.Cond 的活。

而且:WaitGroup 不传递错误、不传递结果、不支持超时、不支持取消。 一旦你需要这些,就该上 errgroup(golang.org/x/sync/errgroup,它会传播第一个错误、支持 WithContext 做取消)——用 channel + WaitGroup 手搓错误收集,是每个 Go 程序员都干过、然后都后悔的事。

Once 的实现补全

Once 的结构简单到让人失望:一个 done uint32 + 一把 Mutex。

流程:

Do(f):
  fast path: atomic.Load(done) == 1 ? → 直接 return(无锁,零阻塞)
  slow path:
     加锁
     if done == 0:          ← double-check
         f()                ← 在持锁期间执行
         置 done = 1(atomic 写)
     解锁

四个必须讲清楚的点:

1. 为什么 fast path 能无锁? 因为 done 只有”0 → 1”这一个方向,永远不会从 1 变回 0。单向的状态转移 + 原子读 = 无锁安全。 这是并发设计里一个极其好用的模式:如果你的状态是单调的(只增不减、只前置不回退),读取方往往可以完全无锁。

2. 为什么需要 double-check? 因为”检查”和”加锁”之间有一道缝:两个 goroutine 可能同时读到 done == 0,然后排队进锁。第一个进去执行完、置了 done、解锁;第二个进去——如果不检查,就会再执行一次 f。所以必须加锁后再看一眼。这就是 double-checked locking。

3. done 为什么要在解锁之前用 atomic 写? 顺序很讲究:f() 的所有副作用 → atomic.Store(done, 1) → 解锁。如果在解锁之后才写 done,那么排在锁外面的 goroutine 拿到锁、进入 slow path 时可能看到 done 还是 0(因为写还没发生),于是它也要执行一遍 f ——而且此时没人拦得住它。先置 done 再放锁,保证了”任何后续进入者(无论走 fast path 还是 slow path)看到的 done 都是 1”。

配合 atomic 的顺序一致(seq_cst)语义,就有了 Once 的核心保证:任何 goroutine 看到 done == 1 时,f() 的所有副作用对它一定可见。 你永远不用担心”看到 done 了但初始化还没做完”。

4. 如果 f panic 了会怎样? 文档写得很明确:Do 认为 f 已经返回了,done 依然会被置为 1,后续调用不会重试 f。

这是个必须知道的行为——如果你在 Once 里做”初始化连接池”,第一次失败了(panic 或 error),Once 不会给你第二次机会,后续所有 Do 都直接返回,而你的连接池是 nil,然后全链路 NPE。

怎么办? 两种:

  • 在 f 内部自己重试 / 保证不失败;
  • 或者:不要用 Once 做可能失败的初始化。用 atomic.Pointer + CAS 的”lazy init”模式,失败时把指针留在 nil 让下一次重试。

顺便,Go 1.21 加了 OnceFunc / OnceValue / OnceValues,把”Once + 一个全局变量”这个样板封装掉了:

var getDB = sync.OnceValue(func() *sql.DB {
    db, _ := sql.Open("mysql", dsn)
    return db
})
 
// 调用点
db := getDB()   // 比 once.Do(func(){...}) 后跟一次读全局变量干净得多

它带来的额外好处是:返回的是一个闭包,调用点不再需要 once.Do(func(){...}) 那种笨重的结构,而且类型安全(返回值有具体类型,不需要类型断言)。

Once 的两个坑

坑一:f 里再调同一个 Once.Do → 死锁。 因为 slow path 持有了那把 Mutex,而 Mutex 不可重入。是的,就这么简单,但线上真的会发生(通常是”初始化函数里间接调用了依赖它的另一个函数”)。

坑二:把 Once 放进结构体,然后复制结构体。 复制出来的 done 是副本,两个 Once 各管各的,f 会被执行两次。go vet 会抓,但如果你用 unsafe 或者经过 interface 中转,vet 可能就看不见了。

坑三(算是提醒):Once 和 init() 不是一回事。 init() 是包级的、在 main 之前执行、由 runtime 串行调用、不能返回错误、不能按需跳过;Once 是运行时的、可以放在任何作用域(比如每个连接一个 Once)、可以条件触发。需要延迟到真正用到时才做 → Once;包级无条件的准备工作 → init() 或者干脆包级变量初始化。


子主题十:性能与选型——atomic < Mutex < channel,以及量级感

现在给你一张”价格表”。先看数字,再谈哲学。

以下是在一台典型 x86-64 服务器上、无竞争情况下的数量级(不是精确值,别拿去写论文,数量级才是重点):

原语无竞争时的量级竞争时
普通变量读写亚纳秒(且可能被优化掉)——
atomic.Load/Store几纳秒cache line 争用时飙到几十~上百纳秒
atomic.Add / CAS约十纳秒级别(CAS 比 Load 贵,要独占 cache line)重试循环,可能无界
Mutex Lock+Unlock(无竞争)几十纳秒(一次 CAS + 一次原子写)微秒级(走信号量,进内核)
RWMutex RLock+RUnlock几十纳秒(比 Mutex 略贵)微秒级
RWMutex Lock+Unlock(写锁)几十~上百纳秒微秒级,且比 Mutex 写路径更长
channel send/recv(有缓冲、不阻塞)几十~上百纳秒微秒级(goroutine 挂起 + 唤醒)
channel send/recv(无缓冲/阻塞)微秒级(必然要交接)微秒级
WaitGroup.Wait / Once.Do(fast path)一次原子读,几纳秒——

把这张表从上到下扫一遍,你会发现一个惊人的事实:无竞争时,atomic 和 Mutex 的差距只有 2~5 倍,而且都在纳秒级。

换句话说:在绝大多数代码里,把 Mutex 换成 atomic 省下的那点时间,你根本测不出来。

那为什么还要优化?

因为价格表下半部分才是真相。

一旦发生竞争,Mutex 从”几十纳秒”跳到”微秒级”——这是 100 到 1000 倍的跃迁,中间跨了一整个量级。而这一次跃迁的触发条件不是”你用了 Mutex”,而是”有人跟你抢”。

所以优化的优先级排序,你应该倒过来念:

  1. 第一优先:消灭竞争(不共享 / 分片 / 每 goroutine 一份),而不是换更快的原语;
  2. 第二优先:缩短临界区(锁内只做 O(1) 的操作,把 IO、计算、序列化全部挪出去);
  3. 第三优先:换原语(Mutex → RWMutex → atomic)。

大部分人的精力全花在第 3 条上,收益最小;真正的大头在第 1 条,而它往往只需要改一行——比如把全局计数器变成 [64]atomic.Int64 按 CPU/goroutine id 分片,或者把共享 map 改成每 goroutine 一份、定期合并。

类比:堵车时,你该做的是”多修几条路”或者”让大家错峰出行”(消灭竞争),而不是”把每辆车换成跑车”(换更快的原语)。 跑车在堵死的高架上,和拖拉机一样快。

选型决策树

来,遇到并发问题,按这个顺序问自己:

Q1:你是在”保护一个值”,还是”保护一段过程”?

  • 一个值(计数器、标志位、指针)→ atomic(或 atomic.Value / atomic.Pointer[T])
  • 一段过程(多步操作要作为一个整体生效)→ Mutex

Q2:你要表达的是”保护共享状态”,还是”传递数据所有权”?

  • 传递所有权:我要把这条数据/这个连接/这个任务交给另一个 goroutine → channel
  • 保护状态:这份数据待在原地,大家轮流来看/改 → Mutex / RWMutex

Q3:你只是在”等一批活干完”吗?

  • 是 → WaitGroup(或 errgroup,如果你还要收集错误/做取消)
  • 不是用 channel 数数,更不是 time.Sleep。

Q4:你只是”想让某件事只做一次”吗?

  • 是 → Once / OnceValue / OnceValues,或者包级 init()
  • 不是手写 if !initialized { initialized = true; ... }(那是教科书级的数据竞争)

Q5:你在等”某个条件成立”吗?

  • 是 → sync.Cond(配合 for 循环检查),或者干脆用 channel 当条件通知

一句话选型总结

  • atomic 是螺丝刀:一次拧一个值,快、轻、但干不了组装活。
  • Mutex 是上锁的车间:一整段过程都归你,安全、直白,代价是别人要等。
  • channel 是传送带:货物在上面流动,所有权跟着货走。用它来”保护一个变量”就像用传送带送一颗螺丝去隔壁工位——能用,但你会成为同事口中的谈资。

反过来也别走火入魔

用 channel 当互斥锁(容量 1 的 buffered channel,send 抢 token、recv 还 token)——技术上完全可行,但:

  • 语义绕,读代码的人要停顿三秒才反应过来这是把锁;
  • 没有 TryLock;
  • 最致命的:持有 token 的 goroutine panic 了,token 永远不会还回去,整个系统永久卡死。而 Mutex 有 defer Unlock() 兜底。

这种写法是典型的”聪明反被聪明误”。 用对工具,别炫技。

最后一条元规则

任何关于性能的结论,都必须有 benchmark 背书。 至少这样写:

func BenchmarkCounterMutex(b *testing.B)   { /* ... */ }
func BenchmarkCounterAtomic(b *testing.B)  { /* ... */ }
func BenchmarkCounterSharded(b *testing.B) { /* ... */ }

然后跑:

go test -bench=. -benchmem -cpu=1,4,8,16

-cpu 这个参数是灵魂——很多并发优化在 1 核上快得飞起,在 16 核上一塌糊涂(因为 cache line 争用是核数的函数)。只看单核 benchmark 就下结论,等于闭着眼睛选股。


子主题十一:锁的九种死法——常见误用与反模式

这一节是”别人的事故报告”。每一条都不是理论,都在线上真实发生过。

死法一:锁顺序颠倒(ABBA 死锁)

最经典的死锁。两个 goroutine,两把锁,顺序相反:

// goroutine A:先 accounts[1] 再 accounts[2]
mu1.Lock(); mu2.Lock(); transfer(); mu2.Unlock(); mu1.Unlock()
 
// goroutine B:先 accounts[2] 再 accounts[1]
mu2.Lock(); mu1.Lock(); transfer(); mu1.Unlock(); mu2.Unlock()

A 拿着 1 等 2,B 拿着 2 等 1。两个人在门口互相让路,谁也过不去。

解法(按推荐度排序):

  1. 建立全局锁序。 给每把锁定一个”级别”,规定永远按级别从低到高获取。比如按账户 ID 排序:if id1 < id2 { lock(a); lock(b) } else { lock(b); lock(a) }。这是最根本、最可靠的解法。规则本身比规则是什么更重要——只要所有人都遵守同一个顺序,死锁就不可能发生。
  2. 合并成一把锁。 如果两个资源总是一起被访问,它们本来就该共享一把锁。
  3. 一次性获取(all-or-nothing)。 用一个”资源组锁”包住整个集合。粗,但简单可靠。
  4. TryLock + 全有或全无。 抢不到第二把就全退回去重试。注意:这不是首选,因为退避重试活锁的复杂度不低,只在特殊情况用。

死法二:锁粒度太大

一把大锁保护整个 map / 整个缓存 / 整个业务对象。结果是:所有业务请求被串行化,你的 32 核机器跑出了单核的性能。

解法:分片(sharding)。

type shard struct {
    mu sync.Mutex
    m  map[string]int
}
 
type ShardedMap struct {
    shards [64]shard
}
 
func (s *ShardedMap) forShard(key string) *shard {
    h := fnv32(key)
    return &s.shards[h%64]
}

争用概率直接降到 1/64。这个思路的终极形态就是 sync.Map(内部用 read map + dirty map 双缓冲,读走无锁路径)。

另一个方向:读写分离(RWMutex)、不可变快照(atomic.Pointer)、每 goroutine 一份 + 定期合并(这是很多高性能计数器的做法)。

反问:你怎么知道粒度太大了? 用 pprof 的 mutex profile:

runtime.SetMutexProfileFraction(1)   // 采样比例,生产环境别开 1,开销大
// ... 然后 pprof 看 contention

或者看 goroutine dump:如果几百个 goroutine 都卡在同一个 semacquire 上,那就是它了。

死法三:锁内做耗时操作

mu.Lock()
data, err := http.Get(url)      // ❌ 网络 IO,几十毫秒
cache[key] = data
mu.Unlock()

持锁 50 毫秒,QPS 直接被锁死在 20。锁内做 IO,是”用一把锁废掉整个服务”的最短路径。

正确姿势:把耗时操作全部挪到锁外,锁内只做 O(1) 的交接。

// ✅ 锁外做 IO
data, err := http.Get(url)
if err != nil { return err }
 
// 锁内只做一次指针/值的替换
mu.Lock()
cache[key] = data
mu.Unlock()

更进一步的模式是 “锁外计算,锁内提交”:先在无锁状态下把新值算好(可能很贵),然后进锁只做一次 CAS 或赋值。这也是无锁算法的核心思想——把”临界区”压到只剩一次原子操作。

锁内绝对禁止的清单:网络调用、文件 IO、数据库查询、time.Sleep、复杂计算、可能阻塞的 channel 操作、调用用户传入的回调。

死法四:复制含锁的结构体

type Cache struct {
    mu   sync.Mutex
    data map[string]string
}
 
func NewCache() Cache { /* ... */ }   // ❌ 返回了值!
func (c Cache) Get(k string) string  // ❌ 值接收者,每次调用都复制整个 Cache(含锁)

后果是什么?

  • 复制一份”已加锁”的 Mutex → 副本从”已锁”状态诞生,一上来就死锁;
  • 更常见的:复制后变成两把不同的锁,它们各自保护各自的 map,看起来加了锁,实际上保护形同虚设。

好消息是:go vet 的 copylocks 检查能抓住绝大多数情况。所以:

  • 永远把 go vet ./... 放进 CI;
  • 结构体的所有方法都用指针接收者(只要结构体里有锁,就全用指针,别混着用——混用值接收者和指针接收者本身就是 bug 温床);
  • 小心 for _, v := range caches(v 是副本)、小心闭包捕获、小心把锁放进会被 copy 的 struct。

死法五:defer Unlock 的范围失控

func handle(req *Request) {
    mu.Lock()
    defer mu.Unlock()      // ⚠️ 锁会一直持有到 handle 返回
    // ... 后面还有 200 行业务逻辑,包括 IO ...
}

defer 在函数返回时才执行。如果临界区其实只有开头三行,那你持锁的时间被拉长到了整个函数的生命周期——这是死法三的变种,而且非常隐蔽,因为代码看起来”很规范”。

解法:用匿名函数把临界区圈起来。

func handle(req *Request) {
    v := func() int {
        mu.Lock()
        defer mu.Unlock()
        return counter   // 锁的范围就这三行
    }()
    // 后面 200 行业务逻辑,不持锁
    doSomethingSlow(v)
}

原则:defer Unlock() 要紧跟着 Lock(),而 Lock() 要离它需要保护的那几行越近越好。

死法六:忘了解锁(尤其是 panic 路径)

mu.Lock()
riskyOperation()    // 这里 panic 了
mu.Unlock()         // 永远执行不到 → 锁永久丢失

一个 goroutine 忘了还锁,所有其他 goroutine 全部永久阻塞。这比死锁还难查,因为”忘了加锁的那个人”可能早就消失了。

唯一正确的姿势:defer mu.Unlock()。 而且 Go 1.14 之后的 open-coded defer 已经把 defer 的开销压到几纳秒(没有额外分配、几乎就是一次函数指针跳转),别再为了”性能”手写 Unlock——那点收益远不抵你漏掉一个分支的风险。

死法七:锁内调用用户代码 / 外部回调

mu.Lock()
for _, cb := range callbacks {
    cb()        // ❌ 你不知道 cb 里会不会来抢这把锁
}
mu.Unlock()

回调是”未知代码”。它可能重入、可能加锁、可能 panic、可能阻塞。在持锁状态下把控制权交给未知代码,等于把系统的生死交给一个你不认识的人。

解法:先拷贝出快照,解锁,再调用。

mu.Lock()
snapshot := make([]Callback, len(callbacks))
copy(snapshot, callbacks)
mu.Unlock()
 
for _, cb := range snapshot {
    cb()      // 不持锁,安全
}

这就是”锁内只做拷贝,锁外做事情”的通用模式。

死法八:用 time.Sleep 做同步

go worker()
time.Sleep(100 * time.Millisecond)   // ❌ "等它跑完"
fmt.Println(result)

这是在赌。Sleep 赌的是”100ms 一定够”,而”一定”这两个字在并发里从来不成立。 机器负载高一点、GC 卡一下、调度抖一下,100ms 就不够了,然后你的程序在压力测试时偶发失败,而本地永远复现不了。

同步的活,交给同步原语:WaitGroup / channel / Once / Cond。time.Sleep 唯一合法的用途是在测试里做”等待条件成立”的粗暴近似,以及 select 的超时分支。

死法九:sync.Cond 的 if 与 for

sync.Cond 是被用得最少、也最容易用错的同步原语,但它有不可替代的场景:“等到某个条件成立”(WaitGroup 只能等到”计数归零”,channel 适合”收到一个事件”,都不等于”等到条件”)。

var mu sync.Mutex
var cond = sync.NewCond(&mu)
var queue []Task
 
// ❌ 错误:用 if
cond.L.Lock()
if len(queue) == 0 {
    cond.Wait()
}
task := queue[0]
cond.L.Unlock()
 
// ✅ 正确:用 for
cond.L.Lock()
for len(queue) == 0 {      // 必须用循环!
    cond.Wait()
}
task := queue[0]
cond.L.Unlock()

为什么必须是 for? 因为 Wait() 返回不代表条件成立。

  • Wait 内部会先解锁、再睡、被唤醒后再加锁——在你”睡→被唤醒→重新拿到锁”这段时间里,别的 goroutine 完全可能已经把任务取走了;
  • Broadcast() 会唤醒所有等待者,但只有一个能抢到锁并拿到任务,其他人醒来发现队列又空了;
  • 某些平台/实现上还可能存在虚假唤醒(spurious wakeup)。

用 for 是唯一的正确写法。这不是风格问题,是正确性问题。

Signal() vs Broadcast():Signal 唤醒一个(更省,但你要确定任何一个醒来都能干活);Broadcast 唤醒全部(更保险,比如”条件从成立变为不成立”这类状态广播)。不确定就用 Broadcast,多醒几次的代价远小于漏醒一次。

附:一份 Code Review 自检清单

把这张表贴在你的团队 wiki 上:

  • 加解锁配对了吗?有没有 defer Unlock()?
  • 临界区里有没有 IO / RPC / 数据库 / Sleep / 回调?
  • defer Unlock 的持锁范围是不是比实际需要的大?
  • 有多把锁吗?获取顺序全局一致吗?
  • 结构体里有锁吗?方法是不是全用了指针接收者?
  • go vet ./... 过了吗(copylocks / loopclosure)?
  • Add 在 go 之前吗?Done 是 defer 的吗?
  • RWMutex 有没有嵌套 RLock / 锁升级?
  • Cond.Wait 外面是 for 还是 if?
  • 有没有用 Sleep 当同步?
  • 这个场景真的需要锁吗?(无共享 / 不可变 / 分片 能不能解决?)

子主题十二:业界对照补强——C++ 的内存序、Rust 的所有权、Java 的锁升级

前面的”业界对照”给了一张速览表。这一节把三个最值得深挖的对照点展开。因为理解别人为什么那么设计,才能真正理解 Go 为什么这么设计。

对照一:C++ 的六档内存序 vs Go 的”只有一档”

C++ 的 std::atomic 允许你为每一个原子操作指定内存序,一共六档:

内存序保证典型用途
memory_order_relaxed只保证原子性,不保证任何顺序纯计数器、统计数据(不需要和其他数据同步)
memory_order_consume理论上比 acquire 弱,实践中基本等同于 acquire(已被标准”不鼓励使用”)——
memory_order_acquire本操作之后的读写,不会被重排到它之前加锁(lock)、获取引用
memory_order_release本操作之前的读写,不会被重排到它之后解锁(unlock)、发布数据
memory_order_acq_relacquire + release读-改-写操作(如 exchange)
memory_order_seq_cst全局唯一总序,所有线程看到的操作顺序完全一致默认档,最强最慢

这套体系能干什么?举一个真实的优化:

引用计数的释放。 用 fetch_sub(1, memory_order_release) 而不是 seq_cst。为什么这样是对的?因为”减到 0 说明没人再引用了”这个语义,需要的是 release 语义(保证我之前的所有修改对后续看到 0 的人可见),而不需要全局总序。

这么一改,在 ARM/POWER 这类弱内存序架构上,每减少一次引用就少一条屏障指令。在 shared_ptr 这种被疯狂拷贝的东西上,累积收益非常可观。

那代价是什么?

代价是心智负担。写 memory_order 的人必须能回答:

  • 这个操作和哪个操作配对?
  • 我需要的是 acquire、release 还是 acq_rel?
  • 如果这里用 relaxed,会不会让某个线程看到”半初始化”的对象?
  • 在 x86 上测不出来(x86 硬件本身就偏强),换到 ARM 服务器上就偶发崩溃——你能复现吗?

这就是最难查的一类 bug:它对的概率是 99.9999%,它在你的开发机上永远不出现,它在客户的 ARM 服务器上每周崩一次,而崩溃现场的数据看起来”不可能是错的”。

Go 的取舍:只给一档——顺序一致(seq_cst)。

sync/atomic 里没有 relaxed、没有 acquire/release,所有操作都是顺序一致的。(Go 1.19 官方重写了内存模型文档,把 atomic 的顺序一致性、以及 sync 包各原语的 happens-before 关系,正式写进了语言规范——这是 Go 在并发语义上的一次重要”立法”。)

凭什么这么任性?三个理由:

  1. 主战场是 x86/x86-64。 在这上面,acquire/release 基本是免费的(硬件已经足够强,主要影响的是编译器重排),而 seq_cst 的额外代价主要体现在 store 需要 xchg 或 mfence——可控。真正的差距在 ARM/POWER 上才显著。
  2. 目标用户是业务工程师,不是内核作者。 Go 团队明确表示过:能正确写出 relaxed 内存序代码的人是极少数,而这类错误的代价是整个系统的不可靠。
  3. 多一个选项,就多一整类 bug。 少一个档位,不是”功能缺失”,是把一整类最难查的问题从生态里删掉。

反问一句:你真的需要那 20%~30% 吗? 如果你的程序真的卡在原子操作的屏障开销上,正确的答案通常不是”降内存序”,而是**“别用 atomic,改成不共享”**——让每个 goroutine 持有自己的计数器,最后合并。架构层面的优化,永远比指令层面的优化收益大。

这就是 Go 一以贯之的哲学:砍掉选择空间,用一点点性能上限,换掉一整类灾难性错误的可能性。 和它取消隐式数值转换、强制 {}、砍掉继承、只留一种循环,是同一个价值观。

对照二:Rust 的 Mutex<T>——让编译器替你记住加锁

Go 的写法:

type Counter struct {
    mu sync.Mutex
    n  int
}
 
func (c *Counter) Inc() {
    c.mu.Lock()
    c.n++          // 忘了 Lock?编译器不管。忘了 Unlock?编译器也不管。
    c.mu.Unlock()
}

注意这里的根本问题:mu 和 n 是”约定上相关”的两个字段,但这个相关性没有任何机制保证。 你可以不加锁直接读 c.n,编译器一声不吭,代码跑得好好的,然后在高并发下数据错乱。

Rust 的写法:

use std::sync::Mutex;
 
let counter = Mutex::new(0);       // 锁包着数据
*counter.lock().unwrap() += 1;     // 必须 lock() 才能碰数据

Mutex<i32>,不是 (i32, Mutex)。 一字之差,天壤之别:

  • 锁即数据:数据被装在锁里面,物理上就拿不出来。想读 i32?先 lock(),拿到 MutexGuard<T>,Guard 解引用才给你 &mut i32。
  • 忘加锁 = 编译不过。 不是运行时崩溃,不是偶发错误,是根本编译不过去。
  • RAII 自动解锁:MutexGuard 离开作用域时自动 drop → 自动解锁。连 defer 都不用写。而且 panic 时栈展开会 drop Guard,锁也自动释放。

这是”把并发安全从运行时约定,提前到编译期强制”的典型案例。

再看一个 Go 里没有的机制:Poisoning(中毒)。

Rust 里,如果一个线程持锁时 panic 了,这把 Mutex 会被标记为 poisoned(中毒)。后续所有 lock() 都会返回 Err(PoisonError),你必须显式处理(通常是 unwrap() 直接崩,或者调 into_inner() 强行忽略)。

为什么这么设计? 逻辑很硬:

线程 panic 时,它可能正改到一半——数据结构可能处于不一致的中间状态。如果下一个 lock 的人拿到锁,看到的就是一堆自相矛盾的数据,然后基于错误数据继续跑,错误会像病毒一样扩散。所以宁可让锁”中毒”,逼你面对这个问题。

Go 的选择相反:Mutex 不 poison。 panic 时 defer Unlock() 照常执行,锁被释放,下一个 goroutine 拿到锁,看到什么数据算什么数据。

谁对?这是两种错误处理哲学的差异,不是对错:

RustGo
panic 的定位可恢复的、局部的错误信号通常是不可恢复的(Go 里 panic 绝大多数意味着”程序该崩了”)
锁中毒有(显式处理不一致状态)无(假定你要么崩、要么在 defer 里回滚)
忘加锁编译期拦截靠 code review 和 vet
解锁RAII 自动靠 defer(约定,但很可靠)
学习成本高(生命周期、借用检查)低

Go 的假设是:“panic 就该崩,或者你在 recover 里已经把状态回滚干净了。” 在实际的 Go 服务里,这个假设大体成立(panic 通常在 HTTP handler 边界被 recover 掉,请求级状态随之丢弃)。但这个对比确实暴露了一个真实风险:Go 里如果 panic 发生在”修改共享结构到一半”的时候,而你又在顶层 recover 了让它继续服务,那份共享数据就已经是脏的了,而没有任何机制提醒你。

这是一个值得记住的 Go 实践建议:在 defer 里 recover 时,如果 panic 发生在持锁期间,你要么明确知道状态是干净的,要么干脆让进程退出(对于共享的、长生命周期的状态,退出往往比带病运行更安全)。

顺带一提,Rust 的 std::sync::Mutex 在 Linux 上也是 futex-based,同样会先自旋再进内核等待——和 Go 的思路殊途同归。这印证了一件事:这些不是”语言特性差异”,而是”面对同一个硬件现实(上下文切换很贵)时,所有人都收敛到了同一个答案”。

对照三:Java 的锁升级——一条单向的路

HotSpot JVM 的 synchronized 有一条著名的锁升级路径,由低到高:

  1. 无锁:对象刚创建,没人竞争。
  2. 偏向锁(Biased Locking):如果一个线程反复获取同一把锁,JVM 就把这个线程的 ID 直接记在对象头里。之后这个线程每次进出同步块,只需要比对一下 ID——连 CAS 都不用做,几乎零成本。这个优化针对的是一个真实存在的普遍事实:大量的锁在程序整个生命周期里只被一个线程用过。
  3. 轻量级锁:一旦有第二个线程来竞争,偏向锁被撤销,升级为轻量级锁。线程在自己的栈帧里创建一个 lock record,用 CAS 把对象头的 mark word 换成指向它的指针;抢不到就自旋一会儿。
  4. 重量级锁:自旋也拿不到,膨胀成 ObjectMonitor,走操作系统互斥量(pthread mutex),抢不到锁的线程进等待队列(cxq / EntryList)睡下去,等 JVM 唤醒。

和 Go 对照,你会发现结构惊人地相似:

阶段JavaGo
快速路径偏向锁(无 CAS)fast path(一次 CAS)
竞争初现轻量级锁 + 自旋自旋(有限次)
竞争严重重量级锁(OS 互斥量,睡眠)信号量(睡眠)
公平性兜底重量级锁的队列 + 非公平/公平可选饥饿模式(FIFO 递锁)

但有一个关键区别:方向性不同。

  • Java 的锁升级基本是单向的。 一旦膨胀成重量级锁,就几乎不会退回轻量级(HotSpot 理论上能在 safepoint 做锁降级,但实际影响微乎其微,你可以认为”膨胀即永久”)。
  • Go 的饥饿模式是可逆的。 队列空了、或者等待时间降下来了,立刻退回 normal 模式继续拼吞吐。

为什么 Go 要做成可逆的? 因为 Go 的并发模型是”goroutine 廉价、成千上万、短生命周期”,锁的竞争是突发性的——流量尖峰过去之后,竞争应该立刻消失、吞吐应该立刻恢复。而 Java 的重量级锁设计于”线程昂贵、数量有限、生命周期长”的时代,竞争往往持续存在于某些热点对象上,膨胀一次就一直用着,反而省事。

还有一个更有趣的故事:偏向锁被废弃了。

JDK 15 起,偏向锁默认关闭,后续版本逐步废弃。为什么?

因为随着现代程序和硬件的演进,“锁只被一个线程用”这个假设的收益,被”撤销偏向”的成本吃掉了。撤销偏向锁需要一个 safepoint(暂停所有线程),代价高昂;而当你的服务有大量短生命周期对象、大量线程池 worker 时,一个对象很可能先后被不同线程访问,偏向锁刚建好就要撤销——净亏损。

这个故事值得反复咀嚼:任何优化都不是免费的,优化本身也有成本,而收益是 workload-dependent 的。 十年前的天才优化,十年后可能变成负债。所以 Go 团队对”往 Mutex 里加新优化”极其克制——每加一层启发式,就多一份”在某些 workload 上变慢”的风险。

最后一问:为什么 Java 要把锁做得这么复杂?

因为 Java 背负着二十多年的存量代码,其中有海量”无脑 synchronized”的写法。JVM 不能指望程序员写出正确的并发代码,它必须在运行时替所有人兜底——于是复杂性被推进了 JVM 里。

Go 从零设计,站在 Java 的肩膀上,选择了更简洁的三段式(CAS → 自旋 → 信号量 + 一个可逆的饥饿模式)。复杂度没有消失,只是转移了——从”运行时帮你猜”,变成了”你自己选对工具”。

三条技术路线,三种价值观

语言策略复杂度放在哪代价
C++把控制权和责任全交给你你的脑子里极高出错率,且错误不可复现
Java运行时替你兜底JVM 里行为难预测,调优靠玄学
Rust把正确性提前到编译期类型系统和你的学习曲线上手门槛高,编译期折腾
Go砍掉选择空间,默认够用且难用错被删掉了性能上限低一档,极端场景得自己造

没有最优解。 只有”你的团队、你的 workload、你的错误容忍度,配得上哪种成本”。

而 Go 的答案始终一致:把默认的那个选项,做得足够好、且足够难用错。 理解了这一点,你就理解了 sync 包里的每一个设计决定——Mutex 的饥饿模式、Once 的 fast path、atomic 的 seq_cst、WaitGroup 的”Add 必须在 Wait 之前”——它们全都是同一个价值观的不同切面。


业界对照

C++ std::mutex / std::atomic: mutex 有自旋/睡眠,atomic 有完整的内存序(relaxed/acquire/release/seq_cst),用户可以精确控制。C++ 的性能上限更高,但内存序心智负担极重,memory_order 用错就是最难查的 bug。Go 的 atomic 默认 seq_cst,是”用性能上限换简单安全”。

Java synchronized / Lock / Atomic: synchronized 有偏向锁、轻量级锁、重量级锁的升级路径(类似 Go 的自旋→信号量),Atomic 有 CAS + volatile 内存语义。Java 的并发工具(AQS、并发容器)极其丰富,但也更重、更复杂。

Rust std::sync: Mutex 包裹数据(Mutex<T>),锁即数据,所有权系统保证”不先拿到锁就别想碰数据”,从编译期杜绝”忘加锁”的错误。Rust 的 atomic 也提供多档内存序。Rust 的哲学是”把并发安全交给编译期,而不是运行时约定”。

Erlang: 没有锁和 atomic——因为进程间不共享内存,同步靠消息。锁的问题在 Erlang 里被”无共享”直接消解。


版本演进小结

版本关键变化
早期Mutex 基础实现(自旋 + 信号量)
1.9Mutex 引入饥饿模式,解决等待者饿死问题
1.9sync.Map 引入(读多写少专用)
1.18Mutex / RWMutex 加入 TryLock
1.19类型化 atomic(atomic.Int64 / atomic.Pointer[T] 等),官方内存模型文档重写,把 atomic 的顺序一致性写进规范
1.21OnceFunc / OnceValue / OnceValues,懒初始化样板被收进标准库
持续atomic 优化、Once/WaitGroup 细节打磨

主线:Go 的同步原语,一直在”公平”和”吞吐”之间找平衡(饥饿模式)、在”性能”和”简单安全”之间找平衡(atomic 默认强内存序)。它不追求极限性能,追求”默认够快、且不容易用错”。


本章思考题

【思考题】

  1. Mutex 为什么”先自旋再睡”?自旋太久会有什么问题?饥饿模式解决的是什么?

  2. sync.Once 的 fast path 为什么能”无锁”?它是怎么保证”初始化只执行一次、且副作用对所有 goroutine 可见”的?

  3. atomic 和 Mutex 的分界在哪?什么场景必须用 Mutex 而不是 atomic?

  4. 为什么说”锁和 channel 本质都是同步原语”?它们的根本区别是什么?

  5. Mutex 的 normal 模式与 starvation 模式,各自的规则是什么?进入和退出饥饿模式的条件分别是什么?状态字里的”等待者计数”具体起到哪几个作用?

  6. RWMutex 为什么要让”写者阻塞新来的读者”?请解释”递归读锁 + 恰好插入一个写者”导致的死锁链条,并给出至少一种修法。

  7. sync/atomic 的五种操作(Add / Load / Store / Swap / CAS)各自的用途是什么?CAS 循环的正确骨架长什么样?ABA 问题是什么,在 Go 里通常怎么规避?

  8. 为什么 WaitGroup 的 Add 不能写在 goroutine 内部?“计数器为 0 时的正 delta 必须先于 Wait”这条规则,背后的机制原因是什么?WaitGroup 内部为什么要把”任务计数”和”等待者计数”打包进同一个 64 位字?

  9. sync.Once 里,为什么 done 标记要在解锁之前用 atomic 写?如果 Do 传入的函数 panic 了,Once 会重试吗?这会带来什么线上风险?

  10. 请给出 atomic / Mutex / channel 的量级对比,并回答:性能优化的第一优先级是什么,为什么不是”把 Mutex 换成 atomic”?

  11. 列举至少六种锁的常见反模式,并给出各自的解法。特别说明:为什么 sync.Cond.Wait 外面必须用 for 而不是 if?

  12. 三个业界对照问题:(a) C++ 有六档 memory_order,Go 只提供 seq_cst 一档,这个取舍的合理性在哪里?(b) Rust 的 Mutex<T> 和 Go 的 “锁 + 数据” 并列写法,本质差异是什么?Rust 的 poisoning 机制和 Go 的做法各自反映了怎样的错误哲学?(c) Java 的锁升级路径和 Go 的自旋→信号量结构很像,但有一个关键的方向性区别,是什么?偏向锁后来被废弃这件事说明了什么?

【参考答案】

  1. 因为”睡下再唤醒”要走 OS 调度,代价微秒级;若临界区很短、锁很快释放,自旋几纳秒就等到了,更便宜。但自旋在烧 CPU,锁迟迟不释放就是纯浪费,所以自旋有次数限制、只在多核且确有竞争时进行。饥饿模式解决的是”睡醒的老实人抢不过正在自旋的新人、被永远饿死”的问题——等待超时后切换 FIFO,把锁直接递给等最久的人。

  2. fast path 用 atomic 读 done 标记:若已是 1,直接返回,无锁。保证”只执行一次”靠 double-checked locking(加锁后再检查 done,防止两个 goroutine 都通过第一次检查导致执行两次)。保证”副作用可见”靠 atomic 写 done=1 + Once 的 happens-before 语义:任何 goroutine 读到 done==1 时,初始化函数的副作用一定已完成。fast path 让”已初始化后”的调用几乎零成本,这是它比手动加锁高明之处。

  3. 分界是”保护一个值还是保护一段过程”。单个计数器的加减、单个标志位读写、单个指针 CAS,用 atomic(无锁、不阻塞、快)。多个变量的组合操作、需要保护一段临界区(多步逻辑),必须用 Mutex,因为 atomic 只能保证单个操作的原子性,保证不了”多步组合”的一致性。选择标准:你要保护的是一个值,还是一段过程。

  4. 因为它们都解决同一个底层问题:多个执行流之间,如何保证”对内存的修改能被正确看到”(可见性)和”不会互相踩”(互斥)。锁通过”互斥”同步——同一时刻只允许一人进临界区;channel 通过”交接”同步——数据所有权随消息转移。根本区别在于表达方式:锁保护”共享状态”,channel 传递”数据所有权”。Go 同时提供两套扎实的工具,该用锁用锁、该用 channel 用 channel,这才是它并发能力强的真相。

  5. normal 模式:新来的 goroutine 可以和队首等待者竞争,且新来的赢面更大(它正在 CPU 上跑,队首还在被唤醒),这是用公平换吞吐。starvation 模式:新来者禁止自旋抢锁、只能排到队尾;解锁方不释放锁让别人抢,而是把锁直接 handoff 给队首等待者(FIFO),醒来者连 CAS 都不用做。进入条件:等待者的等待时间超过一个阈值(约 1 毫秒)时打开饥饿标志。退出条件(满足其一):等待队列已空(waiter 计数归零,即当前这个是最后一个),或者队首等待者的等待时间已经小于该阈值——说明积压消化完了,退回 normal 换吞吐。进出条件不同构成”滞回”结构,避免在临界点抖动。waiter 计数的作用有三:(1) 决定解锁时是否要走”信号量唤醒”这条慢路——没有等待者时,解锁只做一次原子清位就返回,不碰内核;(2) 判断队列是否为空,从而决定能否退出饥饿模式;(3) 饥饿模式下判断还有没有人等着被递锁。

  6. 原因:如果不阻止新读者,在读者源源不断的场景下,写者要等的”所有读者退出”这一刻永远不会到来,写者被饿死;阻塞新读者是防止写饥饿的必要代价(writer preference)。代价是会制造一次读停顿尖刺。死锁链条:① 外层函数拿到读锁(读者数=1);② 某写者调用 Lock() 开始等待,点亮”有写者在等”信号;③ 外层函数内部调用的下层函数再次 RLock(),因有写者在等而被阻塞;④ 写者要等读者数归零,而外层的第一次读锁要等内层返回才释放 → 互相等待,死锁。关键特征:只在写者恰好插入时才触发,所以测试环境跑一万次都没事,上线后在配置刷新时段偶发崩溃。修法:锁的持有不要跨函数——把被调函数写成不加锁的裸实现(命名为 xxxLocked),由知道全貌的调用方统一加锁;或者用 RLocker() 传递读锁。另外要记住:RWMutex 不支持从读锁升级为写锁,真要升级必须先 RUnlock、再 Lock,然后重新检查条件(double-check)。附加提醒:RWMutex 并非一定比 Mutex 快——RLock 要原子地加减一个所有读者共享的计数器(cache line 争用),写锁路径比 Mutex 更长;临界区极短时 RWMutex 可能净亏。是否更快,只能由 go test -bench 加 -cpu=1,4,8,16 说了算。

  7. Add:new = old + delta 并返回新值,用于计数器/累加器;无符号减法用补码技巧(Add(^uint64(n-1)))。Load / Store:原子读写,用于标志位、发布-订阅(Store 发布一个初始化好的对象,Load 拿到即保证数据完整可见)。普通读写不行有三个理由:32 位平台上 64 位变量会撕裂;编译器可能把循环内不变的标志位优化到寄存器导致死循环;普通读写不提供任何内存序保证。Swap:原子地”读旧值 + 写新值”,用于把槽位里的东西整个摘下换新(无锁缓冲区轮换)、一次性状态转换。CAS:乐观并发的核心,“我以为现在是 old,是就改成 new”,用于无锁算法和状态机推进。CAS 循环骨架:for { old := v.Load(); nv := compute(old); if v.CompareAndSwap(old, nv) { break } }——失败后必须重新 Load,禁止复用过期的 old;compute 必须无副作用可重入。ABA:值从 A→B→A,CAS 看到”A 没变”于是成功,但世界已经变了(无锁栈会被破坏)。规避:加版本号打包成一体;Go 有 GC,只要还持有引用就不会被复用成”新的 A”,天然缓解;最实用的是不写无锁数据结构——用 atomic.Value / atomic.Pointer[T] 存不可变对象整体替换,不可变就没有中间态、就没有 ABA。

  8. 为什么不能放在 goroutine 里:Wait() 的 fast path 是”原子读一次状态字,若计数器已为 0 就直接返回、根本不睡”。如果主 goroutine 的 Wait 已经返回,而另一个 goroutine 才慢悠悠地 Add(1) 并启动任务,就没有任何人在等它;等它 Done 时计数器变成 -1,直接 panic(sync: negative WaitGroup counter)。所以”计数器为 0 时的正 delta 必须先于 Wait”是硬性正确性要求,正确写法是”先 Add 再 go”,最好一次性 wg.Add(N)(少 N 次原子操作,且杜绝循环体内 panic 导致少 Add)。打包进同一个字的原因:两个计数的更新必须是原子且同时的。若分成两个变量,就会出现”计数器已归零、但等待者计数还没加进去”的中间态,等待者错过唤醒信号而永远睡死。打包后一次 CAS 同时更新两者,中间态不存在。这是无锁编程的通用手法:相关状态要么加锁,要么打包进一个字用 CAS。另外注意:WaitGroup 等的是”归零”这个事件,不是”等于某个值”;它不传递错误、不支持超时和取消——需要这些就用 errgroup。

  9. 为什么在解锁前写 done:如果等解锁之后才置位,那么排在锁外、随后进入 slow path 的 goroutine 可能看到 done 仍是 0(写还没发生),于是它也会执行一遍 f,而且没人拦得住。先置 done 再放锁,保证任何后续进入者——无论走 fast path 还是 slow path——看到的 done 都是 1。配合 atomic 的顺序一致(seq_cst)语义,就有了 Once 的核心保证:看到 done == 1 时 f 的副作用一定已完成。panic 行为:文档明确写了,Do 认为 f 已经返回,done 照样置 1,后续调用不会重试 f。线上风险:如果你用 Once 做”初始化连接池/加载配置”这类可能失败的初始化,第一次失败后 Once 就永久”完成”了,后续所有 Do 直接返回,全局资源仍是 nil,然后全链路空指针。对策:在 f 内部自行保证成功或自行重试;或者不要用 Once 承载可失败的初始化,改用 atomic.Pointer + CAS 的 lazy-init,失败时把指针留在 nil 让下一次继续尝试。另外两个坑:f 内部再调同一个 Once.Do 会因 Mutex 不可重入而死锁;Once 放进结构体后被复制会导致 f 执行两次(go vet 可抓)。

  10. 量级(典型 x86-64,无竞争):atomic Load/Store 几纳秒;atomic Add/CAS 约十纳秒级(CAS 更贵,要独占 cache line);Mutex 加解锁几十纳秒(fast path 就是一次 CAS);RWMutex 读锁几十纳秒、写锁更长;不阻塞的 channel 收发约几十到上百纳秒。一旦竞争:Mutex/channel/WaitGroup 唤醒全部进入微秒级,这是 1001000 倍的跃迁。结论:无竞争时 atomic 和 Mutex 只差 25 倍、且都在纳秒级,绝大多数代码里根本测不出来;真正的成本来自”抢”。所以优化优先级要倒着念:① 第一优先是消灭竞争(不共享 / 分片 / 每 goroutine 一份最后合并),而不是换更快的原语;② 第二优先是缩短临界区(锁内只做 O(1) 的交接,把 IO、计算、序列化、回调全部挪出去);③ 最后才是换原语(Mutex → RWMutex → atomic)。比喻:堵车时该做的是多修路或错峰出行,而不是把每辆车换成跑车——跑车在堵死的高架上和拖拉机一样快。选型判据:保护一个值用 atomic;保护一段过程/一组变量间的不变量用 Mutex;传递数据所有权用 channel;等一批活干完用 WaitGroup(要错误传播和取消就用 errgroup);只做一次用 Once/OnceValue;等条件成立用 Cond。补充反模式:用容量 1 的 channel 当互斥锁——能跑,但语义绕、无 TryLock,最致命的是持有 token 的 goroutine panic 后 token 永不被归还,整个系统永久卡死;而 Mutex 有 defer Unlock() 兜底。最后一条元规则:任何性能结论必须有 go test -bench=. -benchmem -cpu=1,4,8,16 背书,-cpu 是灵魂——很多并发优化在单核上快得飞起,在多核上因 cache line 争用而崩盘。

  11. 反模式与解法:① 锁顺序颠倒(ABBA 死锁)——建立全局锁序(永远按级别从低到高获取)、合并成一把锁、或用 TryLock 全有或全无(不推荐当首选)。② 锁粒度太大——分片(按 key 哈希分成 N 份,争用降为 1/N)、读写分离、不可变快照、每 goroutine 一份定期合并;用 mutex profile 和 goroutine dump 定位(几百个 goroutine 卡在同一个 semacquire 上就是它)。③ 锁内做耗时操作——锁内禁止网络调用、文件 IO、DB 查询、Sleep、复杂计算、阻塞式 channel 操作、用户回调;模式是”锁外算好,锁内只做一次 O(1) 交接”。④ 复制含锁的结构体——永远指针接收者、别返回值、小心 range 副本和闭包捕获;把 go vet ./... 放进 CI(copylocks 能抓绝大多数)。⑤ defer Unlock 范围失控——defer 到函数返回才执行,若真实临界区只有几行就用匿名函数把临界区圈起来;原则是 Lock() 紧贴需要保护的代码。⑥ 忘了解锁(尤其 panic 路径)——唯一正确姿势是 defer Unlock();Go 1.14+ 的 open-coded defer 已降到几纳秒,别再为”性能”手写 Unlock。⑦ 锁内调用用户回调/未知代码——先拷贝快照,解锁,再回调。⑧ 用 time.Sleep 做同步——这是在赌”一定够”,而并发里没有”一定”;Sleep 只在测试近似和 select 超时分支里合法。⑨ Cond 用 if 而非 for——必须用 for:Wait() 返回不代表条件成立。因为 Wait 内部是”先解锁→睡→被唤醒→再加锁”,在这段窗口里别的 goroutine 完全可能已经把资源取走;Broadcast 会唤醒所有等待者但只有一个能抢到;还可能存在虚假唤醒。用 for 不是风格问题,是正确性问题。不确定用 Signal 还是 Broadcast 时选 Broadcast——多醒几次的代价远小于漏醒一次。

  12. (a) C++ 六档 vs Go 一档:六档是 relaxed / consume(已不鼓励)/ acquire / release / acq_rel / seq_cst,能让你为每一次操作精确指定屏障强度(典型优化是引用计数释放用 release 而非 seq_cst,在 ARM 上省掉一条屏障指令)。代价是心智负担:写错就是”开发机永不复现、客户 ARM 服务器每周崩一次、崩溃现场数据看起来不可能错”的一类 bug。Go 只给 seq_cst 的合理性有三:主战场 x86 上 acquire/release 基本免费、seq_cst 的额外代价主要体现为 store 需要 xchg/mfence,可控;目标用户是业务工程师而非内核作者,能正确写 relaxed 的人是极少数;少一个档位不是功能缺失,而是把一整类最难查的错误从生态里删掉。而且如果真的卡在屏障开销上,正解通常是”别用 atomic,改成不共享”,而不是降内存序——架构层的优化永远比指令层的收益大。(b) Rust Mutex<T> vs Go:Rust 把数据装在锁里面(Mutex<i32> 而非 (i32, Mutex)),想读必须 lock() 拿到 MutexGuard<T>,忘加锁是编译不过而不是运行出错;Guard 离开作用域自动解锁(RAII),panic 时栈展开也会 drop 掉,连 defer 都不用写。Go 里 mu 和 n 只是”约定上相关”的两个字段,忘了加锁编译器一声不吭。Poisoning 的差异:Rust 中持锁线程 panic 会把 Mutex 标记为 poisoned,后续 lock() 返回 Err,逼你显式面对”数据可能处于不一致中间态”;Go 的 Mutex 不 poison,defer Unlock() 照常释放,下一个 goroutine 拿到什么数据算什么数据。这反映了两种错误哲学:Rust 把 panic 视为可恢复的局部错误信号,所以要求显式处理污染;Go 默认 panic 意味着”程序该崩了”或”你已在 recover 里回滚干净”。实践建议:Go 里若在持锁期间 panic 又在顶层 recover 后继续服务,共享状态可能已脏且无人提醒——对于长生命周期的共享状态,宁可让进程退出。两者殊途同归之处:Rust 的 Mutex 在 Linux 上同样是 futex-based,也是先自旋再进内核等待,说明这是面对”上下文切换很贵”这一硬件现实时所有人的共同收敛。(c) Java 锁升级:无锁 → 偏向锁(把 owner 线程 ID 记在对象头,重入几乎零成本)→ 轻量级锁(栈上 lock record + CAS + 自旋)→ 重量级锁(膨胀为 ObjectMonitor,走 OS 互斥量,等待者睡进 cxq/EntryList)。与 Go 的对照:偏向锁/轻量级锁 ≈ Go 的 fast path 与自旋,重量级锁 ≈ Go 的信号量睡眠。关键的方向性区别:Java 的升级基本是单向的(膨胀后几乎不降级),而 Go 的饥饿模式是可逆的(队列空了或等待时间降下来就退回 normal)。原因是并发模型不同:Go 的 goroutine 廉价、海量、短命,竞争是突发性的,尖峰过后必须立刻恢复吞吐;Java 的线程昂贵、稳定、长命,竞争往往持续存在于热点对象上,膨胀一次一直用着反而省事。偏向锁被废弃的启示:JDK 15 起默认关闭、后续逐步废弃,因为”撤销偏向”需要 safepoint(暂停所有线程),代价高昂,而在大量短命对象 + 线程池 worker 的现代服务里,偏向刚建好就要撤销,净亏损。这说明——任何优化都不是免费的,优化本身也有成本,而收益是 workload-dependent 的。十年前的天才优化,十年后可能是负债。这也解释了 Go 团队往 Mutex 里加新启发式时为何极其克制:每加一层,就多一份”在某些 workload 上变慢”的风险。