4. 线程同步与互斥:竞态条件、临界区、互斥锁(mutex)的原理与使用
多线程编程,说白了就是让多个工人同时干活。但问题来了——如果两个工人同时抢一把锤子,会怎样?
嗯,这就是我们今天要聊的核心问题。我在做嵌入式通信项目时,就曾因为没处理好这个「抢锤子」的问题,导致设备偶尔死机,排查了整整三天。从那以后,我对线程同步这件事,再也不敢马虎了。
4.1 竞态条件:你永远猜不到谁先跑完
先看一个最简单的例子。两个线程同时对一个全局变量做 counter++。你觉得结果会是多少?
我告诉你,大概率不是你期望的值。为什么会这样?
因为 counter++ 在底层其实是三步操作:
- 从内存读取 counter 到寄存器
- 在寄存器里加 1
- 把结果写回内存
两个线程同时执行这三步,就可能出现「读取-加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 本章知识体系
下面这张图,帮你理清本章的核心逻辑:
4.8 小结
线程同步这件事,说难不难,说简单也不简单。核心就三点:
- 识别竞态条件——知道哪些共享资源需要保护
- 划定临界区——只保护必要的代码,别贪多
- 正确使用互斥锁——加锁、解锁、避免死锁
嗯,记住这些,你就能避开 90% 的并发坑了。剩下的 10%,等你真正遇到时,再回来翻翻这一章吧。
最后一句:多线程编程,不是比谁写得花哨,而是比谁写得稳。互斥锁虽然基础,但用好了,它就是最可靠的伙伴。
公众号:蓝海资料掘金营,微信deep3321