4. 线程同步之互斥锁:竞态条件概念、互斥锁原理、初始化与销毁、加锁与解锁
好,咱们进入多线程编程里最核心、也最容易踩坑的一章——线程同步。
你想想看,如果多个线程同时去读写同一个变量,会发生什么?我刚开始学多线程时,觉得这有什么难的,不就是读写吗?结果程序跑起来,数据全乱套了。嗯,这就是典型的竞态条件。
4.1 竞态条件:为什么需要同步?
竞态条件,说白了就是多个线程同时访问共享数据,最终结果取决于线程执行的“时序”。谁先跑完、谁后跑完,完全不可控。
举个例子,两个线程同时对一个全局变量 counter 做 counter++。你以为是加两次,结果可能只加了1次。为什么?因为 counter++ 在底层是三条指令:读、加、写。两个线程可能同时读到同一个值,然后各自加1写回去——其中一个线程的修改就被覆盖了。
核心结论:只要多个线程共享了可写数据,就必须用同步机制保护起来。否则,程序跑得越快,错得越离谱。
我在项目中遇到过这样一个bug:一个日志系统,多个线程同时写日志文件,结果日志内容互相穿插,甚至出现半行记录。排查了两天才发现是竞态条件导致的。从那以后,我对共享数据的访问就格外小心了。
4.2 互斥锁(pthread_mutex_t)原理
解决竞态条件最经典的工具就是互斥锁。它的原理其实很简单:
- 锁只有两种状态:锁定和未锁定
- 同一时刻,只有一个线程能持有这把锁
- 其他线程想拿锁,必须等待,直到锁被释放
你可以把互斥锁想象成厕所的门。一个人进去了,锁上门,其他人就得在外面排队。里面的人出来,下一个人才能进去。嗯,就是这么个道理。
在 POSIX 线程库中,互斥锁的类型是 pthread_mutex_t。它是一个结构体,但咱们不需要关心它的内部细节,只需要知道怎么用它就行。
个人习惯:我一般把互斥锁和它保护的共享数据放在一起,比如定义成一个结构体。这样代码可读性高,也不容易搞混。
4.3 锁的初始化与销毁
使用互斥锁之前,必须先初始化。有两种方式:
4.3.1 静态初始化
如果锁是全局变量或静态变量,可以用宏 PTHREAD_MUTEX_INITIALIZER 直接初始化:
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
这种方式最简单,适合全局锁。但要注意,它只适用于默认属性的锁。
4.3.2 动态初始化
如果锁是局部变量,或者你需要设置锁的属性(比如递归锁、检错锁),就得用 pthread_mutex_init():
pthread_mutex_t lock;
int ret = pthread_mutex_init(&lock, NULL);
if (ret != 0) {
// 处理错误
}
第二个参数是 pthread_mutexattr_t 指针,传 NULL 表示使用默认属性。
我曾经犯过的错:初始化锁之后忘记检查返回值。有一次在嵌入式设备上,锁初始化失败(内存不足),但程序继续往下跑,结果加锁时直接崩溃。排查了半天才发现是初始化没成功。所以,一定要检查返回值。
4.3.3 销毁锁
用完之后,记得销毁锁,释放资源:
int ret = pthread_mutex_destroy(&lock);
if (ret != 0) {
// 处理错误
}
销毁锁时,锁必须处于未锁定状态。如果还有线程持有锁,销毁会失败,返回 EBUSY。
| 初始化方式 | 适用场景 | 注意事项 |
|---|---|---|
| PTHREAD_MUTEX_INITIALIZER | 全局/静态锁,默认属性 | 不能用于局部变量 |
| pthread_mutex_init() | 局部锁,或需要自定义属性 | 必须配对调用 destroy() |
4.4 加锁与解锁
锁准备好了,接下来就是怎么用。核心函数就两个:
pthread_mutex_lock(&lock)— 加锁。如果锁已被其他线程持有,当前线程阻塞等待。pthread_mutex_unlock(&lock)— 解锁。释放锁,唤醒等待的线程。
标准的使用模式是这样的:
pthread_mutex_lock(&lock);
// 临界区:访问共享数据
// 只有当前线程能执行到这里
pthread_mutex_unlock(&lock);
嗯,看起来很简单对吧?但实际项目中,坑往往出在“忘记解锁”上。比如函数中间有 return 语句,或者发生了异常,锁没释放,其他线程就永远等下去了。
我建议:写加锁解锁代码时,先把 unlock 写出来,再写中间的临界区代码。就像写文件时先写 close 一样,养成习惯。
4.4.1 完整示例
来看一个完整的例子:两个线程对同一个计数器累加,用互斥锁保护:
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock);
counter++; // 临界区
pthread_mutex_unlock(&lock);
}
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(&lock);
return 0;
}
如果不加锁,结果大概率小于 2000000。加了锁,每次累加都是原子操作,结果就对了。
关键点:加锁和解锁必须成对出现。每调用一次 lock,就必须有一次 unlock。否则就是死锁或资源泄漏。
4.5 避坑指南
最后,分享几个我踩过的坑:
- 忘记解锁:函数中有多个 return 路径时,每个路径都要 unlock。我建议用 goto 统一出口,或者用 RAII 风格封装(C 语言里可以自己写宏)。
- 锁的粒度太粗:把整个大函数都锁住,虽然安全,但性能极差。锁的粒度要尽量小,只保护真正共享的数据。
- 锁的顺序不一致:如果有多个锁,所有线程必须按相同的顺序加锁,否则可能死锁。比如线程A先锁L1再锁L2,线程B先锁L2再锁L1——两个线程就互相等死了。
- 重复加锁:默认的互斥锁是非递归的。同一个线程对同一把锁连续 lock 两次,会死锁。如果确实需要,可以用递归锁(PTHREAD_MUTEX_RECURSIVE)。
嗯,这一章的内容就到这里。互斥锁是线程同步的基石,理解透了,后面的条件变量、读写锁学起来就轻松多了。