4. 互斥锁(Mutex):基本原理、在构建工具中的应用(如Bazel的锁机制)

说到并发编程,互斥锁绝对是最基础、最常用的同步原语。说白了,它就是一把「钥匙」——谁拿到钥匙,谁就能进房间干活,其他人得在门外等着。

我刚开始接触多线程时,总觉得锁这东西很简单。直到有一次线上事故,我才真正体会到:锁用不好,系统分分钟给你颜色看。

4.1 互斥锁的核心原理

互斥锁(Mutex,全称 Mutual Exclusion)的核心目标就一个:保证同一时刻只有一个线程访问共享资源。

它的工作流程其实很直观:

  1. 线程A尝试获取锁
  2. 如果锁空闲,线程A拿到锁,进入临界区
  3. 如果锁已被占用,线程A阻塞等待
  4. 线程B释放锁后,线程A被唤醒,拿到锁
  5. 线程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%以下。

优化思路:

  1. 减少锁持有时间:把耗时操作移出临界区
  2. 降低锁粒度:用分段锁代替全局锁
  3. 无锁数据结构:CAS、RCU等
  4. 读写分离:读多写少用读写锁

4.6 小结

互斥锁是并发编程的基石。它简单,但用好并不容易。

在构建工具中,锁无处不在:输出文件锁、缓存锁、资源锁……每个锁的设计都要考虑性能、死锁、可见性等问题。

最后送大家一句话:锁是必要的恶。能不用锁就不用锁,但该用锁时一定要用对。

核心要点回顾:

  • 互斥锁保证同一时刻只有一个线程访问共享资源
  • Bazel使用输出文件锁、缓存锁、资源锁等多种锁机制
  • 死锁、锁粒度、可见性是常见问题
  • 优先用标准库的锁,别自己造轮子
  • 锁有性能开销,需要合理优化

可可精品资料库