线程同步:互斥锁、读写锁与条件变量

多线程编程中,同步是个绕不开的话题。说白了,就是多个线程同时访问共享数据时,怎么保证数据不出错。我刚开始写多线程程序时,总觉得加锁是件麻烦事,能不加就不加。直到有一次线上服务莫名其妙地崩溃,排查了整整两天,最后发现是线程间共享了一个全局计数器,没有做任何保护——嗯,从那以后我再也不敢轻视同步问题了。

今天我们来聊聊 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;
}
小技巧:如果写操作非常频繁,读写锁可能比互斥锁还慢。因为每次写锁释放后,要唤醒所有等待的读线程,上下文切换开销很大。我一般建议:写操作占比超过 20%,直接用互斥锁。

读写锁的陷阱

读写锁有个经典问题——写者饥饿。如果读线程源源不断地到来,写线程可能永远拿不到锁。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。虽然会多唤醒几个线程,但至少不会漏掉。性能损失通常可以接受。

四、知识体系总览

下面这张图总结了三种同步机制的核心区别和适用场景:

线程同步三种机制 互斥锁 pthread_mutex_t • 排他访问 • 同一时间只有一个线程 • 简单可靠 适用:写操作频繁 或读写比例接近 性能:中等 死锁风险:高 读写锁 pthread_rwlock_t • 多读单写 • 读时共享 • 写时排他 适用:读多写少 (写操作 < 20%) 性能:读场景高 写者饥饿风险 条件变量 pthread_cond_t • 事件通知机制 • 必须配合互斥锁使用 • 避免忙等待 适用:生产者-消费者 等待特定条件 注意:虚假唤醒 必须用 while 检查 复杂度递增 灵活性递增

五、避坑指南与最佳实践

说了这么多,最后分享几条我用血泪换来的经验:

  1. 锁的粒度要适中。锁得太细,性能开销大;锁得太粗,并发度低。我一般遵循「能锁局部不锁全局,能锁函数不锁循环」的原则。
  2. 避免锁嵌套。两个线程互相等待对方释放锁,就是死锁。如果实在避免不了,确保所有线程按相同的顺序加锁。
  3. 条件变量的 while 循环不能省。前面说过,虚假唤醒是真实存在的。我在一个网络库中见过因为用 if 导致的条件竞争,排查了三天才找到原因。
  4. 读写锁不是万能的。如果写操作频繁,读写锁的维护开销可能超过互斥锁。性能测试才是王道。
  5. 考虑使用更高级的同步原语。比如 C11 的 atomic 操作、POSIX 的屏障(barrier)、信号量(semaphore)等。有时候用原子变量就能解决的问题,何必上锁呢?
一个实用建议:在开发阶段,可以用 PTHREAD_MUTEX_ERRORCHECK 类型的互斥锁,它能帮你检测出重复加锁等错误。上线前再换成 PTHREAD_MUTEX_NORMAL 提升性能。

好了,关于线程同步的三种基本机制就聊到这里。这些东西看起来简单,但真正用好需要大量的实践。你想想看,Linux 内核里成千上万的锁,每一种都有它存在的理由。多写、多调试、多思考,慢慢就能找到感觉了。


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