type
Post
status
Published
date
Oct 12, 2025
slug
os4
summary
tags
技术探索
category
icon
password
真正的工程级思维,是不看重“锁的定义”的,而是看重“锁的妥协”。今天,我们就从应用场景出发,重新审视锁的生态,并探讨如何在代码中优雅地打破死锁的魔咒。
一、 锁的哲学:悲观与乐观的宏观博弈
在下钻到操作系统级别的锁之前,我们必须先建立业务层面“悲观”与“乐观”的认知框架。这不是两种具体的锁,而是两种并发控制的哲学。
- 悲观锁 (Pessimistic Lock)
- 核心思维: “总有刁民想害朕”。假设每次读取数据时,别人都会去修改,所以不管三七二十一,先上锁再说。
- 适用场景: 写多读少的重冲突场景。例如电商秒杀扣减库存、银行转账。在关系型数据库中,
SELECT ... FOR UPDATE就是典型的悲观锁。
- 乐观锁 (Optimistic Lock)
- 核心思维: “世界是和平的”。假设没有冲突,大家随便读,只有在最后提交更新时,才去检查有没有人在这期间动过数据(通常通过版本号 Version 或 CAS 机制)。
- 适用场景: 读多写少的轻冲突场景。比如用户个人资料的修改。它的优势在于省去了加锁、释放锁的昂贵开销,极大提升了系统的吞吐量。
二、 极限拉扯:互斥锁 vs 自旋锁
当我们需要在代码中保护一段临界区(Critical Section)时,互斥锁和自旋锁是最基础的两种选择。它们的核心区别在于:当锁被别人占用时,当前线程该怎么办?
锁类型 | 线程拿不到锁时的行为 | 底层开销 | 核心适用场景(面试必答点) |
互斥锁 (Mutex) | “佛系让出”:挂起当前线程,放弃 CPU 调度,进入睡眠,等待被唤醒。 | 较高。涉及两次上下文切换(挂起 + 唤醒)。 | 临界区代码执行时间长(例如涉及磁盘 I/O、复杂计算、网络请求)。让出 CPU 是划算的。 |
自旋锁 (Spinlock) | “死缠烂打”:不释放 CPU,在一个 while 循环里疯狂探测锁是否被释放。 | 较低。不涉及上下文切换,但会白白消耗 CPU 周期。 | 临界区代码执行时间极短(例如只是给某个内存变量加 1)。且系统必须是多核 CPU。 |
工程实践的升华:“纯粹的互斥锁和自旋锁在极端场景下都有性能瓶颈。在实际的高并发 RPC 框架(比如我之前深入过的dubbo-go)以及 Go 语言的标准库中,sync.Mutex其实是一种混合锁。当 Goroutine 尝试获取锁失败时,它不会立刻挂起,而是会先短暂地自旋几次,看看能不能碰运气立刻拿到锁;如果自旋了几次还是拿不到,为了避免过度浪费 CPU,它才会退化成互斥锁,将自身挂起。这种自适应的策略才是工业界的真实做法。”
三、 读写锁 (Read-Write Lock):场景细分的极致
如果系统中有 90% 的操作是读,10% 是写,用普通的互斥锁将所有读操作也串行化,是对性能的巨大屠杀。
读写锁的规则极其精简:
- 读读不互斥: 多个人可以同时读。
- 读写互斥、写写互斥: 只要有人在写,其他人既不能读也不能写;有人在读,不能写。
适用场景: 本地缓存的加载与访问、系统配置项的热更新。这种场景下,配置一天只改一次(写),但每秒被读取一万次(读),读写锁能带来几何倍数的性能飞跃。
四、 驯服死锁:打破魔咒的四个维度
多把锁交织在一起,就容易召唤出高并发系统最可怕的幽灵:死锁 (Deadlock)。面试官往往会让你默写死锁的四个必要条件,但更重要的是“如何打破”。
产生死锁必须同时满足以下四个条件,我们只要在代码中打破其中任意一个,死锁就不攻自破。
1. 互斥条件 (Mutual Exclusion)
- 含义: 资源只能被一个线程占用。
- 如何打破: 极难打破。因为锁的意义就是互斥。部分场景可以退化为无锁并发(如
ThreadLocal或Atomic原子操作)。
2. 占有且等待 (Hold and Wait)
- 含义: 线程手里已经攥着一把锁 A,还在眼巴巴地等待锁 B,并且死活不放锁 A。
- 如何打破: “一次性分配”。在线程启动时,强制要求它一次性申请完所有需要的资源。但这在工程上极度缺乏灵活性。另一种方法是,如果申请锁 B 失败,就主动释放手里的锁 A,过一会儿再重新尝试。
3. 不可抢占 (No Preemption)
- 含义: 别人手里的锁,你不能强行抢过来,只能等他主动释放。
- 如何打破: 引入超时机制。不要无限期地等待。在很多语言的并发库中都有带超时的锁 API。如果在规定时间内拿不到下一把锁,就主动抛出异常,并释放已占有的资源。
4. 循环等待 (Circular Wait) —— 工程中最常用的破局点
- 含义: 线程 1 等待线程 2 的锁,线程 2 等待线程 3 的锁... 最后线程 N 等待线程 1 的锁,形成了一个首尾相连的死循环。
- 如何打破: 资源排序加锁 (Lock Ordering)。这是在实际代码中最常用来避免死锁的规范。
我们可以用图解来直观感受“循环等待”的灾难与“资源排序”的优雅:
实战话术建议:
“在日常开发中,我通常会通过强制的锁排序规范来避免死锁。比如在一个复杂的转账业务中,如果要同时锁住账户 A 和账户 B 的记录,我绝对不会按照用户输入的顺序去加锁。我会比较两个账户的 ID,强制规定系统总是先尝试获取 ID 较小的账户锁,再去获取 ID 较大的账户锁。这样从根本上破坏了循环等待的条件,以最低的编码成本解决了死锁隐患。”
结语
高并发编程没有银弹,所有的锁机制本质上都是在“吞吐量”与“数据一致性”之间做交易。当我们清楚了自旋锁对 CPU 的压榨、互斥锁上下文切换的昂贵,以及破坏死锁四要素的工程手段后,那些曾经抽象的八股文,就会变成你手中游刃有余的架构工具。
分享
