16、多线程安全:互斥锁使用规范、条件变量安全、线程安全队列、死锁预防

多线程编程,说白了就是让多个执行流同时干活。听起来很美好,但实际做起来,坑多得能让你怀疑人生。我做了十几年嵌入式,见过太多因为线程安全问题导致的诡异bug——有时候是偶发死机,有时候是数据错乱,最要命的是,这些问题往往在实验室里复现不出来,一上现场就翻车。

嗯,今天我们就来聊聊多线程安全的核心话题。我会结合自己踩过的坑,把互斥锁、条件变量、线程安全队列和死锁预防这些关键点讲清楚。

核心观点:多线程安全不是靠运气,而是靠严格的规范和防御式编程习惯。你写的每一行代码,都要假设其他线程随时可能来抢资源。

多线程安全核心知识体系 多线程安全 互斥锁使用规范 条件变量安全 线程安全队列 死锁预防 锁粒度控制 RAII封装 避免递归锁 虚假唤醒处理 while循环检查 超时机制 锁+条件变量 无锁队列 内存屏障 固定锁顺序 trylock回退 锁超时检测

16.1 互斥锁使用规范

互斥锁(Mutex)是多线程编程中最基础的同步工具。但基础不代表简单,用不好照样出大问题。

锁粒度控制是我个人最在意的一点。锁太粗,性能差;锁太细,容易出bug。我见过一个项目,为了追求极致性能,把锁拆成了十几个细粒度锁,结果死锁问题层出不穷,最后不得不重构。

我的经验:锁的粒度以「保护的数据访问路径」为单位,不要以「单个变量」为单位。一个结构体如果经常一起访问,就用一把锁保护整个结构体。

RAII封装是C++里常用的手法,但C语言也能做到类似效果。我习惯写一个锁管理结构体,在进入函数时自动加锁,退出时自动解锁。这样即使函数中间有多个return,也不会忘记释放锁。

// 锁管理结构体(RAII风格)
typedef struct {
    pthread_mutex_t *mutex;
    int locked;
} LockGuard;

LockGuard lock_acquire(pthread_mutex_t *m) {
    pthread_mutex_lock(m);
    LockGuard guard = { .mutex = m, .locked = 1 };
    return guard;
}

void lock_release(LockGuard *guard) {
    if (guard->locked) {
        pthread_mutex_unlock(guard->mutex);
        guard->locked = 0;
    }
}

// 使用示例
void process_data(Data *data) {
    LockGuard guard = lock_acquire(&data->mutex);
    // ... 处理数据 ...
    // 不需要手动解锁,guard离开作用域时自动释放
    lock_release(&guard);
}

避坑指南:我曾经在一个项目中使用了递归锁(Recursive Mutex),觉得这样方便,不用考虑重入问题。结果后来发现,递归锁掩盖了设计缺陷,导致锁的语义变得模糊。最后全部改成了普通互斥锁,反而让代码更清晰了。

16.2 条件变量安全

条件变量是用来等待某个条件成立的。但这里有个大坑——虚假唤醒。什么意思?就是条件变量可能在条件并未满足时被唤醒。这不是bug,是POSIX标准允许的行为。

为什么会这样?说白了,操作系统为了性能,可能会把多个等待线程同时唤醒。你想想看,如果只唤醒一个,但那个线程刚好被取消了,其他线程就永远等下去了。

正确的做法:永远在while循环中检查条件,而不是用if。

// 错误写法(可能出问题)
if (queue_is_empty(&q)) {
    pthread_cond_wait(&cond, &mutex);
}

// 正确写法(防御式编程)
while (queue_is_empty(&q)) {
    pthread_cond_wait(&cond, &mutex);
}

嗯,这里要注意,pthread_cond_wait在返回时,锁是重新获取的。但条件可能已经变了,所以必须重新检查。

超时机制也是我强烈建议加上的。我遇到过一种情况:生产者线程挂了,消费者线程永远等不到条件满足,整个系统卡死。加上超时后,至少能检测到异常并做恢复处理。

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 5;  // 5秒超时

while (queue_is_empty(&q)) {
    int ret = pthread_cond_timedwait(&cond, &mutex, &ts);
    if (ret == ETIMEDOUT) {
        // 超时处理:记录日志、尝试恢复
        log_error("Condition wait timeout");
        break;
    }
}

16.3 线程安全队列

线程安全队列是生产者-消费者模式的核心组件。实现方式有两种:基于锁的队列无锁队列

我个人习惯,在嵌入式系统中优先使用基于锁的队列。为什么?因为无锁队列虽然性能好,但实现复杂,而且对内存模型有严格要求。我在一个ARM Cortex-M的项目中尝试过无锁队列,结果因为编译器优化导致内存屏障缺失,数据一致性出了问题。排查了整整两天才找到原因。

下面是一个简单的线程安全队列实现:

typedef struct {
    int *buffer;
    int head;
    int tail;
    int size;
    int count;
    pthread_mutex_t mutex;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;
} ThreadSafeQueue;

void queue_push(ThreadSafeQueue *q, int value) {
    pthread_mutex_lock(&q->mutex);
    
    while (q->count == q->size) {
        pthread_cond_wait(&q->not_full, &q->mutex);
    }
    
    q->buffer[q->tail] = value;
    q->tail = (q->tail + 1) % q->size;
    q->count++;
    
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->mutex);
}

int queue_pop(ThreadSafeQueue *q) {
    pthread_mutex_lock(&q->mutex);
    
    while (q->count == 0) {
        pthread_cond_wait(&q->not_empty, &q->mutex);
    }
    
    int value = q->buffer[q->head];
    q->head = (q->head + 1) % q->size;
    q->count--;
    
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->mutex);
    
    return value;
}

关键点:push和pop操作都使用了while循环检查条件,并且用两个条件变量分别表示「非空」和「非满」,避免不必要的唤醒。

16.4 死锁预防

死锁是多线程编程中最让人头疼的问题。两个线程互相等待对方释放锁,谁也动不了。我见过最离谱的一次,是三个线程形成了循环等待,整个系统像被按了暂停键。

固定锁顺序是最简单有效的预防手段。所有线程在获取多个锁时,都按照相同的顺序来。比如锁A、锁B、锁C,所有线程都先拿A再拿B最后拿C,就不会出现循环等待。

// 线程1:先锁A,再锁B
pthread_mutex_lock(&lockA);
pthread_mutex_lock(&lockB);
// ... 处理 ...
pthread_mutex_unlock(&lockB);
pthread_mutex_unlock(&lockA);

// 线程2:也必须先锁A,再锁B
pthread_mutex_lock(&lockA);
pthread_mutex_lock(&lockB);
// ... 处理 ...
pthread_mutex_unlock(&lockB);
pthread_mutex_unlock(&lockA);

trylock回退是另一种策略。如果拿不到锁,就释放已经持有的锁,等一会儿再试。这样虽然可能降低性能,但能避免死锁。

while (1) {
    pthread_mutex_lock(&lockA);
    if (pthread_mutex_trylock(&lockB) == 0) {
        // 成功获取两把锁
        break;
    }
    // 获取lockB失败,释放lockA,避免死锁
    pthread_mutex_unlock(&lockA);
    usleep(100);  // 等待一段时间再试
}

避坑指南:我曾经在一个项目中使用了trylock,但没有加回退延迟。结果两个线程都在疯狂尝试,CPU占用率飙升到100%,系统响应变得极慢。加上usleep后问题解决。记住,trylock不是忙等,要有合理的退避策略。

锁超时检测是我在调试阶段常用的手段。给每个锁操作加上超时,如果超时就打印调用栈,这样能快速定位死锁位置。

int timed_lock(pthread_mutex_t *mutex, int timeout_ms) {
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    ts.tv_nsec += timeout_ms * 1000000;
    
    int ret = pthread_mutex_timedlock(mutex, &ts);
    if (ret == ETIMEDOUT) {
        // 打印调用栈,帮助定位死锁
        print_stack_trace();
        return -1;
    }
    return 0;
}

好了,多线程安全这块内容就讲到这里。记住,写多线程代码时,永远假设最坏情况会发生。用防御式编程的思路,把每个可能出问题的地方都堵死。这样你的代码才能在各种恶劣环境下稳定运行。

总结:互斥锁要RAII封装、条件变量要while循环、队列要双条件变量、死锁要固定顺序。这四个习惯养成了,多线程编程的坑就能避开一大半。

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