线程同步:互斥锁、读写锁与条件变量
多线程编程中,同步是个绕不开的话题。说白了,就是多个线程同时访问共享数据时,怎么保证数据不出错。我刚开始写多线程程序时,总觉得加锁是件麻烦事,能不加就不加。直到有一次线上服务莫名其妙地崩溃,排查了整整两天,最后发现是线程间共享了一个全局计数器,没有做任何保护——嗯,从那以后我再也不敢轻视同步问题了。
今天我们来聊聊 POSIX 线程库提供的三种同步机制:互斥锁(pthread_mutex_t)、读写锁(pthread_rwlock_t)和条件变量(pthread_cond_t)。这三种工具各有各的适用场景,用对了事半功倍,用错了可能比不用还糟糕。
一、互斥锁:最基础的同步工具
互斥锁,名字就说明了一切——互斥,就是同一时间只能有一个线程持有这把锁。其他想拿锁的线程,都得等着。
我个人习惯把互斥锁想象成厕所的门。一个人进去了,把门锁上,其他人只能在门外排队。里面的人出来,下一个才能进去。简单粗暴,但有效。
基本用法
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* worker(void* arg) {
pthread_mutex_lock(&mutex);
// 临界区:访问共享资源
printf("线程 %ld 正在工作\n", (long)arg);
pthread_mutex_unlock(&mutex);
return NULL;
}
这里要注意,PTHREAD_MUTEX_INITIALIZER 是静态初始化的宏,适用于全局或静态互斥锁。如果是动态分配的,需要用 pthread_mutex_init() 来初始化。
pthread_mutex_destroy() 释放锁资源。虽然进程退出时系统会回收,但在长时间运行的服务中,反复创建销毁线程而不销毁锁,会导致资源泄漏。别问我怎么知道的。
互斥锁的类型
POSIX 定义了三种互斥锁类型,通过属性对象来设置:
| 类型 | 行为 | 适用场景 |
|---|---|---|
| PTHREAD_MUTEX_NORMAL | 不检测死锁,重复加锁会导致未定义行为 | 简单场景,性能优先 |
| PTHREAD_MUTEX_ERRORCHECK | 检测死锁,重复加锁返回 EDEADLK | 调试阶段,排查问题 |
| PTHREAD_MUTEX_RECURSIVE | 允许同一线程重复加锁,需要对应次数的解锁 | 递归函数中需要加锁 |
你想想看,递归锁虽然方便,但容易掩盖设计问题。我在项目中见过有人为了省事,把所有函数都加上递归锁,结果性能一塌糊涂。能用普通锁解决的问题,别用递归锁。
二、读写锁:读多写少的利器
互斥锁有个明显的缺点:不管你是读还是写,都得排他。但实际场景中,读操作往往远多于写操作。比如一个配置表,99% 的时间都在查询,只有 1% 的时间在更新。这时候用互斥锁,读线程之间互相阻塞,太浪费了。
读写锁就是为解决这个问题而生的。它允许多个线程同时读,但写操作是排他的——写的时候不能读,读的时候不能写。
基本用法
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void* reader(void* arg) {
pthread_rwlock_rdlock(&rwlock);
// 读共享数据
printf("读取数据\n");
pthread_rwlock_unlock(&rwlock);
return NULL;
}
void* writer(void* arg) {
pthread_rwlock_wrlock(&rwlock);
// 写共享数据
printf("写入数据\n");
pthread_rwlock_unlock(&rwlock);
return NULL;
}
读写锁的陷阱
读写锁有个经典问题——写者饥饿。如果读线程源源不断地到来,写线程可能永远拿不到锁。POSIX 标准没有规定读写锁的调度策略,有些实现是读者优先,有些是写者优先。Linux 的 NPTL 实现默认是读者优先,但可以通过设置属性来调整。
我在项目中遇到过这样的情况:一个监控系统每隔 100ms 读取一次配置,而配置更新可能几小时才一次。结果有一次配置更新时,读线程太多,写线程等了将近 30 秒才拿到锁。嗯,虽然不影响正确性,但用户体验很差。
三、条件变量:让线程学会等待
互斥锁和读写锁解决的是「资源竞争」问题,但有时候我们需要的是「事件通知」。比如生产者-消费者模型:消费者要等生产者生产出数据才能消费,如果数据还没准备好,消费者应该等待,而不是空转轮询。
条件变量就是干这个的。它让线程在某个条件不满足时挂起,直到其他线程改变了条件并通知它。
基本用法
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
void* producer(void* arg) {
// 生产数据
pthread_mutex_lock(&mutex);
data_ready = 1;
pthread_cond_signal(&cond); // 通知消费者
pthread_mutex_unlock(&mutex);
return NULL;
}
void* consumer(void* arg) {
pthread_mutex_lock(&mutex);
while (!data_ready) {
pthread_cond_wait(&cond, &mutex); // 等待条件
}
// 消费数据
pthread_mutex_unlock(&mutex);
return NULL;
}
pthread_cond_wait() 要在 while 循环中调用?因为存在虚假唤醒——即使没有线程调用 signal,wait 也可能返回。这是 POSIX 标准允许的行为。所以必须用 while 重新检查条件,不能用 if。
signal 与 broadcast 的区别
pthread_cond_signal() 只唤醒一个等待线程,而 pthread_cond_broadcast() 唤醒所有等待线程。怎么选?
- 如果所有等待线程都能处理同一个条件,用 signal。比如多个消费者等待一个任务,谁抢到谁做。
- 如果条件变化会影响所有等待线程,用 broadcast。比如配置更新了,所有等待配置的线程都需要重新加载。
我个人习惯:拿不准的时候用 broadcast。虽然会多唤醒几个线程,但至少不会漏掉。性能损失通常可以接受。
四、知识体系总览
下面这张图总结了三种同步机制的核心区别和适用场景:
五、避坑指南与最佳实践
说了这么多,最后分享几条我用血泪换来的经验:
- 锁的粒度要适中。锁得太细,性能开销大;锁得太粗,并发度低。我一般遵循「能锁局部不锁全局,能锁函数不锁循环」的原则。
- 避免锁嵌套。两个线程互相等待对方释放锁,就是死锁。如果实在避免不了,确保所有线程按相同的顺序加锁。
- 条件变量的 while 循环不能省。前面说过,虚假唤醒是真实存在的。我在一个网络库中见过因为用 if 导致的条件竞争,排查了三天才找到原因。
- 读写锁不是万能的。如果写操作频繁,读写锁的维护开销可能超过互斥锁。性能测试才是王道。
- 考虑使用更高级的同步原语。比如 C11 的
atomic操作、POSIX 的屏障(barrier)、信号量(semaphore)等。有时候用原子变量就能解决的问题,何必上锁呢?
PTHREAD_MUTEX_ERRORCHECK 类型的互斥锁,它能帮你检测出重复加锁等错误。上线前再换成 PTHREAD_MUTEX_NORMAL 提升性能。
好了,关于线程同步的三种基本机制就聊到这里。这些东西看起来简单,但真正用好需要大量的实践。你想想看,Linux 内核里成千上万的锁,每一种都有它存在的理由。多写、多调试、多思考,慢慢就能找到感觉了。
公众号:蓝海资料掘金营,微信deep3321