第 05 章 原子与锁:从 CAS 到 futex 的四层退化

前面几章我们一直说”多线程共享内存是危险的”。这一章,我们正面解决这个问题:怎么在共享内存的前提下,维持秩序?

答案只有一个词:锁。

但锁不是一个东西,是一整套随竞争强度变化的策略。从硬件的原子指令,到内核的 futex,到用户态的 mutex,到 Go 的四层状态机——这是一条精心设计的”退化链”。

理解这条链,你就理解了 Go 的 sync.Mutex 为什么这么设计,以及你什么时候该用 atomic,什么时候该用 Mutex,什么时候该用 RWMutex,什么时候该换一种思路(RCU、无锁)。


引子:一个看似简单的问题

var counter int64
 
// 10 个 goroutine,每个加 100 万次
for i := 0; i < 10; i++ {
    go func() {
        for j := 0; j < 1_000_000; j++ {
            counter++   // 问题在哪?
        }
    }()
}

你期望 counter == 10_000_000。实际呢?几乎总是更小,而且每次跑的结果都不一样。

原因在第 2 章讲过:counter++ 是三步(读、加、写),不是原子的。三个 goroutine 交错执行,就会”丢失更新”。

那怎么修?三个层次的解法,正好对应这一章要讲的三层:

// 层次 1:原子操作
atomic.AddInt64(&counter, 1)
 
// 层次 2:互斥锁
mu.Lock()
counter++
mu.Unlock()
 
// 层次 3:换个思路,不加锁(每个 goroutine 各自累加,最后合并)
// 无锁/免锁设计

这一章,我们从最底层(硬件)往上,把这套机制彻底讲透。

给你一句总纲:

锁的本质,是用”串行化”来保证”正确性”。

而”串行化”的代价,是”并行度的损失”。

所以所有锁的设计,都在追求同一个目标:在保证正确性的前提下,让”串行化”的范围尽可能小、时间尽可能短。


本章路线图

第一幕:硬件层的原子性
  Q1  为什么需要原子操作?"原子"到底是什么意思?
  Q2  原子指令在硬件上怎么实现?缓存锁和总线锁是什么?
  Q3  CAS 是什么?它有什么缺陷(ABA 问题)?

第二幕:自旋锁 —— 锁的第一形态
  Q4  自旋锁是什么?为什么它有时是解药,有时是毒药?
  Q5  自旋锁和多核有什么关系?

第三幕:互斥锁与 futex —— 锁的第二形态
  Q6  互斥锁和自旋锁的本质区别是什么?
  Q7  futex 是什么?为什么它是"用户态快路径 + 内核态慢路径"的典范?
  Q8  Go 的 sync.Mutex 的四层状态机是什么?

第四幕:更精细的锁
  Q9  读写锁(RWMutex)解决了什么?又引入了什么(写者饥饿)?
  Q10 RCU 这种"不加锁的读"是什么原理?

第五幕:锁的代价与困境
  Q11 什么时候用 atomic,什么时候用 Mutex?
  Q12 锁面临哪些结构性的问题?

第一幕:硬件层的原子性

Q1 为什么需要原子操作?“原子”到底是什么意思?

“原子”(atomic)的本义是”不可分割”。

一个操作是原子的,意味着:其他线程要么看到它”完全没发生”,要么看到它”完全发生了”,绝不可能看到”做了一半”的中间状态。

回到 counter++:

非原子的 counter++:
  读 counter(0)
  加 1(寄存器里变成 1)
  写回 counter(1)
  
  如果另一个线程在"读"和"写回"之间插入,就会丢失更新

原子的 atomic.AddInt64:
  一个指令完成"读-改-写",中间不可能被插入

为什么需要原子操作?因为”共享可变状态”需要”互斥”来保证一致。

而”互斥”的最底层实现,就是硬件提供的原子指令。所有上层的锁(自旋锁、mutex、读写锁),最终都建立在原子指令之上。

“原子”的层次:

逻辑原子性(最高层):一个"事务"要么全成功要么全失败
  → 数据库事务、CAS 乐观锁

指令原子性(中间层):一个机器指令要么执行要么不执行,不被打断
  → 这是硬件保证的(单条指令不可被中断)

内存原子性(底层):一次内存读/写,不会被"撕成两半"
  → 对齐的 64 位读写在 x86 上是原子的
  → 但"读-改-写"不是原子的,需要锁总线/缓存

这里有个容易被忽视的点:“读”和”写”本身(单次)往往是原子的,但”读-改-写”不是。

问题几乎总是出在”读-改-写”上:counter++、if (x) x--、链表插入……这些”先看再改”的操作,天然有竞争窗口。

这就是为什么需要 CAS(Compare-And-Swap)——它把”读-改-写”变成一个原子操作。

Q2 原子指令在硬件上怎么实现?缓存锁和总线锁是什么?

问题:为什么”读-改-写”不原子?

因为 CPU 要先把数据从内存读到寄存器(或缓存),修改,再写回。这个过程中,其他 CPU 可能也在做同样的事。

硬件怎么保证原子?两种锁:

总线锁(Bus Lock):

早期的做法:执行原子指令时,锁住整个内存总线。

效果:锁住总线期间,其他 CPU 无法访问内存
  → 保证了"读-改-写"期间没有其他 CPU 插手

代价:锁住总线 = 所有 CPU 的内存访问都暂停
  → 性能灾难(本来只是要原子地改一个变量,结果整个系统都停了)

缓存锁(Cache Lock):

现代做法:利用缓存一致性协议(MESI,第 7 章讲)

原理:
  1. CPU 要原子地改一个变量,这个变量在它的缓存行里
  2. 它把这个缓存行标记为"独占/修改"状态
  3. 此时其他 CPU 的缓存里这个缓存行失效了
  4. CPU 在"独占"状态下完成"读-改-写"
  5. 因为独占,没有其他 CPU 能插手

效果:只"锁"住这一个缓存行(64 字节),不影响其他内存
代价:远小于总线锁

现代 CPU 用缓存锁为主,只在特殊情况下回退到总线锁(比如变量跨缓存行、或者某些指令不支持缓存锁)。

关键洞察:原子操作的代价,和”变量是否被多核共享”有关。

单核/无竞争:原子操作几乎免费(缓存行本来就在自己这)
多核竞争:原子操作要"抢"缓存行,代价高(类似锁)

这个”原子指令 → 缓存行 → 缓存一致性”的链条,是理解后面第 7 章”内存模型”的基础。这里先记住:

原子性的实现,靠的是”独占缓存行”。而”独占缓存行”本身是有代价的——它让其他 CPU 的缓存失效。

Q3 CAS 是什么?它有什么缺陷(ABA 问题)?

CAS(Compare-And-Swap,比较并交换):

// CAS 的语义:如果 *addr == old,则 *addr = new,返回 true
//            否则返回 false(说明被别人改过了)
bool CAS(int *addr, int old, int new) {
    if (*addr == old) {
        *addr = new;
        return true;
    }
    return false;
}

它是原子的——“比较”和”交换”在一个原子操作里完成。

CAS 是”无锁编程”的基石,因为你可以用它实现各种并发结构:

// 用 CAS 实现原子的"先检查再更新"
func compareAndIncrement(addr *int64, limit int64) bool {
    for {
        old := atomic.LoadInt64(addr)
        if old >= limit {
            return false   // 到上限了
        }
        if atomic.CompareAndSwapInt64(addr, old, old+1) {
            return true    // 成功加一
        }
        // CAS 失败(被别人抢了),重试
    }
}

CAS 的经典缺陷:ABA 问题。

场景:一个栈,用 CAS 实现 pop:

初始:栈顶是 A(A.next = B)

线程 1:要 pop,读到栈顶 = A
线程 1:准备 CAS(把栈顶从 A 改成 B)
        ↓(此时线程 1 被暂停)

线程 2:pop A,pop B,然后又 push A
        (栈现在还是 A 在栈顶,但下面的结构变了!)

线程 1:恢复,执行 CAS(栈顶还是 A,CAS 成功)
        但 A.next 已经不是 B 了,而是别的!
        → 栈结构被破坏了

问题的本质:CAS 只比较”值”,不比较”这个值是否还是原来的那个对象”。

A 虽然还是 A(值相同),但它的”上下文”(next 指针)已经变了。CAS 检测不到这种”换了又换回来”的变化。

解法:加版本号(tagged pointer)。

把"指针 + 版本号"打包在一起,每次修改都递增版本号。
CAS 比较"指针 + 版本号",这样"A 被 pop 又 push"会改变版本号,CAS 能检测到。

Go 的 atomic.Value 内部处理了这个问题(它用的是 interface 的 type + data 指针,配合版本机制)。

ABA 问题给你的启示:

“相等”不等于”没变过”。

这在工程里到处都是:

  • 缓存:key 相同,但 value 可能已经变过(所以要版本号/etag)
  • 配置:文件名相同,但内容变了(所以要 hash 或 mtime)
  • 乐观锁:用”版本号”而不是”值比较”来做并发控制

凡是”判断能不能改”的地方,用版本号,别用值相等。 这就是数据库乐观锁用 version 字段、HTTP 用 ETag、Git 用 commit hash 的原因。


第二幕:自旋锁 —— 锁的第一形态

Q4 自旋锁是什么?为什么它有时是解药,有时是毒药?

自旋锁(spinlock):最简单的锁。

// 自旋锁的伪代码
void spin_lock(atomic_flag *lock) {
    while (atomic_test_and_set(lock)) {
        // 忙等:什么都不干,反复检查锁是否释放
        // 可能加一个 pause 指令降低功耗
    }
    // 拿到锁了
}
 
void spin_unlock(atomic_flag *lock) {
    atomic_clear(lock);
}

它的行为:拿不到锁就一直”转圈”(spin),直到拿到。

什么时候它是解药?

条件:锁的持有时间极短(纳秒到微秒级)

原因:如果锁很快释放,与其"睡眠然后被唤醒"(代价几微秒),
     不如"转几圈等它释放"(代价几十纳秒)

睡眠的代价:上下文切换(保存/恢复寄存器、调度器决策)≈ 1-2 微秒
自旋的代价:转一圈 ≈ 几个周期,转 100 圈 ≈ 几百纳秒

如果锁 100 纳秒就释放,自旋明显更划算

什么时候它是毒药?

条件:锁的持有时间长(毫秒级),或者竞争激烈

原因:
1. 如果锁持有时间长,自旋就是在"空转"——纯浪费 CPU
2. 如果竞争激烈,多个 CPU 都自旋,都在空转
3. 更糟的是:自旋的 CPU 占着核,让"持有锁的线程"反而没 CPU 可用
   → 持有锁的线程被抢占,其他线程自旋等它,但它没 CPU 跑
   → 形成"死锁式的空转"(虽然最终会恢复)

关键矛盾:自旋锁假设”锁很快释放”,但这个假设在高竞争下不成立。

一句话:自旋锁是”用 CPU 换延迟”。

它赌的是”锁很快释放”,所以不值得睡眠。赌对了,极快;赌错了,极浪费。

自旋锁的真实应用:

1. 内核里:保护极短的临界区(比如修改一个链表节点)
   - 内核的 spinlock 持有时间通常 < 100ns

2. 用户态:Go 的 sync.Mutex 在"加锁失败时先自旋几圈"
   - 赌锁很快释放,自旋能避免睡眠

3. 无锁结构里:CAS 失败后的"重试循环",本质也是一种自旋

Q5 自旋锁和多核有什么关系?

自旋锁和多核的关系,是理解它”什么时候有用”的关键。

单核上的自旋锁:几乎总是愚蠢的。

单核:只有一个 CPU
如果线程 A 拿到自旋锁后,被抢占(时间片到了)
线程 B 开始跑,它想拿锁,于是自旋
但线程 A 还没释放锁(它被挂起了,没有 CPU 跑)
→ 线程 B 永远自旋,直到它自己的时间片用完
→ 纯浪费,什么问题都没解决

多核上的自旋锁:有意义,但有条件。

多核:线程 A 在 CPU0 持有锁,线程 B 在 CPU1 自旋
线程 A 能继续在 CPU0 跑,很快释放锁
线程 B 在 CPU1 自旋,几纳秒后拿到锁
→ 自旋有意义

但多核 + 高竞争 = 灾难:

8 核,8 个线程都想要同一个锁
1 个线程持有锁,7 个线程自旋
7 个自旋的线程,都在"抢"这个锁的缓存行
→ 缓存行的"乒乓"(在 8 个 CPU 之间来回跳,每次都要走互连总线)
→ 持有锁的线程释放锁时,也要抢回缓存行,反而变慢

所以自旋锁的适用条件是:

1. 多核(单核自旋无意义)
2. 锁持有时间极短(< 睡眠成本)
3. 竞争不激烈(自旋的线程不多)

这个”自旋 vs 睡眠”的权衡,是理解 Go mutex 的关键。 Go 的 mutex 会在”自旋”和”睡眠”之间动态切换,就是因为它要适应不同的竞争强度。我们 Q8 讲。


第三幕:互斥锁与 futex —— 锁的第二形态

Q6 互斥锁和自旋锁的本质区别是什么?

核心区别:拿不到锁时,自旋锁”忙等”,互斥锁”睡眠”。

自旋锁:拿不到锁 → 转圈(占着 CPU 等)
互斥锁:拿不到锁 → 睡眠(让出 CPU,等唤醒)

为什么需要互斥锁?因为不是所有临界区都”很快”。

自旋锁适合:临界区 < 1 微秒(内核链表操作)
互斥锁适合:临界区可能很长(读文件、网络调用、复杂逻辑)

如果临界区是”读一个文件”(毫秒级),用自旋锁就是灾难——几百个 CPU 空转几毫秒。

互斥锁的代价:睡眠和唤醒。

睡眠:线程进入阻塞态,调度器切换走(~1-2 微秒)
唤醒:锁释放时,唤醒睡眠的线程(~1-2 微秒)

所以互斥锁的"加锁 + 解锁"开销约 2-5 微秒(含切换)
而自旋锁在"无竞争"时,加锁解锁可能只要 20-50 纳秒

关键:这个 2-5 微秒的睡眠开销,就是 Go mutex 要优化的对象。

如果锁 100 纳秒就释放,为了 100 纳秒花 2 微秒睡眠,是亏的。所以 Go mutex 会”先自旋几圈,再睡眠”。

Q7 futex 是什么?为什么它是”用户态快路径 + 内核态慢路径”的典范?

futex(fast userspace mutex) 是 Linux 的锁基础设施,它是理解”高效锁”的关键。

它解决的问题:

传统的互斥锁(纯内核实现):
  每次加锁/解锁都要系统调用(陷入内核)→ 太贵

纯用户态的自旋锁:
  无竞争时快,但有竞争时只能自旋(浪费 CPU)

futex 的巧妙之处:结合两者
  无竞争时:纯用户态,不加系统调用(快)
  有竞争时:才陷入内核,睡眠等待

futex 的工作原理:

// futex 的核心是一个"用户态的原子变量" + "内核的等待队列"
 
// 加锁(用户态部分,无竞争时走这里)
// 1. 原子地尝试把锁从 0 改成 1(CAS)
// 2. 如果成功了,拿到锁,完事(没有系统调用!)
// 3. 如果失败(锁被别人持有),调用 futex 系统调用睡眠
 
int futex_wait(int *addr, int expected) {
    // 如果 *addr == expected,睡眠(等待 *addr 变化)
    // 否则立刻返回(锁可能已经释放了)
}
 
int futex_wake(int *addr, int n) {
    // 唤醒 n 个等待 *addr 的线程
}

完整的加锁流程(简化):

线程要加锁:

1. CAS(&lock, 0, 1)  → 成功?
   ├─ 成功:拿到锁,返回(快路径,零系统调用)
   └─ 失败:锁被别人持有

2. 尝试"自旋"几圈(赌锁很快释放)

3. 还拿不到 → futex_wait(&lock, 1)
   → 陷入内核,睡眠
   → 内核把线程加入"等待 lock 的队列"

4. 持有锁的线程解锁:
   CAS(&lock, 1, 0)
   → 如果有线程在等待,futex_wake(&lock, 1)
   → 唤醒一个等待线程

5. 被唤醒的线程重新竞争锁(回到步骤 1)

这个设计的精妙之处:

把”常见情况”(无竞争)放在用户态处理(快),把”罕见情况”(有竞争)交给内核处理(慢但正确)。

这就是”快路径 + 慢路径”的设计范式:

  • 快路径:99% 的情况,无竞争,纯用户态,几十纳秒
  • 慢路径:1% 的情况,有竞争,陷入内核,几微秒

平均成本 = 0.99 × 快 + 0.01 × 慢 ≈ 快路径的成本。

futex 还有两个精妙细节:

细节一:futex_wait 的”再检查”。

futex_wait(&lock, 1) 的意思是:"如果 lock 还是 1,才睡眠"

为什么需要这个检查?
因为从"CAS 失败"到"调用 futex_wait"之间,锁可能被释放了。
如果 lock 已经变成 0 了,就不该睡眠(否则错过唤醒,永久睡眠)。
futex_wait 会在内核里再检查一次,如果 lock != 1,立刻返回。

细节二:优先级继承(priority inheritance)。

问题:优先级反转(第 11 章会详细讲)
  - 高优先级线程等一个低优先级线程持有的锁
  - 低优先级线程被中优先级线程抢占,迟迟不释放锁
  - 高优先级线程被"卡死"

futex 支持 PI(priority inheritance):
  持有锁的低优先级线程,临时"继承"等待者的高优先级
  → 让它尽快跑完,释放锁

futex 给你的启示(比技术本身重要):

性能优化的通用模式:让”常见情况”走快路径,让”罕见情况”走慢路径,用”再检查”衔接两者。

这个模式在工程里无处不在:

  • 缓存:命中走快路径(内存),未命中走慢路径(数据库/磁盘)
  • Go mutex:无竞争快路径,有竞争慢路径
  • 分支预测:预测对的路径快,预测错的路径慢(冲刷流水线)
  • JIT 编译:热点代码编译成机器码(快),冷代码解释执行(慢)

关键技巧是那个”再检查”:从快路径失败到进入慢路径之间,情况可能已经变了,必须再检查一次。 少了这个再检查,就会有”错过唤醒”这种 bug。

Q8 Go 的 sync.Mutex 的四层状态机是什么?

Go 的 sync.Mutex 是理解”现代锁设计”的最佳案例。它的实现(src/sync/mutex.go)经过精心设计,用一个状态机来适应不同的竞争强度。

先看它的状态(4 个字段的状态位):

Mutex 内部是一个 int32(state)+ 一个 sema(信号量):

state 的位:
  bit 0: mutexLocked    是否加锁
  bit 1: mutexWoken     是否有 goroutine 被唤醒
  bit 2: mutexStarving  是否处于饥饿模式
  bit 3-31: waiterCount 等待的 goroutine 数量

四层策略(这是精髓):

第一层:CAS 快速路径(无竞争时)。

// 大部分情况:锁没被持有,一个 CAS 就拿到
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return  // 拿到锁,耗时 ~20ns
}

第二层:自旋(锁很快释放时)。

// CAS 失败,但先别急着睡,自旋几圈
// 条件:多核 + 自旋次数少 + 锁确实还在竞争
for i := 0; i < active_spin; i++ {
    // 自旋,赌锁很快释放
    // 如果锁释放了,重新 CAS 抢
}

Go 的自旋有严格条件(runtime_canSpin):

  • 多核(单核自旋无意义)
  • 当前 P 的本地队列为空(否则不如去跑别的 goroutine)
  • 自旋次数有限(约 4 次)

第三层:正常模式(有竞争,但能容忍)。

自旋失败后,进入正常模式:
  1. goroutine 被加入等待队列(waiterCount++)
  2. 通过 sema 睡眠
  3. 锁释放时,唤醒队头的 goroutine

正常模式的特点:
  被唤醒的 goroutine 不直接拿锁,而是和"新来的 goroutine"一起竞争
  → 新来的 goroutine 可能"插队"(因为它已经在 CPU 上跑了)
  → 等待的 goroutine 可能"饿死"(一直抢不过新来的)

第四层:饥饿模式(等待者太多,防止饿死)。

如果某个 goroutine 等待超过 1ms(阈值),进入饥饿模式:
  1. 锁释放时,直接"交接"给队头的等待者(不竞争)
  2. 新来的 goroutine 不抢锁,直接排队

饥饿模式解决"公平性"问题,但牺牲了吞吐(交接有开销)

完整的决策流程:

加锁:
  1. CAS 抢锁 → 成功?拿到锁(20ns)
  2. 自旋几圈 → 抢到?拿到锁(几十 ns)
  3. 进等待队列,睡眠
  4. 等待超 1ms?→ 进入饥饿模式

解锁:
  1. CAS 释放
  2. 有等待者?唤醒队头的
  3. 饥饿模式?直接交接,不唤醒竞争

为什么要这么复杂?因为锁的”最优策略”取决于竞争强度:

无竞争 → CAS 最快(20ns)
轻微竞争 → 自旋(避免睡眠开销)
中等竞争 → 睡眠 + 公平竞争(吞吐优先)
严重竞争 → 饥饿模式(公平优先,防止饿死)

Go mutex 的设计哲学:一个锁,覆盖从”无竞争”到”严重竞争”的所有情况,动态切换策略。

这比”提供 4 种锁让你自己选”更好——因为你通常不知道自己的竞争强度,或者竞争强度会随流量变化。

但代价是:Go mutex 在任何单一场景下,都不是”最优”的。 无竞争时它比裸 CAS 慢一点点(有状态检查),严重竞争时它比专门的无锁结构慢。它赢在”通用”和”自适应”。

这又是那个熟悉的权衡:通用 vs 极致。 Go mutex 选择了通用。

一个实用的推论:Go mutex 的”自旋”默认只转 4 圈,且在多核 + 本地队列空时才转。 所以:

  • 如果你的临界区很长(比如里面读文件、调 RPC),自旋是浪费,Go 会很快进入睡眠(正确)
  • 如果你的临界区极短(比如 map 查一下),自旋可能拿到锁,避免睡眠(高效)

所以:保持临界区短,是让锁高效的第一原则。


第四幕:更精细的锁

Q9 读写锁(RWMutex)解决了什么?又引入了什么(写者饥饿)?

RWMutex 的动机:很多场景”读多写少”。

场景:一个配置对象,读得非常频繁(每个请求都读),写得很少(配置更新时才写)

如果用 Mutex:
  所有读都互斥 → 读也被串行化 → 浪费

如果用 RWMutex:
  多个读可以并行(读不互斥)
  只有写才互斥(写和读互斥,写和写互斥)

RWMutex 的规则:

读锁(RLock):多个 goroutine 可以同时持有
写锁(Lock):独占,写和任何其他(读或写)都互斥

组合:
  读 + 读:允许(并行)
  读 + 写:互斥
  写 + 写:互斥

它引入的问题:写者饥饿(writer starvation)。

场景:读非常频繁,写偶尔发生

问题:
  只要还有一个读者持有读锁,写者就进不来
  而读者源源不断地来,写者永远等不到
  → 写者饿死

Go 的 sync.RWMutex 对此的处理:

// Go 的 RWMutex:当有写者在等待时,新来的读会被阻塞
// 目的:让写者有机会执行,避免饿死
 
func (rw *RWMutex) RLock() {
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // readerCount < 0 说明有写者在等待(写者把 readerCount 减去了一个很大的数)
        // 新来的读要阻塞,给写者让路
        runtime_SemacquireMutex(&rw.readerSem, ...)
    }
}

Go 的实现细节(巧妙):

readerCount:
  - 正常时 = 当前读者的数量(正数)
  - 有写者等待时 = 被减去一个很大的数(变成负数)
  - 所以 "readerCount < 0" 表示"有写者在等"

这样用一个 int 同时表达了"读者数量"和"是否有写者等待"两个信息。

RWMutex 的另一个陷阱:读锁的”假共享”。

每个 goroutine 加读锁,都要 atomic.AddInt32(&readerCount, 1)
这个原子操作会修改 readerCount 这个缓存行

高并发读时,readerCount 这个缓存行在所有 CPU 之间"乒乓"
→ 读锁的原子操作成为瓶颈

所以 RWMutex 也不是”读多写少”的银弹:

  • 如果读操作本身极快(纳秒级),读锁的原子开销可能比读本身还大
  • 如果读操作较慢(微秒级),RWMutex 才有意义

一个经验法则:RWMutex 只在”读操作明显比锁开销慢”时才值得用。

如果读操作就是 map[key] 这种纳秒级的,RWMutex 的原子操作开销(几十纳秒)反而成了大头,还不如用 sync.Map 或者分片锁。

Q10 RCU 这种”不加锁的读”是什么原理?

RCU(Read-Copy-Update) 是 Linux 内核里最巧妙的设计之一,它实现了”读完全不加锁”。

核心思想:读不加锁,写用”复制-修改-替换”,延迟回收旧数据。

传统读写锁:
  读要加读锁(有开销),写要加写锁(阻塞所有读)

RCU:
  读:直接读,不加任何锁(零开销!)
  写:复制一份 → 修改副本 → 原子地替换指针 → 延迟回收旧数据

具体过程(以链表的更新为例):

链表:A → B → C(读者可能正在读 B)

写者要修改 B:
  1. 复制 B,得到 B'(新的副本)
  2. 修改 B'(改 B' 的字段,或改 B' 的 next 指针)
  3. 原子地替换指针:把 A.next 从 B 改成 B'
     (这是一个指针的原子写,读者要么读到 B,要么读到 B',都是完整数据)
  4. 等所有读者都"离开"了 B(宽限期 grace period 结束)
  5. 回收旧的 B

关键:第 4 步”宽限期”怎么判断?

RCU 的宽限期(grace period):
  一个读者"进入读临界区"和"离开读临界区"之间,不能有睡眠
  (读临界区是"准原子"的,很短)

  当所有 CPU 都至少经历了一次"上下文切换"或"离开读临界区",
  就认为"没有读者还在用旧数据了",可以回收。

RCU 的精妙之处:

读的零开销:
  读者读数据,完全不加锁、不原子操作、不睡眠
  → 读的速度 = 直接读内存的速度

写的代价转移:
  写的"复制 + 延迟回收"代价,由写者承担
  → 而写本来就少,可以接受

适合"读极多、写极少"的场景

RCU 的代价:

1. 写复杂:要复制、替换、判断宽限期、回收
2. 延迟回收:旧数据不能立即释放,内存占用暂时变高
3. 只适合"读多写少",写多时复制开销大

RCU 在工程里的应用:

  • Linux 内核:路由表、文件描述符表、网络协议栈
  • 数据库:某些 MVCC 实现(读不加锁,写做快照)
  • Go:sync.Map 的读路径(近似 RCU 思想)、atomic.Value(读不加锁)

RCU 给你的启示(这是重要的一课):

消除锁的最好方式,不是”更聪明的锁”,是”改变数据结构,让共享状态变成不可变”。

RCU 的本质是:写不修改旧数据,而是生成新数据,然后原子地切换指针。

这样读者看到的永远是”完整的旧版本”或”完整的新版本”,不需要锁来保护”修改到一半”的状态。

这就是”不可变数据结构”(immutable data structure)和”多版本并发控制”(MVCC)的共同思想。

你的工程里怎么用?

  • 配置对象:每次更新生成一个新配置,原子地切换指针(用 atomic.Value)
  • 缓存:写时不改旧缓存,生成新条目,切换指针
  • 数据库:MVCC 读不加锁,写生成新版本

当”改”变成”换”,锁就消失了。 这是比”优化锁”更高一层的解法。


第五幕:锁的代价与困境

Q11 什么时候用 atomic,什么时候用 Mutex?

这是 Go 程序员最常问的问题。答案是清晰的:

用 atomic(原子操作),如果:

1. 操作是"简单的读-改-写"
   - 计数器:atomic.AddInt64
   - 标志位:atomic.StoreBool / atomic.LoadBool
   - 单值交换:atomic.SwapInt32

2. 不需要保护"多个变量的组合"
   - 原子操作只保护"单个变量"的原子性
   - 如果要保证"a 和 b 一起变化"(不变量),atomic 不够

3. 性能关键路径
   - atomic 比 mutex 快(无竞争时 ~20ns vs mutex ~25ns)
   - 但差距不大,别为了这点差距牺牲清晰度

用 Mutex,如果:

1. 要保护"多个变量的组合"
   - 比如:两个字段必须一起改(转账的 from 和 to)
   - atomic 无法表达"这两个字段是绑定的"

2. 临界区包含"复合操作"
   - 比如:查 map 再改 map,这不是单个原子操作

3. 代码可读性优先
   - mutex.Lock() / defer mu.Unlock() 清晰表达"这段是临界区"
   - atomic 的 CAS 循环对不熟悉的人更难懂

一个判断的实用框架:

问自己:要保护的是"单个值"还是"一个不变量"?

单个值 → atomic(counter++,flag 切换)
不变量(多个值的一致性)→ mutex

例子:
  counter++              → atomic(单值)
  user.Age = 30          → 如果 Age 是单字段,atomic 或 mutex 都行
  转账(from--, to++)    → mutex(两个字段的一致性)
  map 的读写              → mutex 或 sync.Map(map 是复合结构)
  链表插入               → mutex(改 next 指针 + 其他,是复合操作)

一个常见的误区:

// 误区:以为用 atomic 保护单个字段就够了
var count atomic.Int64
 
func process() {
    count.Add(1)          // 原子,没问题
    // ... 
    if count.Load() > 100 {  // 原子读
        // 但 count 可能在两次 Load 之间变了
        doSomething(count.Load())  // 这里的 count 可能又变了
    }
}

atomic 保证”单次读写是原子的”,但不保证”多次读写的组合”是原子的。如果你需要”读 + 判断 + 操作”的原子性,还是得用 mutex,或者用 CAS 循环(但那已经是”无锁”的复杂写法了)。

核心原则:atomic 保护”单个操作”,mutex 保护”一段逻辑”。

Q12 锁面临哪些结构性的问题?

最后泼冷水。锁这套机制,有几个从根上解决不了的问题:

问题一:锁的”正确性”和”性能”的永恒矛盾。

锁越粗(大锁):正确性容易保证,但并行度低,性能差
锁越细(小锁):并行度高,但容易死锁、容易漏锁,正确性难保证

没有"免费"的中间点。你必须在"简单但慢"和"快但难"之间选。

问题二:锁的”不可组合性”(这是最深刻的)。

锁的一个根本问题:你不能"组合"两个正确的锁操作,得到另一个正确的锁操作。

例子:
  transfer(from, to) 需要同时锁 from 和 to
  deposit(account)   锁 account
  withdraw(account)  锁 account

  如果 transfer 的实现是:
    lock(from); lock(to); ...
  而另一个 transfer(to, from) 是:
    lock(to); lock(from); ...
  → 死锁!因为锁的顺序相反(第 6 章讲死锁)

  这就是"不可组合":你单独看每个操作都是对的,
  但组合起来就出问题。而锁无法自动"组合"。

这个问题的根源:锁是”过程式”的,不是”声明式”的。

锁要求你"手动"指定"锁哪些、按什么顺序锁",
而"正确性"依赖"所有代码都遵守约定"。

一旦有人不遵守约定(锁的顺序搞反了),就死锁。
而编译器/运行时无法自动检测这种错误(静态分析只能发现部分)。

这正是”软件事务内存(STM)”、“函数式编程(无共享可变状态)”、“actor 模型”想要解决的问题——用更高级的抽象,避免手动的锁管理。

问题三:锁的”公平性”与”吞吐”的权衡。

公平锁(FIFO):等待最久的先拿锁,公平,但吞吐低(每次交接有开销)
非公平锁:新来的可以插队,吞吐高,但可能饿死等待者

Go mutex 的正常模式(非公平)和饥饿模式(公平)的切换,就是在这个权衡上做文章。

问题四:锁保护不了”锁外的不变量”。

锁只能保护"临界区内的代码",但锁外的不变量无法保护。

例子:
  type User struct {
      mu   sync.Mutex
      name string
      age  int
  }
  
  // 有锁保护,安全
  func (u *User) SetAge(a int) {
      u.mu.Lock()
      u.age = a
      u.mu.Unlock()
  }
  
  // 但这里暴露了内部字段,绕过了锁
  func (u *User) Age() int {
      return u.age   // 没加锁!如果有并发写,这里读到的是不一致的值
  }

Go 的哲学:sync.Mutex 应该和它保护的数据放在同一个结构体里,通过方法访问,而不是直接暴露字段。 但这是”约定”,不是”强制”——语言层面无法保证。

问题五:锁不能解决”优先级反转”和”锁持有者被抢占”。

场景:
  低优先级线程 A 持有锁
  高优先级线程 B 等锁
  中优先级线程 C 抢占 A(A 被挂起,锁迟迟不释放)
  → B 被"事实上"的阻塞,虽然 B 优先级最高
  → 这就是优先级反转(第 11 章详细讲)

Mars Pathfinder 火星探测器 1997 年就是因为优先级反转反复重启。

问题六:锁是”悲观”的,它假设”会有冲突”,所以提前加锁。

悲观锁(mutex):假设会冲突,先加锁
乐观锁(CAS):假设不冲突,先做,冲突了再重试

乐观锁在"冲突少"的场景更好(无锁开销),
悲观锁在"冲突多"的场景更好(避免反复重试)。

这就是数据库的"乐观锁 vs 悲观锁"之争,
和内存里的 "atomic vs mutex" 是同一个问题。

总结这一章的核心思想:

锁是把”并行”变”串行”来保证正确性的工具。

它的成本是并行度的损失。所以锁的设计,本质是”在正确的前提下,尽量减少串行化的范围和时间”。

从硬件原子指令(最小串行化)→ 自旋锁(短时间串行)→ mutex(睡眠让出 CPU)→ RCU(读不串行,写生成新版本),这条链上每一环,都是在”减少串行化”上更进一步。

而更高明的做法,是第 6、7 章会讲的:通过设计(锁顺序、无锁结构、不可变数据)来减少甚至消除对锁的需求。


思考题

思考题 1

面试官问:“Go 的 sync.Mutex 加锁失败后,是立即睡眠还是先自旋?为什么?“请回答,并说明 Go mutex 的”正常模式”和”饥饿模式”的区别,以及为什么要设计这两种模式。

参考答案

核心答案:先自旋(有条件的),再睡眠,且区分正常模式和饥饿模式。

第一步:加锁失败后的完整流程。

1. 先尝试 CAS 抢锁(无竞争时一步到位)

2. CAS 失败 → 判断能否自旋:
   自旋条件(runtime_canSpin):
     - 多核(单核自旋无意义)
     - 当前 P 的本地运行队列为空
       (否则还不如去跑别的 goroutine,自旋纯浪费)
     - 自旋次数有限(约 4 次)
   
   能自旋 → 自旋几圈,赌锁很快释放
   (自旋期间用 pause 指令,降低功耗和对总线的压力)

3. 自旋还拿不到 → 进入正常模式,通过 sema 睡眠
   - 加入等待队列(waiterCount++)
   - 睡眠,让出 CPU

4. 被唤醒后 → 重新竞争锁

第二步:正常模式 vs 饥饿模式的区别。

正常模式(默认):
  - 被唤醒的 goroutine 不直接拿锁
  - 而是和"新来的 goroutine"一起竞争
  - 新来的 goroutine 可能插队(它已经在 CPU 上跑,CAS 更快)
  - 优点:吞吐高(减少锁的交接开销)
  - 缺点:等待的 goroutine 可能饿死(一直抢不过新来的)

饥饿模式(触发条件:某个 goroutine 等待超过 1ms):
  - 锁释放时,直接"交接"给队头的等待者,不做竞争
  - 新来的 goroutine 不抢锁,直接去排队
  - 优点:公平(FIFO,不会饿死)
  - 缺点:吞吐低(交接有开销,且新来的无法利用"锁刚好释放"的机会)

切换回正常模式的条件:
  - 队头的等待者等待时间 < 1ms
  - 或者它是最后一个等待者

第三步:为什么要设计两种模式?

因为**“公平”和”吞吐”是对立的**:

非公平(正常模式):吞吐高,但可能饿死
公平(饥饿模式):不会饿死,但吞吐低

Go 的设计:
  平时用非公平(追求吞吐)
  一旦检测到"饿死"的风险(等待 > 1ms),切换到公平(保证正确性)
  → 兼顾吞吐和公平

第四步:这个设计的深层含义。

Go mutex 不是一个”简单的锁”,而是一个自适应状态机:

竞争弱 → 纯 CAS(最快)
竞争中等 → 自旋 + 睡眠(平衡)
竞争强 → 睡眠 + 非公平竞争(吞吐优先)
竞争极强 → 饥饿模式(公平优先)

它根据竞争强度动态切换策略,目标是”在不同负载下都保持合理性能”。

追问:为什么自旋要”P 的本地队列为空”这个条件?

因为自旋的收益是”避免睡眠的开销”,但如果 P 的本地队列里有其他 goroutine 等着跑,那”让出 CPU 去跑别的 goroutine”比”自旋等锁”更有价值——自旋是纯浪费,跑别的 goroutine 是有意义的。

所以自旋的条件是”没有别的事可做时才自旋”。

追问:自旋为什么只转 4 圈,不转更多?

自旋的收益随时间递减:

  • 转 1-4 圈:可能在锁释放的瞬间拿到,值得
  • 转几十圈:锁大概率”不会很快释放”了,继续自旋是浪费

而且自旋会给内存总线带来压力(反复抢缓存行)。所以自旋要”浅尝辄止”,转几圈拿不到就睡眠。

这道题考察什么:

  1. 知不知道 mutex 的”自旋 + 睡眠”两层结构
  2. 知不知道自旋的条件(多核、队列空、次数有限)
  3. 知不知道正常模式和饥饿模式的区别和切换条件
  4. 能不能说出”公平 vs 吞吐”这个本质权衡

思考题 2

你有一个热点配置对象(一个 struct),被所有请求频繁读取,偶尔(每秒一次)更新。你会怎么设计?对比 Mutex、RWMutex、atomic.Value 三种方案,并说明各自的问题。

参考答案

这是”读多写少”的经典场景。逐个分析:

方案一:Mutex。

type Config struct {
    mu     sync.Mutex
    data   ConfigData
}
 
func (c *Config) Get() ConfigData {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.data   // 返回副本
}
 
func (c *Config) Update(d ConfigData) {
    c.mu.Lock()
    c.data = d
    c.mu.Unlock()
}

问题:读也被串行化了。高并发读时,所有读都排队等锁,锁成为瓶颈。这是最差的方案(对读多写少场景)。

方案二:RWMutex。

type Config struct {
    mu     sync.RWMutex
    data   ConfigData
}
 
func (c *Config) Get() ConfigData {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data
}
 
func (c *Config) Update(d ConfigData) {
    c.mu.Lock()
    c.data = d
    c.mu.Unlock()
}

改进:多个读可以并行。但仍有问题:

1. 每次读都要 RLock(atomic 操作,约 20-50ns)
   如果读操作本身很快(比如就是返回一个指针),
   RLock 的开销可能比读本身还大

2. RLock 会修改 readerCount 这个共享缓存行
   高并发读时,这个缓存行在所有 CPU 之间"乒乓"
   → 假共享(第 7 章讲),成为隐藏瓶颈

3. 返回 c.data 是一次拷贝(如果 ConfigData 大,拷贝也贵)

方案三:atomic.Value(推荐)。

type Config struct {
    v atomic.Value  // 存 ConfigData
}
 
func (c *Config) Load() ConfigData {
    return c.v.Load().(ConfigData)  // 无锁读!
}
 
func (c *Config) Store(d ConfigData) {
    c.v.Store(d)  // 无锁写
}

这是 RCU 思想的用户态实现:

读:atomic.Value.Load 是"无锁读",就是一次原子的指针读
   (Go 1.19 之前用 interface,1.19 后 atomic.Value 有更高效的实现)
   → 读的开销接近零(就是读一个指针)

写:atomic.Value.Store 生成新版本,原子地切换指针
   → 写是"先复制新值,再原子替换",读永远看到旧值或新值

为什么 atomic.Value 最好:

1. 读零开销:就是读一个指针,没有锁、没有原子 RMW
2. 读不阻塞写、写不阻塞读:
   - 读读的是"旧版本"的指针,写切换指针后,读自然读新版本
   - 中间不需要任何协调
3. 天然是"快照"语义:
   一次 Load 拿到的是一个"一致的快照"
   (因为切换指针是原子的,读者要么拿旧要么拿新)

但 atomic.Value 有约束(这是坑):

1. 存储的类型必须一致
   - 第一次 Store 的类型,决定了之后只能 Store 同类型
   - 存不同的类型会 panic

2. 存的是"值",且是"复制"
   - Store(d) 把 d 复制一份存进去
   - 如果 d 是大的 struct,复制有开销(但读多写少,可接受)

3. 不要存"可变的引用"
   - 如果存的是 map/slice(引用类型),
     Store 后 map 内部还是可能被并发修改
   → 正确做法:存"不可变"的值,或者存"每次更新都生成新副本"

关键原则(这是这道题的核心):

“读多写少”的最佳解法,不是”更聪明的锁”,是”不可变数据 + 原子切换”。

  • 更新配置 = 生成一个新的 ConfigData(不可变)
  • 切换 = atomic.Value.Store(原子地换指针)
  • 读 = 直接读当前指针(零开销)

这就是 RCU / MVCC / 不可变数据结构的核心思想:用”版本切换”代替”原地修改”,读就天然无锁了。

一个补充:如果配置更新频率不低(比如每秒 100 次),atomic.Value 还合适吗?

合适。因为读仍然是无锁的,写虽然每次要复制,但每秒 100 次复制 vs 每秒几万次读,写的小开销完全可以忽略。

但如果配置更新”极频繁”(每秒几万次),atomic.Value 的复制开销就上来了。 这时要重新考虑:

  • 是不是真的需要”全局共享配置”?能不能分片?
  • 能不能用”版本号 + 引用计数”的延迟回收?

但这种情况很罕见,大多数”读多写少”场景,atomic.Value 都是最优解。

总结对比表:

方案读开销写开销并发读复杂度适用
Mutex锁(~25ns+)锁串行低读写都频繁
RWMutex读锁(~20-50ns)写锁并行低读多写少,读较重
atomic.Value指针读(~1ns)复制+原子写完全无锁中读极多,写极少

思考题 3

你在写一个”限流器”,用计数器记录”当前窗口的请求数”。10 个 goroutine 并发处理请求,每个请求都要”检查是否超限,没超则计数+1”。你会用 Mutex 还是 atomic?如果限流逻辑变成”检查计数 + 记录时间戳(两个字段)“,答案会变吗?为什么?

参考答案

第一问:单个计数器,用 atomic。

// 场景:只有 counter 一个字段
var counter int64
 
func tryAcquire(limit int64) bool {
    n := atomic.AddInt64(&counter, 1)  // 原子加一
    if n > limit {
        atomic.AddInt64(&counter, -1)  // 超了,回滚
        return false
    }
    return true
}

理由:这里是”单个变量”的读-改-写,atomic.AddInt64 完美契合,且比 Mutex 快(无锁竞争)。

但注意这个实现的细节:为什么先加一再判断,而不是”先读再判断再 CAS”?

// 错误写法(有竞态窗口):
n := atomic.LoadInt64(&counter)
if n < limit {
    atomic.AddInt64(&counter, 1)  // 这里 counter 可能已经被别人加了
}
 
// 正确写法(先加再判断,超了回滚):
n := atomic.AddInt64(&counter, 1)
if n > limit {
    atomic.AddInt64(&counter, -1)  // 回滚
}

先加再判断是”乐观”的:假设不超限,先加,超了回滚。这样避免了”读-判断-写”之间的竞态窗口。

第二问:变成”计数器 + 时间戳”两个字段,答案变了。

// 现在要保护两个字段的一致性:
type Window struct {
    count int64
    start time.Time  // 窗口开始时间
}
 
// 问题:count 和 start 必须"一起"更新
// 如果窗口过期了,要"重置 count=0 且 start=now",这两个操作必须原子
// 单独用 atomic 保护不了"两个字段的一致性"

正确做法:用 Mutex。

type Window struct {
    mu    sync.Mutex
    count int64
    start time.Time
}
 
func (w *Window) tryAcquire(now time.Time, limit int64) bool {
    w.mu.Lock()
    defer w.mu.Unlock()
    
    if now.Sub(w.start) > time.Second {
        // 窗口过期,重置
        w.start = now
        w.count = 0
    }
    
    if w.count >= limit {
        return false
    }
    w.count++
    return true
}

为什么变了?

因为出现了不变量(invariant):

不变量:count 和 start 必须对应"同一个窗口"
即:start 是"这个窗口的开始时间",count 是"这个窗口内的计数"

如果 count 和 start 分开原子更新,中间状态会破坏这个不变量:
  线程 1:读到 count=100(窗口 A 的计数)
  线程 1:判断 start 过期了,准备重置
  线程 2:同时读到 start(窗口 A 的开始),count=100
  → 两个线程看到的"count 和 start"不属于同一个窗口

核心原则(这是这道题想让你记住的):

atomic 保护”单个值的原子性”,mutex 保护”多个值组成的不变量”。

判断标准:

  • 要保护的是一件事(一个计数、一个标志)→ atomic
  • 要保护的是”几个东西之间的约束关系”(count 和 start 对应同一个窗口)→ mutex

因为不变量是”跨多个字段的一致性”,而 atomic 只能保证”单个字段”的原子性,保证不了”多个字段一起变化”。

一个进阶追问:能不能用 atomic 实现”两个字段的原子更新”?

可以,但要”打包”:

// 把两个字段打包进一个 atomic.Value(或者用一个 struct + CAS)
type WindowState struct {
    count int64
    start time.Time
}
 
var state atomic.Value  // 存 WindowState
 
// 读:无锁读整个 state
// 写:生成新的 state,CAS 切换
 
func tryAcquire(now time.Time, limit int64) bool {
    for {
        old := state.Load().(WindowState)
        // 窗口过期?生成新 state
        if now.Sub(old.start) > time.Second {
            newState := WindowState{count: 1, start: now}
            if state.CompareAndSwap(old, newState) {
                return true
            }
            continue  // CAS 失败,重试
        }
        // 窗口内,检查计数
        if old.count >= limit {
            return false
        }
        newState := WindowState{count: old.count + 1, start: old.start}
        if state.CompareAndSwap(old, newState) {
            return true
        }
        // CAS 失败,重试
    }
}

这是”乐观锁 + CAS 重试”的经典模式。它无锁,但代码复杂度高,且高竞争时 CAS 重试会变成自旋。

对比:

方案复杂度高竞争表现适用
Mutex低好(睡眠,不空转)大多数场景
atomic 打包 + CAS高差(CAS 自旋)低竞争 + 极致性能

结论:除非你有确凿的性能瓶颈证据,否则”两个字段的不变量”用 Mutex 就对了。 无锁的 CAS 重试虽然炫,但在高竞争下反而更差,且难维护。

这道题的元教训:

选择锁的类型,本质是问”我要保护的原子性边界在哪”。

  • 单值 → atomic(边界是一个值)
  • 多值不变量 → mutex(边界是一段逻辑)
  • 多值 + 读多写少 + 不可变 → atomic.Value / RCU(用版本切换消除锁)

思考题 4(开放题)

“锁的粒度”是个经典话题。你的服务有一个全局的 map[string]*User(用户信息缓存),10 个 goroutine 频繁读写。你会选择:A)一个大锁保护整个 map;B)分片锁(把 map 分成 N 片,每片一个锁);C)sync.Map。分析各自的优劣,并说明选型依据。

参考答案

开放题,核心是理解”锁粒度”的权衡。

先明确场景:

全局 map[string]*User
- 读:频繁(每个请求都查用户信息)
- 写:较频繁(用户信息更新、新用户注册)
- 10 个 goroutine 并发

注意:这里说的"读"和"写"都是"对 map 的操作",
而 map 的 value 是 *User(指针),指针指向的对象可能还有自己的并发问题
(这里先简化,假设只讨论 map 本身的并发安全)

方案 A:一个大锁。

type UserCache struct {
    mu    sync.RWMutex
    users map[string]*User
}
 
func (c *UserCache) Get(id string) *User {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.users[id]
}

优点:

  • 简单,不可能出错
  • 正确性容易保证

缺点:

  • 所有读写都串行化(RWMutex 下读可以并行,但写串行,且读锁也有原子开销)
  • 10 个 goroutine,实际上”读 map”这个操作被锁限制了并行度

问题:map 的读操作本身很快(哈希查找,几十纳秒),但 RWMutex 的 RLock 也要 20-50ns,且高并发时 readerCount 缓存行会乒乓。锁的开销可能和读本身相当。

方案 B:分片锁(sharded lock)。

type UserCache struct {
    shards [256]*shard  // 256 个分片
}
 
type shard struct {
    mu    sync.RWMutex
    users map[string]*User
}
 
func (c *UserCache) shardOf(id string) *shard {
    h := fnv32(id)
    return c.shards[h % 256]
}
 
func (c *UserCache) Get(id string) *User {
    s := c.shardOf(id)
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.users[id]
}

优点:

  • 锁竞争降低 256 倍:不同分片的读写不互斥
  • 10 个 goroutine,大概率落在不同分片,几乎无竞争
  • 这是”分而治之”的经典应用

缺点:

  • 代码复杂一点(要算分片、要处理分片数量选择)
  • 分片数怎么定?(太多浪费内存,太少竞争大)
  • 跨分片的操作麻烦(比如”遍历所有用户”要锁所有分片)

分片数怎么选?

经验:分片数 = 并发度 × 4 到 × 16
比如 10 个 goroutine,256 个分片绰绰有余

或者:分片数 = 2 的幂(方便用位运算取模)

关键:让"同一个分片被多个 goroutine 同时访问"的概率足够低

方案 C:sync.Map。

var users sync.Map
 
func get(id string) *User {
    v, ok := users.Load(id)
    if !ok { return nil }
    return v.(*User)
}

sync.Map 是 Go 标准库提供的”并发安全 map”,它内部是”读优化”的设计:

sync.Map 的内部结构:
  - read:一个只读的 atomic.Value,存"热数据"(最近常用的)
  - dirty:一个普通 map,存"全部数据"(含冷数据)
  - 读:先查 read(无锁!),命中就返回;未命中查 dirty(加锁)
  - 写:写 dirty(加锁),且"提升"逻辑维护 read 和 dirty 的同步

它的设计思想:读多写少时,大部分读命中 read(无锁),只有少数读要落到 dirty(加锁)。

sync.Map 的适用场景(官方文档明确说了):

适用(两个条件同时满足):
  1. 一个 key 只写一次,之后只读(write-once, read-many)
     - 比如缓存"启动时加载,之后只读"
  2. 多个 goroutine 读/写/遍历的是"不相交的 key 集合"
     - 比如每个 goroutine 用自己专属的 key 前缀

不适用(大多数"普通 map + 锁"的场景):
  如果 key 会被反复更新、或者读和写相交,
  sync.Map 的 read/dirty 同步开销反而比"map + RWMutex"大

sync.Map 的坑:

1. 类型不安全:interface{},要自己断言
2. 没有 Len()(遍历才能数,慢)
3. Range 期间删改语义微妙
4. 不适合"写多"的场景(dirty 同步开销大)

选型决策树:

1. 读多写少 + key 一次性写入 + 读远多于写?
   → sync.Map

2. 读写都频繁,但数据量不大(< 几十万)?
   → map + RWMutex(简单够用)

3. 读写都频繁,且需要高并发(几十个 goroutine + 大 map)?
   → 分片锁(方案 B)

4. 有"遍历所有数据"的需求?
   → 慎用 sync.Map(Range 慢),用 map + RWMutex 或分片

5. 极致性能 + 熟悉无锁编程?
   → 无锁哈希表(但极度复杂,一般不建议)

我的推荐(针对这个具体场景):

场景:全局用户缓存,10 个 goroutine 读写都频繁

首选:分片锁(方案 B)

理由:
  1. 10 个 goroutine 并发,一把大锁会成为瓶颈
  2. 分片锁把竞争降低 256 倍,10 个 goroutine 基本无竞争
  3. 代码复杂度可控(比无锁简单得多)
  4. 256 个分片的内存开销很小(每个分片就一个 map + 一个锁)
  
次选:如果"读远多于写"且 key 基本不变 → sync.Map
备选:如果并发度低(< 4)或 map 很小 → 一把 RWMutex 够了

这道题想让你理解的核心:

锁粒度 = 在”简单性”和”并行度”之间找平衡。

  • 大锁:简单,但并行度低
  • 细锁(分片):并行度高,但复杂(分片数、跨分片操作)
  • 无锁:并行度最高,但最复杂(最容易出错)

关键不是”越细越好”,而是”匹配你的并发度和数据规模”。

10 个 goroutine,一把 RWMutex 可能就够(除非读操作被锁开销淹没);100 个 goroutine,才值得分片;而 sync.Map 只适合”读多写少 + 一次性写入”的特殊场景。

别一上来就上最复杂的方案。先用最简单的(一把锁),压测,发现锁是瓶颈了,再细化(分片)。

一个容易被忽略的点:value 的并发问题。

上面的讨论都假设”map 本身”的并发安全,但 map 里存的是 *User(指针)。即使 map 安全了,指针指向的 User 对象本身可能还有并发修改问题:

u := cache.Get(id)   // 安全地拿到指针
u.Name = "new"        // 但改 Name 字段,和别处的并发读 Name 冲突!

所以完整的并发安全设计,还要考虑”value 的不可变性”(更新时复制新的 User,而不是原地改)。这又回到了 RCU / 不可变数据的思想。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 05 章 · 原子与锁:从 CAS 到 futex 的四层退化                 │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【锁的本质】                                                  │
│    用"串行化"保证"正确性",代价是"并行度损失"                     │
│    所有锁设计都在追求:串行化的范围最小、时间最短                  │
│                                                              │
│  【从硬件到用户的退化链】                                        │
│    原子指令(缓存锁/总线锁)→ 自旋锁 → futex/mutex → RCU         │
│    每一环都在"减少串行化"上更进一步                               │
│                                                              │
│  【自旋 vs 睡眠】                                               │
│    自旋:用 CPU 换延迟,适合"锁很快释放"(多核 + 短临界区)        │
│    睡眠:让出 CPU,适合"锁可能长时间持有"                         │
│                                                              │
│  【futex 的精髓】                                               │
│    快路径(无竞争,用户态 CAS,零系统调用)                       │
│    慢路径(有竞争,陷入内核睡眠)                                 │
│    + 关键的"再检查"(防止错过唤醒)                               │
│                                                              │
│  【Go mutex 四层状态机】                                        │
│    CAS 快路径 → 自旋 → 正常模式(非公平,吞吐优先)                │
│    → 饥饿模式(公平,防饿死)                                    │
│    本质:根据竞争强度自适应                                       │
│                                                              │
│  【消除锁的最高境界】                                            │
│    RCU:读不加锁,写"复制-替换-延迟回收"                          │
│    本质:把"改"变成"换",用不可变数据 + 原子切换                  │
│    = MVCC / 不可变数据结构 / atomic.Value 的共同思想              │
│                                                              │
│  【选型口诀】                                                   │
│    单个值 → atomic                                              │
│    多值不变量 → mutex                                           │
│    读多写少 → RWMutex 或 atomic.Value(不可变+切换)             │
│    高并发大 map → 分片锁                                         │
│    一次性写入后只读 → sync.Map                                   │
│                                                              │
│  【一句话】                                                     │
│    锁是把并行变串行来保证正确性;                                 │
│    高手的境界是"避免并发",然后是"减少串行化",最后才是"优化锁"。  │
│                                                              │
│  【下一章预告】                                                  │
│    锁能保证单个临界区的正确,但多个锁组合起来——                    │
│    死锁、活锁、饥饿,秩序崩塌的三种方式,下一章讲。                 │
└──────────────────────────────────────────────────────────────┘