第二十讲:多线程安全——互斥锁、条件变量、线程安全随机数

多线程编程,说白了就是让程序同时干好几件事。听起来很爽,对吧?但我在实际项目中踩过不少坑——最典型的就是两个线程同时修改同一个变量,结果数据全乱套了。今天我们就来聊聊怎么用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_lockpthread_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() 不是线程安全的——它内部有全局状态。多个线程同时调用,状态就乱了。

我推荐两种做法:

  1. 使用 rand_r():这是可重入版本,需要传入一个种子指针。
  2. 使用线程本地存储:每个线程维护自己的随机数生成器。
// 方法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() 系列函数——它们提供了更好的统计特性。

知识体系总览

下面这张图总结了多线程安全的核心逻辑:

多线程安全核心知识体系 互斥锁 (Mutex) 条件变量 (Cond) 线程安全随机数 pthread_mutex_lock/unlock 锁粒度控制 死锁预防 pthread_cond_wait/signal while循环检查条件 虚假唤醒处理 rand_r() 可重入版本 _Thread_local 线程本地存储 drand48_r() 高质量随机数 核心原则:保护共享数据 → 避免忙等待 → 每个线程独立状态

实战建议

最后分享几个我多年积累的经验:

  • 先设计,后编码:多线程程序最难的是「想清楚」。我习惯先画出线程交互图,再动手写代码。
  • 尽量用高级抽象:如果项目允许,用C11的 _Atomic 类型或C++的 std::mutex,比自己手撸pthread更安全。
  • 测试要加压力:单线程跑100次没问题,不代表多线程没问题。我一般用 helgrindThreadSanitizer 做静态分析。
  • 不要迷信锁:有时候无锁编程(lock-free)反而更简单。比如用原子操作 __sync_fetch_and_add 实现计数器,比加锁快一个数量级。

记住:多线程安全不是「加把锁就完事」。它需要你理解硬件、操作系统、编译器三者的协作。我见过太多「加锁后反而更慢」的案例——原因往往是锁竞争太激烈,或者锁的粒度没选对。

好了,这一讲的内容就到这里。多线程安全是个大话题,但掌握了互斥锁、条件变量和线程安全随机数这三个核心工具,你已经能解决90%的实际问题了。剩下的10%,靠经验和调试工具慢慢积累吧。