第二十讲:多线程安全——互斥锁、条件变量、线程安全随机数
多线程编程,说白了就是让程序同时干好几件事。听起来很爽,对吧?但我在实际项目中踩过不少坑——最典型的就是两个线程同时修改同一个变量,结果数据全乱套了。今天我们就来聊聊怎么用C语言解决这个问题。
为什么需要线程安全?
先看一个简单的例子。假设两个线程都在执行 counter++,你猜结果会是多少?
// 非线程安全的计数器
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
counter++; // 这行不是原子操作!
}
return NULL;
}
我刚开始学多线程时,以为 counter++ 就是一条指令。其实它分三步:读取、加1、写回。两个线程同时执行,就可能出现「读取到旧值,各自加1,写回相同值」的情况——结果少加了。
核心问题:多个线程同时访问共享数据,且至少有一个线程在写,就会产生数据竞争(data race)。
互斥锁:最简单的保护机制
互斥锁(mutex)就像厕所的门锁——谁拿到钥匙谁进去,其他人等着。我习惯用 pthread_mutex_t 类型,配合 pthread_mutex_lock 和 pthread_mutex_unlock 使用。
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;
void* safe_increment(void* arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
嗯,这里要注意:锁的粒度要适中。锁得太细(比如每个变量都加锁)性能差;锁得太粗(整个函数加锁)又退化成单线程。我在项目中遇到过一种情况——把锁放在循环外面,结果一个线程占着锁跑完整个循环,其他线程活活饿死。
我的习惯:锁的持有时间尽量短。只保护真正需要保护的代码段,不要在锁里做I/O操作或复杂计算。
条件变量:让线程学会等待
互斥锁解决了「同时访问」的问题,但解决不了「等待条件」的问题。比如生产者-消费者模型:消费者要等生产者生产出数据才能消费。轮询?太浪费CPU了。这时候就需要条件变量。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
// 生产者
void* producer(void* arg) {
// 生产数据...
pthread_mutex_lock(&lock);
data_ready = 1;
pthread_cond_signal(&cond); // 通知消费者
pthread_mutex_unlock(&lock);
return NULL;
}
// 消费者
void* consumer(void* arg) {
pthread_mutex_lock(&lock);
while (!data_ready) {
pthread_cond_wait(&cond, &lock); // 自动释放锁,等待通知
}
// 消费数据...
pthread_mutex_unlock(&lock);
return NULL;
}
你想想看,pthread_cond_wait 为什么需要传入锁?因为它要原子地完成「释放锁 + 进入等待」这两个操作。如果先释放锁再等待,中间可能被其他线程插进来——这就是我曾经犯过的错。
避坑指南:条件变量一定要配合 while 循环检查条件,不要用 if。因为存在「虚假唤醒」——线程可能在没有收到信号的情况下被唤醒。我曾经因为这个bug排查了整整两天。
线程安全随机数
说到随机数,很多人直接用 rand()。但 rand() 不是线程安全的——它内部有全局状态。多个线程同时调用,状态就乱了。
我推荐两种做法:
- 使用
rand_r():这是可重入版本,需要传入一个种子指针。 - 使用线程本地存储:每个线程维护自己的随机数生成器。
// 方法1:使用 rand_r()
unsigned int seed = time(NULL) ^ pthread_self(); // 每个线程不同种子
void* thread_func(void* arg) {
unsigned int local_seed = seed + (unsigned long)pthread_self();
for (int i = 0; i < 10; i++) {
int r = rand_r(&local_seed);
printf("Thread %lu: %d\n", pthread_self(), r);
}
return NULL;
}
// 方法2:使用线程本地存储(C11)
_Thread_local unsigned int tls_seed = 0;
void init_tls_seed(void) {
tls_seed = time(NULL) ^ (unsigned int)pthread_self();
}
int tls_rand(void) {
tls_seed = tls_seed * 1103515245 + 12345;
return (tls_seed / 65536) % 32768;
}
我个人更倾向于 rand_r(),因为它简单直接。但如果你需要高质量的随机数,可以考虑 drand48_r() 系列函数——它们提供了更好的统计特性。
知识体系总览
下面这张图总结了多线程安全的核心逻辑:
实战建议
最后分享几个我多年积累的经验:
- 先设计,后编码:多线程程序最难的是「想清楚」。我习惯先画出线程交互图,再动手写代码。
- 尽量用高级抽象:如果项目允许,用C11的
_Atomic类型或C++的std::mutex,比自己手撸pthread更安全。 - 测试要加压力:单线程跑100次没问题,不代表多线程没问题。我一般用
helgrind或ThreadSanitizer做静态分析。 - 不要迷信锁:有时候无锁编程(lock-free)反而更简单。比如用原子操作
__sync_fetch_and_add实现计数器,比加锁快一个数量级。
记住:多线程安全不是「加把锁就完事」。它需要你理解硬件、操作系统、编译器三者的协作。我见过太多「加锁后反而更慢」的案例——原因往往是锁竞争太激烈,或者锁的粒度没选对。
好了,这一讲的内容就到这里。多线程安全是个大话题,但掌握了互斥锁、条件变量和线程安全随机数这三个核心工具,你已经能解决90%的实际问题了。剩下的10%,靠经验和调试工具慢慢积累吧。