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(&not_empty, &mtx);
}
item = dequeue();
pthread_mutex_unlock(&mtx);

条件变量适合生产者—消费者队列、线程池任务分发、状态机等待等场景。它的核心不是“通知”,而是“在锁保护下重新检查条件”。

四、信号量:计数型资源许可

信号量(semaphore)维护一个非负计数,表示可用资源的数量。P 操作(wait)申请一个许可,计数减一,若为负则阻塞;V 操作(post)释放一个许可,计数加一并唤醒等待者。信号量既可以当互斥锁用(初值为 1 的二元信号量),也可以表示“最多允许 N 个线程同时访问”的限流场景,例如数据库连接池、并发下载数控制。

信号量与条件变量的区别在于:信号量自带计数,适合表达“资源数量”;条件变量适合表达“某个布尔条件成立”。如果只是等待一个事件发生,条件变量更自然;如果要控制同时进入的线程数量,信号量更直接。

四种同步原语的适用场景与关键约束 互斥锁 Mutex 同一时刻仅 1 个线程 保护读—改—写复合操作 临界区要短 典型:余额扣减 计数器、链表插入 读写锁 RWLock 读共享,写排他 读多写少收益明显 注意写者饥饿 典型:配置表、路由表 缓存索引 条件变量 Cond 等待布尔条件成立 必须与互斥锁配合 while 循环防虚假唤醒 典型:生产者—消费者 线程池任务分发 信号量 Semaphore 计数型资源许可 P 申请 / V 释放 可限流 N 个并发 典型:连接池 并发下载数控制 死锁的四个必要条件与预防策略 互斥 资源不可共享 占有并等待 持锁再申请新锁 不可剥夺 锁不能被强制抢占 循环等待 A 等 B,B 等 A 尽量用无锁/只读共享 一次性申请全部锁 tryLock + 超时回退 全局统一加锁顺序

五、死锁的预防与检测

死锁通常发生在多个线程以不同顺序申请多把锁时。例如线程 A 先锁 L1 再锁 L2,线程 B 先锁 L2 再锁 L1,两者各持一把、互相等待,就形成循环等待。预防死锁最有效的工程手段是统一加锁顺序:为所有锁定义全局编号,任何线程都按编号从小到大申请。这样循环等待条件被破坏,死锁无法形成。

第二种手段是一次性申请全部锁,或者使用带超时的 tryLock:申请失败就释放已持有的锁并回退重试。第三种是缩小锁范围,尽量用无锁数据结构或只读共享替代多锁协作。对于无法完全避免死锁的系统,需要检测机制:维护“线程—锁”等待图,定期检查是否存在环;一旦发现环,可选择终止代价最小的线程或回滚事务。

实用判断:如果一段代码需要同时持有两把以上的锁,先问三个问题——能否合并为一把锁?能否固定加锁顺序?能否用 tryLock 加超时?三个问题都无解时,才考虑引入死锁检测。
常见误区:条件变量用 if 判断条件、信号量初值设置错误、读写锁中读锁内再申请写锁,都会造成难以复现的挂起。排查时优先检查“锁的申请顺序”和“条件判断是否在循环中”。

综合来看,互斥锁是基础,读写锁是读多写少时的优化,条件变量用于等待条件,信号量用于计数限流。选择原语时,先明确共享对象的访问模式,再决定用哪种机制;而只要涉及多把锁,就必须把加锁顺序当作接口契约来设计,否则死锁只是时间问题。