线程同步之互斥锁:从原理到实战
多线程编程中,最让人头疼的问题是什么?我个人觉得,就是多个线程同时访问共享数据。你想想看,两个线程同时往一个银行账户里存钱,结果钱数对不上,这谁受得了?
互斥锁,说白了就是一把「门锁」。一次只让一个线程进去干活,其他人在门外等着。今天我就带你把这把锁的原理、用法和坑都摸清楚。
互斥锁的原理:为什么需要它?
先看一个经典问题。两个线程各自对全局变量 counter 执行 100 万次自增。你猜结果是多少?
#include <stdio.h>
#include <pthread.h>
int counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 1000000; i++) {
counter++; // 这行不是原子操作!
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d\n", counter);
return 0;
}
运行几次你会发现,结果每次都不一样,而且很少是 200 万。为什么会这样?
因为 counter++ 在底层是三条指令:
- 从内存读取 counter 到寄存器
- 寄存器加 1
- 写回内存
两个线程可能同时执行到第 1 步,都读到同一个值,然后各自加 1 写回。结果只加了 1 次,而不是 2 次。这就是典型的「竞态条件」。
核心概念:互斥锁保证同一时刻只有一个线程能进入临界区(访问共享资源的代码段)。其他线程必须等待,直到锁被释放。
pthread_mutex_t 的使用:四步走
在 POSIX 线程库中,互斥锁用 pthread_mutex_t 类型表示。使用流程就四步,我习惯把它记成「创建-加锁-解锁-销毁」。
1. 初始化互斥锁
两种方式:
// 静态初始化(推荐)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 动态初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // NULL 表示使用默认属性
静态初始化简单高效,我一般都用它。动态初始化适合需要自定义属性(比如递归锁)的场景。
2. 加锁
pthread_mutex_lock(&mutex);
// 临界区代码
// 只有当前线程能执行到这里
pthread_mutex_unlock(&mutex);
注意:lock() 是阻塞的。如果锁已被占用,线程会一直等下去。如果你不想等,可以用 pthread_mutex_trylock(),它立即返回,成功返回 0,失败返回 EBUSY。
3. 解锁
解锁必须由加锁的线程执行。别的线程不能帮你解锁,这是规矩。
4. 销毁
pthread_mutex_destroy(&mutex);
动态初始化的锁需要销毁,释放资源。静态初始化的锁理论上不需要,但调用一下也没坏处。
完整示例:用互斥锁修复计数器问题
#include <stdio.h>
#include <pthread.h>
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* worker(void* arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d\n", counter); // 稳定输出 2000000
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果永远是 2000000。嗯,这才对嘛。
小技巧:加锁和解锁之间的代码要尽量短。只保护真正需要保护的共享资源,别把无关操作也包进去。我在项目中见过有人把整个函数都锁住,结果性能惨不忍睹。
死锁的避免:最头疼的问题
死锁是什么?两个线程互相等待对方释放锁,结果谁也动不了。程序就卡死了。
经典的死锁场景:
// 线程 A
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2); // 等待 lock2
// 线程 B
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1); // 等待 lock1
两个线程各持有一把锁,又都在等对方手里的锁。死锁了。
我曾经踩过的坑
有一次我写一个日志系统,主线程写日志要锁住缓冲区,后台线程刷盘也要锁住同一个缓冲区。结果主线程在锁住缓冲区后调用了刷盘函数,而刷盘函数又试图锁缓冲区... 嗯,自己把自己锁死了。这就是「自死锁」。
后来我改用递归锁 PTHREAD_MUTEX_RECURSIVE_NP 解决了。但说实话,递归锁是「治标不治本」,最好还是从设计上避免。
避免死锁的黄金法则
| 法则 | 说明 |
|---|---|
| 固定加锁顺序 | 所有线程按相同顺序获取锁。比如都先锁 lock1 再锁 lock2 |
| 使用 trylock | 获取不到锁就放弃,避免死等 |
| 锁的粒度要小 | 尽量只锁必要的数据,减少锁的持有时间 |
| 避免嵌套锁 | 能不嵌套就别嵌套,一个锁能解决的问题别用两个 |
警告:死锁的排查非常痛苦。程序卡死了,没有崩溃信息,没有错误日志。我建议你在开发阶段就加上锁的调试信息,比如每次加锁解锁都打印日志。虽然会影响性能,但能救命。
知识体系总览
下面这张图总结了互斥锁的核心知识点,我把它画成了流程图,方便你理解整个逻辑:
避坑指南:我踩过的那些雷
- 忘记解锁:函数中间 return 了,锁没释放。其他线程永远等下去。我建议用
goto统一出口,或者用 RAII 风格封装。 - 锁的粒度太大:把整个循环都锁住,结果多线程退化成单线程,性能还不如不用锁。
- 信号处理函数中加锁:信号处理函数中调用
pthread_mutex_lock可能导致死锁。因为信号可能在线程持有锁时到达。 - 忘记初始化:静态初始化的锁没问题,动态初始化的锁如果忘了调用
pthread_mutex_init,行为是未定义的。
个人习惯:我写多线程代码时,会在每个加锁解锁的地方加上注释,标明保护的是哪个共享变量。比如 // lock: protects counter。这样半年后回来看代码,还能一眼看懂。
互斥锁是线程同步的基础工具,但也不是万能的。读写锁、条件变量、信号量各有各的适用场景。不过先把互斥锁用熟练了,后面的路就好走了。
可可精品资料库