引言:锁不是万能药
很多人刚接触并发编程时,都会觉得锁(Mutex)是解决线程安全问题的万能工具。两个线程同时修改一个变量?加把锁。多个函数访问同一个数据结构?加把锁。仿佛只要看到多线程,第一反应就是"上锁"。
然而,当你真正阅读 Chromium、Linux Kernel、Rust 等大型项目源码时,会发现一个有趣的现象:它们都在尽可能减少锁的使用,转而采用 Sequence、Message Passing、Actor Model、Task 等设计。
为什么会这样?
常见的解释是:锁容易死锁,锁的性能差,锁容易写错。这些答案都对,但都没有触及问题的本质。
锁真正的问题在于:它破坏了模块化(Composability)。
这篇文章会从一个简单的转账例子出发,逐步揭示锁的这个根本缺陷,然后介绍 Chromium 是如何通过"避免共享"来从根本上解决这个问题的。
一、锁诞生的理由:解决数据竞争
在批判锁之前,我们首先要承认:锁解决了真实的问题。
假设我们有一个银行账户,初始余额为 100。两个线程同时操作:
cpp
// 线程 A balance += 100; // 线程 B balance -= 50;
我们期望的结果是 100 + 100 - 50 = 150。但在没有同步机制的情况下,CPU 的实际执行顺序可能是这样的:
text
初始余额:100
线程 A (balance += 100) 线程 B (balance -= 50)
─────────────────────────────────────────────────────────
读取 balance → 100
读取 balance → 100
计算 100 + 100 = 200
计算 100 - 50 = 50
写回 balance = 200
写回 balance = 50 ← 覆盖了 A 的结果
最终余额:50(期望 150)
线程 B 的写入覆盖了线程 A 的结果,这就是经典的数据竞争(Race Condition)。
于是 Mutex 出现了:
cpp
std::lock_guard<std::mutex> lock(mutex_); balance += 100;
任何时刻只有一个线程能进入临界区,数据竞争被消除了。
所以,锁不是坏东西。它在保护简单共享数据时依然是最直接有效的工具。
二、锁的六宗罪
Simon Peyton Jones 在《代码之美》中总结了锁的六个典型问题。在进入核心论点之前,我们先快速回顾一下:
① 锁少了——竞态条件
忘记加锁,或者某些代码路径没有正确加锁。这是最经典的错误,也是最容易出现的。
② 锁多了——性能问题
锁的粒度过大,大量线程排队等待,导致 UI 卡顿、吞吐量下降。Chrome 中有一句铁律:
Never block the UI thread.
UI 线程绝对不能等锁。
③ 锁不匹配——保护关系失控
同一个数据被不同的锁保护,锁和数据之间没有任何强制绑定关系:
cpp
struct Account {
int money;
};
// 程序员 A 认为 user_lock 保护 money
// 程序员 B 认为 account_lock 保护 money
// 线程 1
lock(user_lock);
account.money += 100;
unlock(user_lock);
// 线程 2
lock(account_lock);
account.money -= 50;
unlock(account_lock);
两把不同的锁保护同一份数据,Race 依然存在。这种"保护关系"只存在于程序员的脑子里,编译器不知道,IDE 不知道,静态分析工具也很难发现。
④ 锁顺序——死锁
cpp
// 线程 A lock(A); lock(B); // 线程 B lock(B); lock(A);
两个线程互相等待对方释放锁,经典死锁。大型项目一般会强制规定锁的获取顺序(如 A → B → C),但这种规则只能靠团队纪律维护,没有任何机制能自动保证。
⑤ 条件变量——容易出错
忘记 notify、虚假唤醒、wait 的位置不对……条件变量相关的 Bug 往往难以调试和复现。
⑥ 最根本的问题:锁不能模块化
前面五个问题是"症状",第六个才是"病根"。
这也是本文要重点展开的核心论点。
三、为什么说锁不能模块化?
让我们通过一个仓库搬货的例子来理解。
3.1 两个正确的函数
假设我们有两个仓库 A 和 B,各自有一把锁保护:
cpp
// 从 A 仓库取出 10 箱货
void TakeOut(Inventory& A, int count) {
lock(A.mutex);
A.quantity -= count;
unlock(A.mutex);
}
// 向 B 仓库放入 10 箱货
void PutIn(Inventory& B, int count) {
lock(B.mutex);
B.quantity += count;
unlock(B.mutex);
}
单独来看,这两个函数都是正确的。任何时候调用 TakeOut(A, 10) 或 PutIn(B, 10) 都不会出现问题。
3.2 看似自然的组合
现在老板提了一个新需求:把 10 箱货从 A 仓库搬到 B 仓库。
最自然的想法是什么?
cpp
void Transfer(Inventory& A, Inventory& B, int count) {
TakeOut(A, count);
PutIn(B, count);
}
是不是非常自然?这就是函数组合(Composition)的魅力——用简单函数构建复杂功能。
3.3 完全错误
但这段代码是错误的。让我们看看执行过程:
text
初始状态:
A = 100
B = 50
总库存 = 150
Transfer 开始执行:
第一步:TakeOut(A, 10) 执行完毕
lock(A)
A = 90
unlock(A) ← 锁已经释放!
在这一瞬间,另一个线程开始盘点库存:
Inventory() {
统计 A = 90
统计 B = 50
返回 140 ← 错误!
}
第二步:PutIn(B, 10)
lock(B)
B = 60
unlock(B)
最终状态:
A = 90
B = 60
总库存 = 150(恢复正常)
问题出在哪里?
在 TakeOut 和 PutIn 之间,存在一个"半完成"的中间状态。 另一个线程在这个窗口期看到了一个不可能的状态——10 箱货已经从 A 取出,但还没放入 B,它们仿佛凭空消失了。
3.4 真正的问题
很多人第一反应是:"那我加一把大锁不就行了?"
但真正的问题不在于锁多锁少,而在于:
TakeOut自己决定了什么时候加锁、什么时候解锁,调用者完全无法控制。
当 TakeOut 返回时,锁已经被释放。Transfer 没有办法说:"先别释放,我后面还有一步操作。"
这就是锁的第一个特性:锁的获取和释放由函数内部控制,外部无法干预。
这意味着 TakeOut() 和 PutIn() 这两个正确函数不能被直接组合。要实现 Transfer,你只能:
cpp
void Transfer(Inventory& A, Inventory& B, int count) {
// 必须重写,在外部统一管理锁
lock(A.mutex);
lock(B.mutex);
A.quantity -= count;
B.quantity += count;
unlock(B.mutex);
unlock(A.mutex);
}
或者把原来的函数拆成不带锁的内部版本,然后由调用方统一控制锁:
cpp
void TakeOutInternal(Inventory& A, int count); // 不加锁
void PutInInternal(Inventory& B, int count); // 不加锁
void Transfer(Inventory& A, Inventory& B, int count) {
lock(A.mutex);
lock(B.mutex);
TakeOutInternal(A, count);
PutInInternal(B, count);
unlock(B.mutex);
unlock(A.mutex);
}
不管哪种方案,你都必须重新设计。原来的 TakeOut 和 PutIn 模块没有被复用,你反而要去修改它们、拆分它们、或者暴露它们的内部锁。
四、什么是真正的模块化?
要理解锁为什么失败,我们需要先理解什么是成功的模块化。
4.1 可组合的函数
下面这些函数有一个共同特点:
cpp
sort(array);
int len = strlen(str);
printf("Hello");
它们可以自由组合:
cpp
sort(array);
printf("Sorted: %s\n", array);
int len = strlen(array);
无论怎么组合,结果都是正确的。你不需要知道 sort 内部做了什么,也不需要修改 printf 的实现。
这就是 Composability(可组合性)——模块化的核心属性。
4.2 锁函数为什么不可组合?
回到前面的例子:
cpp
TakeOut(A, 10); // 内部管理锁 PutIn(B, 10); // 内部管理锁
这两个函数单独看都是"正确"的,但组合之后:
-
TakeOut在返回前释放了锁 -
中间状态暴露给外界
-
PutIn此时还没来得及执行
锁的释放时机被封装在函数内部,而这个时机恰好破坏了整体操作的一致性。
就像乐高积木:
-
普通函数是标准积木,可以自由拼接
-
带锁的函数像是每块积木都自带一个弹簧机关,单独玩没问题,但两块拼在一起时,弹簧会互相干扰,你必须拆开积木重新调整
4.3 Simon Peyton Jones 的洞见
Simon 指出:锁把并发控制和业务逻辑耦合在了一起。
一个函数如果内部自己管理锁,它就同时承担了两个职责:
-
业务逻辑(拿货、放货)
-
并发控制(何时加锁、何时解锁)
这两者耦合在一起,导致函数无法被自由组合。这才是锁最大的失败。
五、Chromium 的答案:从"保护共享"到"避免共享"
理解了锁的根本问题之后,再来看 Chromium 的设计选择,一切就豁然开朗了。
5.1 核心思想:与其保护共享,不如避免共享
Chromium 的并发模型建立在一个核心原则之上:
与其让很多线程共享一个对象,用锁来保护;
不如让一个对象只属于一个执行序列,通过消息传递来通信。
具体做法是:
-
把对象绑定到特定的线程或 Sequence(执行序列)
-
其他线程想操作这个对象?发消息,而不是直接访问
-
对象的实际修改永远发生在它所属的线程/Sequence 上
这就是 Message Passing(消息传递) 和 Thread Affinity(线程归属) 的核心理念。
5.2 一个具体的例子
假设 UI 线程上有一个标签页对象 TabStrip,IO 线程需要修改它的标题:
传统加锁方案:
cpp
class TabStrip {
std::mutex mutex_;
std::string title_;
public:
void SetTitle(const std::string& title) {
std::lock_guard<std::mutex> lock(mutex_);
title_ = title;
}
};
// IO 线程直接调用
tab_strip->SetTitle("New Title"); // 阻塞等待锁
这种方式的问题是:
-
IO 线程可能阻塞等待 UI 线程释放锁
-
UI 线程可能阻塞等待 IO 线程释放锁(死锁风险)
-
SetTitle的实现细节(加锁)暴露给调用者
Chromium 的消息传递方案:
cpp
// IO 线程想修改 TabStrip 的标题
// 不直接访问,而是投递一个任务到 UI 线程
content::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE,
base::BindOnce(&TabStrip::SetTitle,
base::Unretained(tab_strip),
"New Title"));
关键区别:
-
IO 线程不直接访问
TabStrip的数据 -
SetTitle保证在 UI 线程上执行 -
没有锁,没有阻塞,没有死锁风险
-
TabStrip内部不需要加锁,因为它只在 UI 线程上被访问
5.3 Sequence:单线程的幻觉
在 Chromium 中,Sequence 是一个重要的抽象概念。
Sequence 是一个虚拟的执行序列。投递到同一个 Sequence 的所有任务,会严格按顺序执行,永远不会并发。
可以把 Sequence 理解为"单线程的幻觉":
-
同一个 Sequence 的任务按顺序执行
-
不同 Sequence 之间可以并行
-
一个线程可以管理多个 Sequence
这个概念的关键价值在于:如果你把对象绑定到一个 Sequence,那么你就不需要锁。 因为所有对该对象的访问都是序列化的。
5.4 Sequence 天然支持模块化
回到我们的仓库例子。如果使用 Sequence:
cpp
// 把仓库操作绑定到同一个 Sequence
task_runner->PostTask(FROM_HERE, base::BindOnce([]{
TakeOut(A, 10); // 不带锁的版本
PutIn(B, 10); // 不带锁的版本
}));
由于整个 Lambda 在同一个 Sequence 上执行,执行期间不会有其他任务插入。外界只能看到:
-
执行前:A=100, B=50
-
执行后:A=90, B=60
中间状态完全不可见。
更重要的是:TakeOut 和 PutIn 终于可以自由组合了!不需要暴露内部锁,不需要重写函数,不需要拆分逻辑。这就是模块化。
六、并不意味着锁被完全抛弃
需要澄清的是:Chromium 并没有完全消灭锁。
在底层基础设施中,锁依然不可或缺:
-
内存分配器
-
任务调度器
-
引用计数(
base::RefCountedThreadSafe) -
缓存系统
这些底层组件需要处理真正的并发访问,锁仍然是最合适的工具。
Chromium 的设计哲学是分层对待:
| 层级 | 并发策略 | 使用场景 |
|---|---|---|
| 业务逻辑层 | Sequence + Message Passing | UI 组件、网络请求处理、扩展管理 |
| 基础设施层 | Lock + Atomic | 内存管理、调度器、引用计数 |
业务逻辑层尽量消除对锁的需求,让开发者不需要手动处理并发;基础设施层在必要时使用锁,由少数专家精心维护。
这种分层思想,才是 Chromium 并发模型的核心智慧。
七、总结
很多人认为 Chromium、Linux Kernel、Rust 等现代项目减少锁的使用,是因为锁会导致死锁、性能差或者容易写错。这些问题确实存在,但都属于"症状"。
Simon Peyton Jones 指出的真正核心在于:锁把并发控制和业务逻辑耦合在了一起。
一个函数如果内部自己管理锁,它可能单独是正确的;但一旦需要与其他函数组合,就会暴露出中间状态,调用者不得不重新设计锁的粒度、作用范围和顺序,最终导致模块失去可组合性。
Chromium 的设计——SequenceChecker、TaskRunner、PostTask、Thread Affinity——本质上都是在回答同一个问题:
与其让很多线程共享一个对象,用锁来保护;
不如让一个对象只属于一个执行序列;
与其用锁保护共享,不如尽量避免共享。
这也是现代并发设计越来越推崇 Message Passing(消息传递)、Actor Model(Actor 模型) 和 Sequence(执行序列) 的原因。它们并不是完全取代锁,而是通过减少共享状态,让业务逻辑能够像普通函数一样自然地组合,从而降低复杂度,提高大型工程的可维护性。
附:深入阅读 Chromium 源码的建议
如果你对 Chromium 的并发模型感兴趣,以下几个文件是很好的起点:
| 文件 | 内容 |
|---|---|
base/sequence_checker.h | SequenceChecker:在 DEBUG 模式下检查当前代码是否运行在正确的 Sequence 上 |
base/task_runner.h | TaskRunner 接口:PostTask 的入口 |
base/lock.h | Lock 和 AutoLock 的实现,观察底层锁的使用方式 |
content/public/browser/browser_thread.h | BrowserThread::UI / IO 等线程定义 |
base/threading/thread_local.h | 线程局部存储,理解 Thread Affinity 的底层支持 |
理解这些组件如何协同工作,你会对"避免共享优于保护共享"有更深的体会。
355



被折叠的 条评论
为什么被折叠?



