幕 II · 机器CH 09进度 09/25

锁:互斥、读写、以及死锁的四个条件

锁是并发工具箱里最被鄙视,也最该用的那个。「加锁性能差」是个被过度传播的迷思 —— 现代的锁在没有竞争时几乎是免费的(一次 CAS,几纳秒,根本不进内核)。真正的成本从来不是「锁本身」,而是竞争:当十个线程抢同一把锁,九个要睡下去、再被唤醒,那才是账单。这一章讲清锁的物理实现、几种锁的取舍,以及那个所有人都听过、但很少有人能当场说全的东西:死锁的四个条件

futex自旋 vs 挂起读写锁死锁四条件活锁

一把锁,物理上是什么

一把互斥锁,最朴素的实现就是一个整数 + 一条原子指令

/* 最朴素的自旋锁 —— 这就是全部 */
void lock(int *m) {
    while (atomic_exchange(m, 1) == 1)   /* 原子地把 1 写进去,返回旧值 */
        ;                                /* 旧值是 1? 说明别人拿着 → 死等 */
}
void unlock(int *m) {
    atomic_store(m, 0);
}

/* 关键:atomic_exchange 是一条 CPU 指令(x86 的 XCHG,自带 LOCK 语义)。
   它在硬件层面保证「读+写」不可分割 —— 这是所有锁的物理地基。 */

但这个实现有个致命问题:while(...)烧 CPU如果锁被持有 10 毫秒,你就白转了 10 毫秒的圈。

futex:现代锁的秘密

Linux 的解法叫 futex(Fast Userspace muTEX),它的设计只有一个洞察:

◆ 定论 · futex 的核心洞察

绝大多数时候,锁是没有竞争的。

所以:

  • 没竞争时(快路径):一次原子 CAS 就拿到锁了。完全在用户态,不进内核,几纳秒。
  • 有竞争时(慢路径):才调 futex(FUTEX_WAIT) 陷入内核,把线程挂起(而不是自旋烧 CPU)。释放锁的人调 futex(FUTEX_WAKE) 叫醒它。

这就是为什么「加锁很慢」是个迷思。没竞争的 synchronized / sync.Mutex,开销是纳秒级的。

慢的从来不是锁,是竞争。而竞争慢,是因为它触发了线程挂起 + 唤醒(那是第 7 章算过的、微秒级的账)。

现实中的锁还会做自适应自旋:先自旋几十个循环(赌锁马上会被放开,省下一次挂起),转了半天还没拿到再挂起。Java 的 synchronized、Go 的 sync.Mutex 都是这么干的。

◈ 三语 · 锁的实现细节
实现值得知道的一个细节
Kotlin/JVM synchronized(对象内建 monitor)
ReentrantLock(AQS)
协程里:Mutex
协程里绝对不要用 synchronized 包住挂起调用 —— 锁是线程持有的,挂起后线程都换了,解锁的会是另一个线程。
(好消息:编译器会拦住你。)协程里要互斥,用 kotlinx.coroutines.sync.Mutex —— 它是挂起式的。
Go sync.Mutex
sync.RWMutex
sync.Mutex 不可重入!同一个 goroutine 连续 Lock() 两次 = 死锁
Go 团队是故意这么设计的:Russ Cox 说过,重入锁会让「我持有的锁保护了什么」这个问题变得无法回答。
另外:Go 1.9 起 Mutex 有饥饿模式 —— 一个 waiter 等超过 1ms,锁会切到 FIFO 模式,防止新来的 goroutine 一直插队。
Python threading.Lock(不可重入)
threading.RLock(可重入)
协程里:asyncio.Lock
不要在 async def 里用 threading.Lock —— 它是阻塞式的,会把整个事件循环卡住(第 13 章)。
要用 asyncio.Lockasync with lock:),它是挂起式的。

规律:每种并发模型都有自己的一套锁。阻塞式的锁配线程,挂起式的锁配协程。混用 = 事故。

读写锁:一个经常帮倒忙的优化

直觉很美好:读操作互不冲突,所以让读者并发,只有写者需要独占。

var mu sync.RWMutex

// 多个读者可以同时进来
mu.RLock()
v := cache[key]
mu.RUnlock()

// 写者独占
mu.Lock()
cache[key] = val
mu.Unlock()

但读写锁比互斥锁复杂得多,而且经常更慢。三个原因:

  • 它自己的开销更大。要维护读者计数,而那个计数本身就是个被所有读者争抢的原子变量(回忆第 6 章的伪共享!)。读多写少时,所有读者在疯狂 CAS 同一个缓存行 —— 你「优化」出来的并发,被缓存一致性开销吃掉了
  • 写者饥饿。如果读者源源不断,写者可能永远等不到。(Go 的 RWMutex 处理了这个:一旦有写者在等,新的读者就要排队,不许插队。)
  • 临界区很短时,纯亏。如果读操作只是读一个 map,耗时几十纳秒 —— 读写锁的额外开销比临界区本身还长
◆ 定论 · 什么时候才该用读写锁

同时满足这三条:

  1. 读远多于写(比如 100:1 以上)
  2. 读操作的临界区足够长(微秒级以上,不是几十纳秒)
  3. 你测过了,它确实更快

否则:用普通的互斥锁。它更简单、更快、更不容易出错。

而如果你的场景是「读多写少的缓存」—— 通常有更好的答案:

  • Go:sync.Map(专门为「写一次读多次」优化)或者干脆用 atomic.Pointer写时复制(COW)
  • Java:ConcurrentHashMap(分段/CAS,读完全无锁
  • 不可变 + 整体替换:写的时候造一个新 map,然后原子地换指针。读者完全不需要任何同步。这是最优雅的方案,代价是写变贵了。

死锁:两把锁,一个环

现在到了这一章的正题。看这个经典的转账场景:

// 线程 1: 从 A 转账到 B
fun transfer(from: Account, to: Account, amount: Int) {
    synchronized(from) {          // 先锁 from
        synchronized(to) {        // 再锁 to
            from.balance -= amount
            to.balance += amount
        }
    }
}

// 现在:
//   线程 1 调 transfer(A, B, 100)   → 先锁 A,再要 B
//   线程 2 调 transfer(B, A, 50)    → 先锁 B,再要 A
//
// 如果它们同时开始 —— 双双卡死,永远。

下面这个 demo 会一步步画出等待图,并检测里面有没有环。先看「顺序不一致」的那一栏,按到底。

死锁的四个条件(Coffman 条件)

死锁的成立,需要四个条件同时满足。这不是记忆题 —— 它是一份「有四把钥匙可以开的锁」,你只需要打破任何一个

条件意思怎么打破它实用性
① 互斥 资源同一时刻只能被一个线程持有 不用锁。用不可变数据、用消息传递(channel)、用无锁结构。 ★★★
「不共享」是最彻底的解法。这正是 Go 和 Erlang 的哲学。
② 持有并等待 抱着一把锁,去要另一把 一次性申请所有锁(要么全拿到,要么一把也不拿)。
或者:tryLock(timeout) —— 拿不到就把已有的全放开,退避一下重来。
★★
可行,但要小心活锁(见下)。
③ 不可抢占 不能把别人手里的锁硬抢过来 基本没法打破(抢锁会破坏临界区的语义)。
数据库里可以:死锁检测 → 选一个「受害者」事务回滚。

应用层通常做不到。
④ 循环等待 T1 等 T2,T2 等 T1(等待图里有环) 给所有锁定一个全局顺序,永远按序申请。 ★★★★★
这是工程上最好使的一条。
◆ 定论 · 一条规则解决 90% 的死锁

给所有的锁定一个全局顺序,然后永远按这个顺序申请。

// ✅ 修好的转账:按账户 ID 排序,永远先锁小的
fun transfer(from: Account, to: Account, amount: Int) {
    val (first, second) = if (from.id < to.id) from to to else to to from

    synchronized(first) {
        synchronized(second) {
            from.balance -= amount
            to.balance += amount
        }
    }
}
// 现在两个线程都是「先锁 A 再锁 B」——
// 等待图里永远画不出环。死锁在结构上不可能发生。

顺序用什么?随便,只要全局一致:对象 ID、内存地址(System.identityHashCode)、类名、一个手工维护的「锁层级」表。

大型项目的做法:把锁分层。规定「层级低的锁不能去申请层级高的锁」,然后用工具在运行时校验(Linux 内核的 lockdep、Go 的 go-deadlock 库就是干这个的)。

活锁:比死锁更气人

如果你选了「打破条件 ②」(拿不到就全放开重来),要小心这个:

for {
    if !mu1.TryLock() { continue }
    if !mu2.TryLock() {
        mu1.Unlock()          // 拿不到第二把,把第一把也放开
        continue              // 重来
    }
    break
}
// 两个 goroutine 完美同步地: 拿 → 失败 → 放开 → 重来 → 拿 → 失败 …
// 它们都在「努力工作」,CPU 100%,但永远推进不了。
//
// 这就是活锁 —— 两个人在走廊里迎面相遇,同时往左让,同时往右让,
// 同时又往左让……

解药:随机退避。失败后 sleep(random) 再重试 —— 用随机性打破那个完美的同步。(这也是以太网 CSMA/CD 的做法,也是所有重试策略里「指数退避 + 抖动」的由来。)

怎么发现死锁

怎么发现
Go 好消息:Go runtime 有全局死锁检测。如果所有 goroutine 都睡着了,它会直接崩:
fatal error: all goroutines are asleep - deadlock!
坏消息:它只在「全部」睡着时才报。如果你的程序还有一个 goroutine 在跑(比如 HTTP server 的 accept 循环)—— 它就永远不会报,那两个卡死的 goroutine 会静静地泄漏
真实项目里的做法:go tool pprof 看 goroutine 栈(第 18 章),或者用 sasha-s/go-deadlock 这种带检测的 Mutex 替换。
JVM jstack <pid> 会直接告诉你Found one Java-level deadlock: 并打印出环。
JMX / ThreadMXBean.findDeadlockedThreads() 可以在运行时程序化检测。
这是 JVM 生态最好用的地方之一。
Python 没有内建检测。faulthandler.dump_traceback_later() 可以在卡死时打印所有线程栈。
asyncio 里:asyncio.all_tasks() + task.get_stack()
⚠ 陷阱 · 最阴险的死锁:跨层的锁

教科书里的死锁都是「两把明显的锁」。真实的死锁通常长这样:

  • 锁 + 回调。你持有锁,调用了一个用户注册的回调,那个回调又去拿了另一把锁。你根本不知道它会拿锁。
    规则:永远不要在持有锁的时候调用外部代码(回调、监听器、虚方法)。
  • 锁 + 数据库事务。你的代码持有一把内存锁,然后开了个数据库事务;数据库那边的行锁也在等你。两个死锁检测器互相看不见对方。
  • 锁 + 线程池。持有锁的任务在等池里的另一个任务,而池满了(第 23 章)。
  • 锁 + 日志。是的,真的。日志框架内部有锁,如果你的 toString() 里又去拿了业务锁……

一条能救你很多次的规则:临界区里只做纯粹的内存操作。不做 IO,不调外部代码,不发网络请求,不打日志。

✚ 动手

1. 写一个死锁,然后用工具抓它。Java 里跑 jstack,Go 里让它全睡着看 fatal error亲手抓一次,以后看到症状就认得。

2. 全局搜你项目里的嵌套 synchronized / Lock()每一处都问:这两把锁的申请顺序,在全项目里一致吗?

3. 搜「在持有锁的时候调用了外部方法」的地方。这是死锁的头号温床,而且 code review 时最容易漏掉。

下一章:如果锁这么麻烦,能不能不用锁?能。CAS、原子类、无锁队列 —— 以及一个不讨喜的忠告:你大概率不该自己写它们。