3. 并发同步原语
并发程序真正困难的地方,不是把任务拆成多个线程,而是让这些线程在共享数据上“有序地碰面”。同步原语就是用来规定这种碰面规则的机制。选错原语,程序可能仍然能跑,但会在高并发下退化成串行,或者在某个边界条件下永久卡死。本节从四种最常用的同步原语入手,讲清它们的原理、适用场景,并给出死锁的预防与检测方法。
一、互斥锁:最基础的临界区保护
互斥锁(mutex)保证同一时刻只有一个线程进入临界区。它的语义是“占有—释放”:线程加锁成功后独占资源,其他线程在锁上等待,直到锁被释放。互斥锁适合保护“读—改—写”这类不可分割的复合操作,例如账户余额扣减、计数器自增、链表节点插入。
使用互斥锁的关键不是“加上锁”,而是控制临界区粒度。临界区过大,会把并行变成串行;临界区过小,又可能漏保护共享状态。一个常见错误是只保护写、不保护读,导致读到中间态。例如下面这段示例代码,扣减余额时必须把“判断余额是否充足”和“扣减”放在同一个临界区内:
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
int balance = 1000;
int withdraw(int amount) {
pthread_mutex_lock(&mtx);
if (balance < amount) { // 判断与扣减必须原子
pthread_mutex_unlock(&mtx);
return -1;
}
balance -= amount;
pthread_mutex_unlock(&mtx);
return 0;
}
如果先解锁再扣减,两个线程可能同时通过余额判断,最终把余额扣成负数。互斥锁的代价是竞争激烈时线程频繁阻塞与唤醒,因此它更适合临界区短、写操作频繁的场景。
二、读写锁:读多写少时的并发优化
读写锁(rwlock)把访问者分为读者和写者:多个读者可以同时持有读锁,但写锁是排他的,且写锁与读锁互斥。它适合“读远多于写”的共享结构,例如配置表、路由表、缓存索引。相比互斥锁,读写锁允许读操作并行,从而提升吞吐。
但读写锁并非总是更快。当写操作比例上升,或读锁持有时间较长时,写者可能长期饥饿。工程上通常需要选择写优先或读优先策略,并控制读锁的持有时间。一个实用判断是:如果读操作占比超过约 80%,且单次读耗时明显,读写锁收益较明显;若读写比例接近,直接用互斥锁往往更简单、更可预测。
三、条件变量:等待某个条件成立
条件变量(condition variable)解决的是“线程需要等到某个条件成立才能继续”的问题。它本身不保护数据,必须与互斥锁配合使用。典型模式是:线程在持锁状态下检查条件,条件不满足则调用 wait 释放锁并睡眠;另一个线程改变条件后调用 signal 或 broadcast 唤醒等待者。
条件变量最容易出错的地方是“虚假唤醒”和“唤醒丢失”。因此判断条件必须放在 while 循环中,而不是 if:
pthread_mutex_lock(&mtx);
while (queue_empty()) { // 必须用 while 防虚假唤醒
pthread_cond_wait(¬_empty, &mtx);
}
item = dequeue();
pthread_mutex_unlock(&mtx);
条件变量适合生产者—消费者队列、线程池任务分发、状态机等待等场景。它的核心不是“通知”,而是“在锁保护下重新检查条件”。
四、信号量:计数型资源许可
信号量(semaphore)维护一个非负计数,表示可用资源的数量。P 操作(wait)申请一个许可,计数减一,若为负则阻塞;V 操作(post)释放一个许可,计数加一并唤醒等待者。信号量既可以当互斥锁用(初值为 1 的二元信号量),也可以表示“最多允许 N 个线程同时访问”的限流场景,例如数据库连接池、并发下载数控制。
信号量与条件变量的区别在于:信号量自带计数,适合表达“资源数量”;条件变量适合表达“某个布尔条件成立”。如果只是等待一个事件发生,条件变量更自然;如果要控制同时进入的线程数量,信号量更直接。
五、死锁的预防与检测
死锁通常发生在多个线程以不同顺序申请多把锁时。例如线程 A 先锁 L1 再锁 L2,线程 B 先锁 L2 再锁 L1,两者各持一把、互相等待,就形成循环等待。预防死锁最有效的工程手段是统一加锁顺序:为所有锁定义全局编号,任何线程都按编号从小到大申请。这样循环等待条件被破坏,死锁无法形成。
第二种手段是一次性申请全部锁,或者使用带超时的 tryLock:申请失败就释放已持有的锁并回退重试。第三种是缩小锁范围,尽量用无锁数据结构或只读共享替代多锁协作。对于无法完全避免死锁的系统,需要检测机制:维护“线程—锁”等待图,定期检查是否存在环;一旦发现环,可选择终止代价最小的线程或回滚事务。
综合来看,互斥锁是基础,读写锁是读多写少时的优化,条件变量用于等待条件,信号量用于计数限流。选择原语时,先明确共享对象的访问模式,再决定用哪种机制;而只要涉及多把锁,就必须把加锁顺序当作接口契约来设计,否则死锁只是时间问题。