4. 互斥锁(Mutex):基本原理、在构建工具中的应用(如Bazel的锁机制)
说到并发编程,互斥锁绝对是最基础、最常用的同步原语。说白了,它就是一把「钥匙」——谁拿到钥匙,谁就能进房间干活,其他人得在门外等着。
我刚开始接触多线程时,总觉得锁这东西很简单。直到有一次线上事故,我才真正体会到:锁用不好,系统分分钟给你颜色看。
4.1 互斥锁的核心原理
互斥锁(Mutex,全称 Mutual Exclusion)的核心目标就一个:保证同一时刻只有一个线程访问共享资源。
它的工作流程其实很直观:
- 线程A尝试获取锁
- 如果锁空闲,线程A拿到锁,进入临界区
- 如果锁已被占用,线程A阻塞等待
- 线程B释放锁后,线程A被唤醒,拿到锁
- 线程A干完活,释放锁
嗯,听起来很简单对吧?但实际用起来,坑可不少。
关键概念:临界区
临界区就是需要互斥访问的代码段。锁的作用就是保护临界区,防止多个线程同时执行它。
4.2 锁的实现方式
我见过不少团队在锁的实现上踩过坑。这里我总结一下常见的实现方式:
| 实现方式 | 原理 | 适用场景 | 我踩过的坑 |
|---|---|---|---|
| 自旋锁 | 忙等待,不断检查锁状态 | 锁持有时间极短 | 曾经在长临界区用自旋锁,CPU直接飙到100% |
| 互斥锁(睡眠锁) | 获取不到锁时线程休眠 | 锁持有时间较长 | 上下文切换开销大,短临界区不划算 |
| 读写锁 | 读读不互斥,读写互斥 | 读多写少场景 | 写线程饥饿问题,后来加了写优先策略 |
| 递归锁 | 同一线程可重复获取 | 递归函数或嵌套调用 | 容易掩盖设计问题,我一般不建议用 |
我的建议:优先用互斥锁(睡眠锁)。自旋锁只在临界区极短(比如几十条指令)时考虑。读写锁要小心写线程饥饿。
4.3 构建工具中的锁机制——以Bazel为例
你可能会问:构建工具跟锁有什么关系?
关系大了去了。现代构建工具都是并行执行的,Bazel、Buck、Pants这些工具,哪个不是同时跑几十上百个任务?没有锁,构建结果分分钟乱掉。
我参与过Bazel的二次开发,对它的锁机制还算熟悉。Bazel的锁设计有几个关键点:
4.3.1 输出文件锁
Bazel最核心的锁就是输出文件锁。多个构建任务可能同时写同一个输出文件,这肯定不行。
// Bazel输出文件锁的简化实现
class OutputFileLock {
private final Mutex mutex = new Mutex();
private final Set<Path> lockedFiles = new HashSet<>();
public void acquire(Path file) throws InterruptedException {
mutex.lock();
try {
while (lockedFiles.contains(file)) {
// 等待文件被释放
file.condition.await();
}
lockedFiles.add(file);
} finally {
mutex.unlock();
}
}
public void release(Path file) {
mutex.lock();
try {
lockedFiles.remove(file);
file.condition.signalAll();
} finally {
mutex.unlock();
}
}
}
这段代码看着简单,但实际Bazel的实现要复杂得多。它还要处理死锁检测、超时重试、优先级反转等问题。
4.3.2 仓库缓存锁
Bazel会缓存远程仓库的依赖。多个构建实例同时下载同一个依赖怎么办?
Bazel的做法是:文件锁 + 状态文件。
- 先尝试获取文件锁
- 获取成功,检查状态文件
- 如果正在下载中,等待
- 如果下载完成,直接使用
- 如果未开始,开始下载
注意:文件锁在NFS等分布式文件系统上可能失效。Bazel在分布式场景下用的是分布式锁,基于Chubby或etcd实现。
4.3.3 执行资源锁
Bazel会限制并行执行的任务数。这其实也是一种锁——资源锁。
// 资源锁的简化实现
class ResourceLock {
private final Semaphore semaphore;
public ResourceLock(int maxConcurrency) {
this.semaphore = new Semaphore(maxConcurrency);
}
public void acquire() throws InterruptedException {
semaphore.acquire();
}
public void release() {
semaphore.release();
}
}
这里用信号量(Semaphore)而不是互斥锁,因为允许多个任务同时执行,只是数量有限制。
4.4 锁的常见问题与避坑指南
做了这么多年并发编程,我总结了几条血的教训:
4.4.1 死锁
死锁是锁最经典的问题。两个线程互相等待对方释放锁,谁也动不了。
我曾经在构建系统中遇到过死锁:任务A持有文件锁1,等待文件锁2;任务B持有文件锁2,等待文件锁1。两个任务就这么僵住了。
解决办法:
- 固定锁的获取顺序(比如按文件路径排序)
- 使用超时机制,获取不到就回滚
- 死锁检测,发现后强制释放
4.4.2 锁粒度
锁粒度太粗,并发性能差;锁粒度太细,容易死锁且开销大。
我个人习惯:从粗粒度开始,性能瓶颈时再细化。不要一开始就搞细粒度锁,容易把自己绕晕。
4.4.3 锁的可见性
你想想看,一个线程修改了共享变量,另一个线程能看到吗?不一定!
锁不仅保证互斥,还保证可见性。Java的synchronized、C++的std::mutex都保证了内存可见性。但如果你自己实现自旋锁,别忘了加内存屏障。
避坑指南:尽量用语言或库提供的标准锁,别自己造轮子。标准锁经过了大量测试,比你手写的可靠得多。
4.5 锁的性能考量
锁不是免费的。每次加锁解锁都有开销:
- 上下文切换:线程阻塞和唤醒需要内核介入
- 缓存失效:锁操作会导致缓存行失效
- 争用开销:多个线程争抢锁时,性能急剧下降
我记得有一次优化构建系统,发现锁争用占了总时间的30%。后来改用读写锁 + 分片锁,把争用降到了5%以下。
优化思路:
- 减少锁持有时间:把耗时操作移出临界区
- 降低锁粒度:用分段锁代替全局锁
- 无锁数据结构:CAS、RCU等
- 读写分离:读多写少用读写锁
4.6 小结
互斥锁是并发编程的基石。它简单,但用好并不容易。
在构建工具中,锁无处不在:输出文件锁、缓存锁、资源锁……每个锁的设计都要考虑性能、死锁、可见性等问题。
最后送大家一句话:锁是必要的恶。能不用锁就不用锁,但该用锁时一定要用对。
核心要点回顾:
- 互斥锁保证同一时刻只有一个线程访问共享资源
- Bazel使用输出文件锁、缓存锁、资源锁等多种锁机制
- 死锁、锁粒度、可见性是常见问题
- 优先用标准库的锁,别自己造轮子
- 锁有性能开销,需要合理优化