16、多线程安全:互斥锁使用规范、条件变量安全、线程安全队列、死锁预防
多线程编程,说白了就是让多个执行流同时干活。听起来很美好,但实际做起来,坑多得能让你怀疑人生。我做了十几年嵌入式,见过太多因为线程安全问题导致的诡异bug——有时候是偶发死机,有时候是数据错乱,最要命的是,这些问题往往在实验室里复现不出来,一上现场就翻车。
嗯,今天我们就来聊聊多线程安全的核心话题。我会结合自己踩过的坑,把互斥锁、条件变量、线程安全队列和死锁预防这些关键点讲清楚。
核心观点:多线程安全不是靠运气,而是靠严格的规范和防御式编程习惯。你写的每一行代码,都要假设其他线程随时可能来抢资源。
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循环、队列要双条件变量、死锁要固定顺序。这四个习惯养成了,多线程编程的坑就能避开一大半。