4. 线程同步与互斥:竞态条件、临界区、互斥锁(mutex)的原理与使用

多线程编程,说白了就是让多个工人同时干活。但问题来了——如果两个工人同时抢一把锤子,会怎样?

嗯,这就是我们今天要聊的核心问题。我在做嵌入式通信项目时,就曾因为没处理好这个「抢锤子」的问题,导致设备偶尔死机,排查了整整三天。从那以后,我对线程同步这件事,再也不敢马虎了。

4.1 竞态条件:你永远猜不到谁先跑完

先看一个最简单的例子。两个线程同时对一个全局变量做 counter++。你觉得结果会是多少?

我告诉你,大概率不是你期望的值。为什么会这样?

因为 counter++ 在底层其实是三步操作:

  1. 从内存读取 counter 到寄存器
  2. 在寄存器里加 1
  3. 把结果写回内存

两个线程同时执行这三步,就可能出现「读取-加1-写回」的交叉。比如:

线程A: 读取 counter=5
线程B: 读取 counter=5
线程A: 加1得6,写回
线程B: 加1得6,写回
// 结果:counter=6,但我们应该得到7

这就是典型的竞态条件。程序的结果依赖于线程执行的相对顺序,而这个顺序是不可控的。

核心定义:竞态条件是指多个线程并发访问共享数据,且最终结果取决于线程执行时序的一种错误现象。

我在项目中遇到过最坑的一次,是日志系统里多个线程同时写同一个文件。平时测试没问题,一到高并发场景,日志就出现乱码、丢失。后来一查,就是竞态条件在作祟。

4.2 临界区:保护共享资源的「隔离区」

既然有竞态条件,我们就得想办法保护共享资源。这个被保护的区域,就叫临界区

临界区有几个特点:

  • 同一时刻,只能有一个线程进入
  • 进入临界区的线程,必须尽快退出
  • 临界区内的代码,通常是对共享资源的操作

你想想看,临界区就像厕所的隔间。一个人进去了,其他人就得在外面等着。不能两个人同时蹲一个坑,对吧?

我的建议:临界区要尽量小。只把真正需要保护的代码放进去,不要在里面做耗时操作(比如文件读写、网络请求)。否则其他线程会等得很痛苦。

4.3 互斥锁(mutex):最基础的同步工具

实现临界区最常用的工具,就是互斥锁(mutex,全称 mutual exclusion)。

互斥锁的原理很简单:

  • 锁有两种状态:锁定(locked)和解锁(unlocked)
  • 线程在进入临界区前,先尝试加锁
  • 如果锁已被其他线程持有,当前线程就阻塞等待
  • 线程退出临界区后,释放锁

用代码说话,POSIX 线程库(pthread)的用法如下:

#include <pthread.h>

pthread_mutex_t lock;  // 定义互斥锁

// 初始化锁
pthread_mutex_init(&lock, NULL);

// 线程函数
void* worker(void* arg) {
    pthread_mutex_lock(&lock);   // 加锁
    // 临界区开始
    counter++;
    // 临界区结束
    pthread_mutex_unlock(&lock); // 解锁
    return NULL;
}

// 使用完后销毁
pthread_mutex_destroy(&lock);

加了锁之后,counter++ 的三步操作就变成了原子操作——要么全部执行完,要么一步都不执行。其他线程只能干等着。

注意:加锁和解锁必须成对出现。我曾经见过一个同事,在某个分支里忘了解锁,结果整个程序卡死。排查了半天,最后发现是少写了一个 pthread_mutex_unlock

4.4 互斥锁的底层原理

互斥锁是怎么实现的?说白了,它依赖硬件提供的原子操作指令,比如 x86 的 lock cmpxchg

一个简单的自旋锁实现(仅用于理解原理,实际不要这么写):

// 自旋锁的伪代码
void mutex_lock(volatile int* lock) {
    while (atomic_exchange(lock, 1) == 1) {
        // 忙等待,CPU空转
    }
}

void mutex_unlock(volatile int* lock) {
    *lock = 0;
}

但真正的 pthread 互斥锁比这复杂得多。它会根据情况选择:

  • 如果锁很快能拿到,就自旋一会儿(避免线程切换开销)
  • 如果锁被长时间持有,就把线程挂起(避免浪费CPU)

这种「自适应」策略,是经过大量实践优化的结果。

4.5 使用互斥锁的常见陷阱

光知道怎么用还不够,还得知道怎么正确地用。我踩过的坑,列出来给你参考:

陷阱 说明 解决方法
死锁 两个线程互相等待对方释放锁 固定加锁顺序,或使用 trylock
锁粒度太大 一个锁保护了太多资源,导致并发度下降 拆分成多个细粒度锁
锁粒度太小 每个操作都加锁解锁,开销巨大 合并操作,减少加锁次数
忘记解锁 异常分支或提前返回时没解锁 使用 RAII 封装,或 goto cleanup
重复加锁 同一个线程对同一个锁再次加锁 使用递归锁(pthread_mutexattr_settype)

避坑指南:我曾经在一个网络代理项目中,因为锁粒度太大,导致性能只有单线程的 70%。后来把一把大锁拆成 8 把细粒度锁,性能直接翻了 4 倍。锁的粒度,真的是门学问。

4.6 互斥锁 vs 其他同步机制

互斥锁不是万能的。不同场景有不同选择:

机制 适用场景 特点
互斥锁 保护临界区,任意时刻只允许一个线程访问 最通用,但可能有上下文切换开销
读写锁 读多写少的场景 允许多个读者,写者独占
自旋锁 临界区极短,且线程不会阻塞 忙等待,不切换线程,但浪费CPU
信号量 控制对有限资源的访问数量 可以允许多个线程同时访问
条件变量 线程间等待某个条件成立 通常与互斥锁配合使用

我个人习惯是:默认用互斥锁。除非你非常确定场景特殊,否则别搞花里胡哨的。简单的东西不容易出错。

4.7 本章知识体系

下面这张图,帮你理清本章的核心逻辑:

线程同步与互斥:知识体系 多线程并发访问共享资源 问题:竞态条件 (Race Condition) 解决方案:临界区 + 互斥锁 临界区:保护共享资源的代码段 互斥锁:实现互斥访问的工具 其他:读写锁、信号量、条件变量 核心原则:临界区尽量小,加解锁必须成对,避免死锁

4.8 小结

线程同步这件事,说难不难,说简单也不简单。核心就三点:

  • 识别竞态条件——知道哪些共享资源需要保护
  • 划定临界区——只保护必要的代码,别贪多
  • 正确使用互斥锁——加锁、解锁、避免死锁

嗯,记住这些,你就能避开 90% 的并发坑了。剩下的 10%,等你真正遇到时,再回来翻翻这一章吧。

最后一句:多线程编程,不是比谁写得花哨,而是比谁写得稳。互斥锁虽然基础,但用好了,它就是最可靠的伙伴。


公众号:蓝海资料掘金营,微信deep3321