Theory · OS · Synchronization & Mutual Exclusion

同步与互斥

竞态 → 原子指令 → 锁的分层实现:自旋 / 睡眠 / futex —— 从硬件 CAS 到 Go sync.Mutex 的完整栈

问题根源

i++ 是三条指令——共享 + 交错 = 丢失更新;原子性、可见性、有序性是并发三原罪

实现分层

硬件原子指令打底 → 自旋锁忙等 → futex 快慢两路 → 互斥锁/条件变量/信号量的语义封装

Go 现实

sync.Mutex 的 normal/starvation 双模式、RWMutex 写优先——教科书原则在 runtime 里的真实落地

定位:OS 系列第五篇,与 Go 岗的 sync-primitives deck 形成"OS 层 / runtime 层"双视角。主线:一个问题(竞态)、一层硬件(原子指令)、四把锁(自旋/互斥/条件变量/信号量)。

Race Condition · i++ Anatomy

竞态条件:i++ 为什么会丢更新

两个线程并发执行 i++ 的交错时序与丢失更新 时序图:i++ 展开为读内存、寄存器加一、写回三步。线程 A 读到 5,线程 B 也读到 5,两者各自加一并写回,最终 i 等于 6 而不是 7,发生丢失更新。右栏列出并发三原罪:原子性、可见性、有序性。 线程 A 线程 B 内存 i ① load i → rA(=5) ② rA++ → 6 ③ store rA → i load i → rB(=5) rB++ → 6 store rB → i(=6) i = 5 i = 5 i = 6 i = 6 两次自增,只加了 1 —— 丢失更新 并发三原罪(不仅是丢更新) 原子性:i++ 非原子,交错即错 可见性:A 的写还在 store buffer / cache,B 读到旧值 有序性:编译器/CPU 重排让"看似正确"的顺序失效 → 需要内存屏障(MESI 协议下的 store buffer 是根源)
i++ 三步展开是所有并发讨论的起点。三原罪里可见性与有序性由硬件(缓存/重排)引入,原子性由"多条指令"引入——对应三种解决武器:原子指令、屏障、锁。

Atomic Primitives in Hardware

互斥的地基:硬件原子指令

为什么关中断不够

单核时代"进临界区前关中断"确实可行;多核下另一核照常访问共享内存——关中断只挡调度,不挡并发访存。而且用户态根本无权关中断。于是需要"读改写一条完成"的指令:CPU 用缓存一致性协议(如对 cache line 加锁 / 总线锁)保证其原子性。

test-and-set / exchange

原子地"读旧值 + 写新值"。用它把"检查锁 + 上锁"合成一步,锁就不会在检查与上锁之间被抢——自旋锁的最小实现。

CAS(compare-and-swap)

原子地"比较等于期望值才写入":x86 的 lock cmpxchg、ARM 的 LL/SC 对(ldxr/stxr)。CAS 是现代无锁数据结构与所有互斥锁快路径的通用积木——Go 的 atomic 包全部映射到它。

// 用 TAS 实现自旋锁(教科书版)
func Lock(l *int32) {
    for atomic.Swap(l, 1) == 1 {
        // 换到 1 说明别人持锁 → 忙等
        // (改进:先读后写,减少总线风暴)
    }
}
func Unlock(l *int32) {
    atomic.Store(l, 0)
}

// CAS 语义(Go atomic 包)
swapped := atomic.CompareAndSwapInt32(
    &v, old, new) // v==old 时才写 new
朴素自旋锁的两个毛病:总线风暴——所有等待者不停 CAS 同一变量,缓存一致性流量爆炸(改用先普通 load、失败再 CAS 的 test-and-test-and-set);② 不公平——刚释放的线程立刻抢回锁,后来者可能饿死(排队自旋锁 / MCS 锁用链表队列解决,Go sync.Mutex 的队列化是同思路)。
面试金句:"所有锁最终都落在一条原子指令上——锁的语义在软件,原子性在硬件(缓存一致性协议背书)。"
TAS/CAS 二选一讲透即可,关键是"把检查与写入合成一条指令"。TAS→TTAS→排队锁的演化线,为 Go mutex 的 queue 做铺垫。

Spinlock vs Blocking Mutex

拿不到锁怎么办:忙等还是睡觉

维度自旋锁 spinlock互斥锁 mutex(睡眠)
等待方式原地循环重试(忙等)挂起线程,让出 CPU
开销来源占着 CPU 空转两次上下文切换 + 唤醒延迟
适用临界区极短(几条指令)、持锁者马上放较长、可能睡眠(I/O)
能否在内核使用能(中断上下文唯一选择)中断上下文不能睡
单核陷阱持锁者被抢 → 等待者空转烧完时间片无此问题
混合策略(现实答案):先自旋一小会儿赌"持锁者马上放"(避免昂贵的睡醒往返),超时就睡觉——Linux 内核 mutex 的 osq + 自适应自旋、glibc 与 Go 的 mutex 都是这个两段式。

决策依据:临界区时长 vs 切换成本

临界区 < 一次上下文切换成本(μs 级)→ 自旋划算;临界区长或持锁睡眠 → 自旋是纯浪费(烧 CPU 还拖慢持锁者)。自适应自旋就是 runtime 用历史信息在线做这道题。

内核语境(面试区分)

Linux 内核里 spinlock 是硬约束选择——中断处理、调度器自身不能睡眠;mutex 才能睡。用户态则相反:pthread_mutex 几乎总是首选,自旋锁只在极热的短临界区(内核池、无锁队列旁路)才值得。

读写锁速览(第 7 页展开)

读多写少场景把"互斥"放宽为"读共享、写独占"——但写者公平性与 cache 一致性开销是新的坑,Go RWMutex 甚至有"递归读锁可能死锁"的陷阱。

一句话总纲:"自旋买延迟,睡眠买吞吐,混合策略两头赌"。内核/用户态语境差异(中断上下文不能睡)是高级追问点。

Fast Userspace Mutex

futex:无竞争零系统调用的两阶段设计

futex 的快路径与慢路径 流程图:加锁先在用户态用 CAS 尝试,无竞争时直接成功返回,零系统调用;CAS 失败进入慢路径,调用 futex_wait 系统调用在内核的等待队列上睡眠;解锁方调用 futex_wake 唤醒一个等待者。内核按地址哈希维护等待队列。 USER SPACE · 快路径(无竞争 = 零系统调用) mutex_lock:CAS(0 → 1) 锁空闲 → 直接成功 CAS 失败:有竞争 先短暂自适应自旋 mutex_unlock:CAS(1 → 0) 若无等待者 → 完事 仍拿不到 → 陷入内核 KERNEL SPACE · 慢路径(futex(2)) futex_wait(uaddr, expected) 校验值没变 → 线程挂到该地址的等待队列,睡眠 futex_wake(uaddr, n) 解锁方发现有等待者 → 唤醒 n 个 唤醒 内核按 uaddr 哈希分桶维护等待队列 · wait 前重校验原子变量防止"唤醒丢失" · futex 是 pthread_mutex / Go sync.Mutex 慢路径的共同地基
futex 的洞察:竞争是少数派,把"无竞争情形"留在用户态(一次 CAS),把"睡眠/唤醒"这种必须内核才能做的事留给慢路径。wait 前重校验防"唤醒丢失"是实现级亮点。

Condition Variable

条件变量:等"某个条件成立"的标准姿势

解决什么问题

互斥锁只能保证"一次只有一个",但线程常常要等"某个谓词成立"(缓冲区非空、队列有位)才能干活。自旋检查谓词浪费 CPU;条件变量提供"睡眠等通知"。

为什么必须配互斥锁

"检查谓词 + 睡眠"必须是原子的,否则"检查完不成立 → 刚要睡 → 生产者恰好放入并 signal → 再睡"——唤醒丢失。所以 wait 前必须持锁,内核在 wait 内部原子地"放锁 + 睡眠 + 醒后重新拿锁"。

为什么用 while 不用 if

被唤醒后谓词可能又变了:① 虚假唤醒(实现允许无故唤醒);② 其他线程抢先消费,条件再次不成立。规范写法:while (!条件) wait(c, m);——醒来重查。

// 消费者线程(标准姿势)
pthread_mutex_lock(&m);
while (queue.empty()) {     // while! 不是 if
    pthread_cond_wait(&cv, &m);
    // 内部:原子地 解锁+睡眠
    // 醒来后:重新加锁再返回
}
item = queue.pop();
pthread_mutex_unlock(&m);

// 生产者线程
pthread_mutex_lock(&m);
queue.push(item);
pthread_cond_signal(&cv);   // 唤一个
// broadcast: 唤醒全部(如状态翻转)
pthread_mutex_unlock(&m);
signal 放锁内还是锁外:都能对,取舍不同——锁内(如上)逻辑最直观;锁外(唤醒者不阻塞)可减少"唤醒后立刻又撞锁"的惊群,性能有时更好。Java/Go 的条件原语已把这个选择封装掉。
面试金句:"条件变量 = 锁(保护谓词)+ 等待队列(等谓词)的组合:if 换 while 是对虚假唤醒与抢先消费的双重防御。"
两个必考点:wait 必须持锁(防唤醒丢失)、while 不用 if(防虚假唤醒+抢先消费)。signal 锁内外之争是工程口味题。Go 里对应 sync.Cond(同样要求持 Lock)。

Semaphore · RWLock

信号量与读写锁:放宽互斥的两把尺子

信号量的语义

内核/runtime 维护的原子计数器 + 等待队列:P(减一,不足则睡)与 V(加一,唤醒等待者)。与互斥锁的本质区别:无所有权——A 加的锁必须 A 还;信号量 B 的 V 可以合法唤醒 C 的 P。于是它能表达"资源池有 N 份"。

二值信号量 ≠ 互斥锁

初值 1 的信号量看似互斥锁,但没有所有权约束:可以在 A 线程 P、B 线程 V 当"信号传递"用——这是能力也是事故源(误用导致解别人锁)。需要互斥语义时用 mutex,需要资源计数时用 semaphore

读写锁

读多写少场景:允许多读者并行,写者独占。两个设计争议:① 写者饥饿(读者川流不息)→ 现实实现普遍让挂起写者挡住新读者;② 读者间 cache line 争抢使高并发读性能反而不佳 → 这引出了 RCU:读完全零锁、写者复制后改指针(内核热路径的终极答案,一句话了解即可)。

原语语义典型场景
mutex独占 + 所有权保护临界区
cond等谓词成立队列空/满等待
semaphore计数许可,无所有权连接池 N 个名额
rwlock读共享写独占读多写少的配置/缓存
barrier集合点:等全员到齐分阶段并行计算
生产者-消费者三件套:互斥锁(护队列)+ 两个条件变量/信号量(not_empty / not_full)+ 有界缓冲。这是把本页所有原语串起来的标准综合题(代码见 QA)。
面试金句:"互斥锁管资格,信号量管数量,条件变量管时序——三把尺子量三种并发问题,混用必出事故。"
所有权是信号量 vs 互斥锁的判别核心。RCU 只需一句话认知(读零开销、写复制替换)。五原语表为 QA 的综合题供弹药。

Producer-Consumer · Dining Philosophers

两个经典问题的"标准解法套路"

生产者-消费者(有界缓冲)

要素:互斥锁护缓冲;信号量 empty(初值 N,缓冲空位)与 full(初值 0,现有数据)。生产者 P(empty) → P(mutex) → 放 → V(mutex) → V(full);消费者对称。
陷阱:P 的顺序不能反——先 P(mutex) 再 P(empty),缓冲满时持着 mutex 睡,生产者全堵死(自己锁死自己)。

哲学家就餐

五人五叉,每人先拿左再拿右 → 可能循环等待(死锁雏形,下一篇主角)。三种标准解法:① 资源有序:奇数号先左、偶数号先右,打破循环等待条件;② 一次拿全:用信号量/互斥锁保证"两叉同时拿";③ 限制人数:最多 4 人同时上桌(破坏"占有并等待"的密度)。

套路总结

所有同步问题都是三步:① 找不变式(缓冲 ≤ N、叉子不共用);② 选原语(数量→信号量、资格→互斥、时序→条件变量);③ 检查获取顺序(谁先谁后决定会不会锁死自己)。

// Go 版生产者-消费者(channel 一行顶三件套)
ch := make(chan int, N)  // 有界缓冲

// 生产者
go func() {
    for i := 0; i < 100; i++ {
        ch <- i  // 满则阻塞 = P(empty)
    }
    close(ch)
}()

// 消费者
for v := range ch {
    _ = v       // 空则阻塞 = P(full)
}
// runtime 内部:hchan 的 lock(互斥)
// + sendq/recvq(等待队列) 全套齐活
对比的深意:Go channel 把"P(empty)/P(mutex)/V(full)"三步封装成一个 <- 操作——正确性由语言内建,这正是 CSP 论点(用通信解同步)的落地证据。细节见 channel 底层 deck
面试提示:被要求手写时,先写 C/pthread 版展示原理,再给 Go 版展示工程品味——两个版本对照着讲,比单写一个高一档。
P 顺序陷阱(先 mutex 后 empty → 自锁)是生产者消费者的灵魂追问。哲学家三解法对应死锁三破坏,为 deadlock deck 直接铺路。

Go sync × OS

Go 的锁在做什么:教科书原则的 runtime 落地

sync.Mutex 的两阶段(normal / starvation)

normal 模式:CAS 快路径 + 短暂自旋,失败入 FIFO 等待队列但允许新来的抢(吞吐优先);等待者被唤醒后还要和新来者赛跑。 starvation 模式:等待超过 1ms 触发,改为严格 FIFO 直接交接(公平优先),持锁者释放时直接把锁递给队头。1.9 引入,专治长尾延迟。

sync.RWMutex:写者优先

挂起的写者会阻塞后续读者,防写者饿死。代价:递归读锁(读锁内再拿读锁)遇到中间夹写者时可能死锁——文档明示的陷阱。读多写少极端场景用原子操作或分片(shard)替代。

与内核的接口

竞争激烈时走 futex(Linux)让 goroutine 挂起?不——Go 先在 runtime 层把 G 挂到 mutex 的等待队列(semaRoot 的 treap),必要时才动用 futex 睡 M:runtime 复刻了一遍 futex 的快慢两阶段,只是快路径是用户态的 G 队列。

// 两模式的行为差异(伪代码)
func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, 1) {
        return  // 快路径:一次 CAS 搞定
    }
    m.lockSlow() // 自旋 → 排队 → 竞争升级
}
// normal:  新来者可与被唤醒者抢锁(吞吐)
// starving: 释放时直接交接队头(公平)
// 触发:     等待 > 1ms 切入 starving
// 解除:     队列清空 或 拿到锁的等待者很少
思想对齐:normal/starvation 双模式 = "自旋/睡眠"权衡 + "吞吐/公平"权衡的合成体;与 OS 语境的"自适应自旋"、CFS→EEVDF 的"公平+延迟"演化是同一套哲学。
深挖链接:源码级逐行拆解(state 位布局、semaRoot treap、RWMutex 的 readerCount 负数技巧)见 sync 并发原语 deck
Go 岗核心页:mutex 双模式的触发条件(1ms/等待数)与设计动机(长尾延迟)。runtime 自建 G 队列 + 按需 futex 是"复刻 futex 分层"的漂亮观点。

Interview QA · Part 1

高频追问:原理与实现

1 · 竞态条件是什么?举一个最小的例子。

i++ 三步

结果取决于执行交错顺序的缺陷。最小例子:两线程并发 i++——load/add/store 三步交错后两次自增只加 1(丢失更新)。根源 = 共享 + 交错;解法 = 消除交错(原子)或串行化(锁)。

2 · CAS 是什么?ABA 问题怎么办?

cmpxchg版本号

CAS 原子地"等于期望才写入"。ABA:值从 A→B→A,CAS 误以为没变(指针场景可能拿到已释放的旧对象)。解法:带版本号/标签指针(CAS 双字),或用能检测 ABA 的原语(LL/SC)。x86 的 cmpxchg8b/16b 支持带标签比较。

3 · 自旋锁和互斥锁怎么选?

临界区时长

临界区接近或小于一次上下文切换成本(μs 级)且持锁者不睡眠 → 自旋;反之睡眠锁。现实是混合:先自适应自旋几轮再睡。内核中断上下文只能自旋(不能睡眠)是硬约束。

4 · futex 为什么快?快在哪?

两阶段

无竞争时锁的获取/释放只是用户态一次 CAS,零系统调用、零切换;只有真正竞争时才陷入内核睡眠/唤醒。把"乐观情形留在用户态"是它的设计哲学,pthread_mutex 与 Go mutex 都建立在其上。

5 · 条件变量为什么要配互斥锁?为什么 while?

唤醒丢失虚假唤醒

配锁:让"查谓词 + 睡眠"原子,否则检查与入睡之间对方 signal 就丢了唤醒。while:醒来后条件可能已变(他人抢先消费/虚假唤醒),必须重查谓词再继续。

6 · 信号量和互斥锁的区别?

所有权

互斥锁有所有权(谁加谁解、递归语义),信号量是计数许可(无所有权,V 可来自任意线程)。互斥锁表达临界区,信号量表达资源池;二值信号量当互斥锁用会引入"解别人的锁"类事故。

前六题为原理主线:竞态、CAS/ABA、自旋vs睡眠、futex、条件变量、信号量。ABA 是无锁方向的加分题。

Interview QA · Part 2

高频追问:工程与实战

7 · 手写生产者-消费者要注意什么?

P 顺序

三件套(mutex + empty + full)之外,灵魂是 P 的顺序:必须先 P(empty/full) 再 P(mutex)——反了会在缓冲满/空时持锁睡眠,锁死对方。Go 里一个带缓冲 channel 等价实现,正确性内建。

8 · Go sync.Mutex 为什么设计两种模式?

吞吐 vs 公平

normal 允许新来者与被唤醒者抢锁(缓存热、吞吐高),但持续竞争会饿死队头;等待超 1ms 切入 starvation 严格 FIFO 交接,压平长尾。等待队列空了自动回 normal。典型"两难权衡用状态机调和"。

9 · 读写锁什么时候反而更慢?

cache line 争抢

读者也要原子更新 readerCount——高并发读时所有读者抢同一个 cache line,性能可能不如普通锁甚至原子操作。读多写少且读者极多时:分片锁、副本更新(copy-on-read)、或 RCU 式发布。

10 · Go 的 RWMutex 有什么坑?

递归读锁死锁

写者优先:写者阻塞时新读者排队。于是"读锁内再拿读锁"遇上中间到达的写者——外层读锁等内层,内层读锁等写者,写者等外层释放 → 死锁。规避:读锁内绝不递归拿读锁;或换成一次性读快照。

11 · 有没有"不锁"的办法?

无共享 / 原子 / Copy-on-Write

按优先级:① 不共享(每 goroutine 私有数据,channel 传值);② 原子操作(计数器、标志位,atomic 包);③ Copy-on-Write(读用不可变快照,写时整体替换,如 COW map);④ 真无锁结构(CAS 循环,谨慎)。锁是最后手段,不是默认。

12 · 线上出现锁竞争热点,怎么排查?

pprof mutex

Go:runtime.SetMutexProfileFraction 开锁画像,pprof 看 contention 排名与持锁调用栈;CPU 上不去但延迟高 → 大概率锁串行化。手段:缩临界区(出锁再做 I/O)、分片、无共享化。内核侧 perf lock / 火焰图看 futex 站队。

后六题偏工程:P 顺序、Go 双模式动机、读写锁反直觉、RWMutex 递归死锁、"不锁"清单、pprof 排查。第 11 题的优先级清单体现工程品味。

Interview QA · Part 3

综合场景题:把原语串起来

13 · 单核机器上自旋锁有意义吗?

时间片视角

几乎没有:单核上持锁者与等待者轮流上 CPU,等待者自旋只会烧完时间片拖慢持锁者。唯一例外:抢占被关闭的内核临界区(关中断 + 自旋),这时"自旋"其实是在等中断上下文退出,时序可控。

14 · 哲学家就餐问题的三种解法分别破坏了什么?

对应死锁条件

资源有序(奇偶分组拿叉)破坏"循环等待";一次拿全(原子取两叉)破坏"逐步占有";限 4 人上桌破坏"占有并等待"的成立密度。与死锁预防一一对应——下一篇的伏笔。

15 · volatile 能替代锁吗?

不够

C 的 volatile 只禁止编译器优化掉读写,不提供原子性与内存序,多核下照样丢更新。Java volatile 有 happens-before 语义(可见性+有序性)但仍无原子性(i++ 依然不安全)。Go 没有 volatile,用 atomic 包或锁。三语言的差异本身就是高频考点。

16 · 一个 mutex 保护两个不相关变量,问题在哪?

锁粒度

无关变量共享一把锁 → 假性竞争(false contention):访问 A 的线程被访问 B 的线程拖住。拆锁提升并行度,但引入锁序问题(持 A 锁申请 B 锁必须全局有序,否则死锁——下一篇)。粒度 vs 正确性是永恒权衡。

17 · context switch 期间锁会怎样?

临界区拉长

持锁线程被切走,临界区被"拉长"到它回来为止,等待者空转或睡眠——这就是自旋锁单核陷阱的通用版。Go 的缓解:syscall 前 P 解绑(不让慢 syscall 拖住 P 上的其他 G);持锁 goroutine 被抢占则整个 P 的队列排队。

18 · 怎么实现一个无锁计数器?上限在哪?

atomic.Addcache line

atomic.AddInt64 一行搞定(底层 lock xadd)。上限:极高并发时所有核抢同一 cache line(伪共享),吞吐反而不如分片——如 runtime 的 per-P 计数再聚合(Go GC 的 stats 就是这么干的)。无锁 ≠ 无成本。

第三组 QA 是综合场景:单核自旋、哲学家对应死锁条件、volatile 三语言辨析、锁粒度、切换期间的锁、无锁计数上限。第 14 题是通往 deadlock deck 的桥。

Related & References

相关知识点与参考

OS 系列(本分类)

死锁 →(同步的另一面:锁出不来的世界)
进程间通信 →(跨进程的同步原语版)
CPU 调度 →(优先级反转与调度器的纠缠)

跨领域联动

sync 并发原语底层 →(本 deck 的 Go 源码版)
Channel 底层原理 →("通信即同步"的实现)
InnoDB 锁体系 →(同一套思想在数据库的化身)

参考来源(本 deck 结论可溯源至下列一手材料)

OSTEP ch.28–31(锁 / 条件变量 / 信号量 / 常见并发问题)TAS→TTAS→排队锁演化、条件变量规范、哲学家三解法
man 2 futex / man 7 pthreadsfutex 两阶段语义、等待队列与重校验规则
CSAPP ch.12(并发编程)基于信号量的生产者-消费者、读者-写者模型
Go src/sync/mutex.go(1.9+ 双模式)normal/starvation 状态机、1ms 阈值、队列交接
xiaolincoding.com《图解系统》multithread_sync / pessim_and_optimi_lock互斥/同步中文叙述、悲观乐观锁对照
MCS Lock 论文(Mellor-Crummey & Scott, 1991)排队自旋锁原型,Go runtime osq 的思想源头
收尾:OSTEP 并发四章是理论主源,Go mutex 源码是 Go 岗的差异弹药。总页数 13。下一篇:死锁。