锁:互斥、读写、以及死锁的四个条件
锁是并发工具箱里最被鄙视,也最该用的那个。「加锁性能差」是个被过度传播的迷思 —— 现代的锁在没有竞争时几乎是免费的(一次 CAS,几纳秒,根本不进内核)。真正的成本从来不是「锁本身」,而是竞争:当十个线程抢同一把锁,九个要睡下去、再被唤醒,那才是账单。这一章讲清锁的物理实现、几种锁的取舍,以及那个所有人都听过、但很少有人能当场说全的东西:死锁的四个条件。
一把锁,物理上是什么
一把互斥锁,最朴素的实现就是一个整数 + 一条原子指令:
/* 最朴素的自旋锁 —— 这就是全部 */
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),它的设计只有一个洞察:
绝大多数时候,锁是没有竞争的。
所以:
- 没竞争时(快路径):一次原子 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.Mutexsync.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.Lock(async 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,耗时几十纳秒 —— 读写锁的额外开销比临界区本身还长。
同时满足这三条:
- 读远多于写(比如 100:1 以上)
- 读操作的临界区足够长(微秒级以上,不是几十纳秒)
- 你测过了,它确实更快
否则:用普通的互斥锁。它更简单、更快、更不容易出错。
而如果你的场景是「读多写少的缓存」—— 通常有更好的答案:
- 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(等待图里有环) | 给所有锁定一个全局顺序,永远按序申请。 | ★★★★★ 这是工程上最好使的一条。 |
给所有的锁定一个全局顺序,然后永远按这个顺序申请。
// ✅ 修好的转账:按账户 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、原子类、无锁队列 —— 以及一个不讨喜的忠告:你大概率不该自己写它们。