
前面学习信号量、互斥锁和条件变量时,注意力往往放在"这一把锁怎么加、那个线程在哪里等待"。但真正写并发程序时,更重要的问题是:共享数据该由谁管理?线程之间靠什么约定先后关系?没有共享内存的进程甚至分布式节点,又该怎样协作?
这一篇沿着"一个同步互斥案例 → 管程 → 消息传递 → 屏障 → 避免读锁"的路径,梳理几种常见的并发协作方式。它们并非彼此替代:有的围绕共享内存,有的刻意避开共享内存;有的保证互斥,有的让一组线程在同一阶段会合。
先看一个同步互斥问题的案例
1. 两个缓冲区、两个任务
假设 task1 从标准输入读入两段字符串,分别写入 buf1 与 buf2;task2 则统计两段内容及其长度。需求不是让两个任务各自无限运行,而是要求严格交替:
text
task1 填写 buf1、buf2
↓
task2 读取并统计 buf1、buf2
↓
task1 才能填写下一组数据
↓
task2 再统计
↓
......
最朴素的写法是各自循环:
c
char buf1[32];
char buf2[32];
void task1(void) {
while (1) {
fgets(buf1, sizeof buf1, stdin);
fgets(buf2, sizeof buf2, stdin);
}
}
void task2(void) {
while (1) {
printf("buf1: %s", buf1);
printf("buf2: %s", buf2);
}
}
这段程序存在两层问题。
第一层是同步缺失 。调度器并不知道"写完一组数据后才能统计"的业务规则,因此 task1 可能连续写了好几组数据,task2 才第一次开始读取;中间的结果就被覆盖了。
第二层是互斥缺失 。当 task2 正在执行 strlen(buf1) 或打印 buf1 时,task1 可能同时修改同一块内存。这样读到的内容可能是旧值、新值,甚至是一半旧一半新的中间状态,形成数据竞争。
| 问题 | 在本例中的含义 |
|---|---|
| 互斥 | task1 修改缓冲区时,task2 不能同时读取它 |
| 同步 | 只有 task1 完成一组输入后,task2 才能统计;统计完成后,task1 才能继续 |
一句话概括:互斥解决"不能同时做",同步解决"必须按顺序做"。
2. 条件变量如何让两个任务严格交替
可以用一把互斥锁保护共享状态,再用两个条件变量表达"可以生产"和"可以消费"。is_data 才是真正的业务状态:0 代表缓冲区可写,1 代表缓冲区中已经有一组可读数据。
c
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t can_produce = PTHREAD_COND_INITIALIZER;
pthread_cond_t can_consume = PTHREAD_COND_INITIALIZER;
char buf1[32], buf2[32];
int is_data = 0; // 0:空;1:已有一组数据
void *task1(void *arg) {
while (1) {
pthread_mutex_lock(&mutex);
while (is_data != 0) {
pthread_cond_wait(&can_produce, &mutex);
}
/* 为使示例聚焦同步,输入动作也放在受保护区域中。 */
fgets(buf1, sizeof buf1, stdin);
fgets(buf2, sizeof buf2, stdin);
is_data = 1;
pthread_cond_signal(&can_consume);
pthread_mutex_unlock(&mutex);
}
}
void *task2(void *arg) {
while (1) {
pthread_mutex_lock(&mutex);
while (is_data == 0) {
pthread_cond_wait(&can_consume, &mutex);
}
printf("buf1: %s", buf1);
printf("buf2: %s", buf2);
is_data = 0;
pthread_cond_signal(&can_produce);
pthread_mutex_unlock(&mutex);
}
}
这段代码里四个对象分工明确:
| 对象 | 职责 |
|---|---|
mutex |
保护 buf1、buf2、is_data,避免并发读写 |
is_data |
描述真实状态,决定谁应该继续运行 |
can_produce |
供生产者在"已有数据未消费"时等待 |
can_consume |
供消费者在"尚无数据"时等待 |
条件变量本身不保存"空"或"满"的状态,它只提供等待/通知的场所 ;条件永远由 is_data 这样的共享状态来表示。因此 pthread_cond_wait 外面必须使用 while 而不是 if:线程被唤醒后,还要重新获得 mutex,并重新检查条件,防止多个线程竞争、虚假唤醒或其他线程抢先改变状态。
这个小案例已经具备了管程的核心组成:共享数据、操作共享数据的方法、互斥机制和条件变量。把这些东西进一步封装起来,便得到了管程。
一、管程:把共享数据和同步规则装进一个模块
1. 什么是管程
管程(Monitor) 是一种高级同步机制。它将:
- 共享数据;
- 访问这些数据的操作;
- 保护数据的互斥规则;
- 等待特定状态变化的条件变量;
封装成一个整体,并且通常保证同一时刻至多一个线程在管程内部执行。
可以把它理解为一个专门管理共享资源的并发安全对象:外部线程不能随便修改内部状态,而是只能调用它暴露出来的方法。
text
管程 Monitor
┌──────────────────────────────────┐
│ 私有共享数据:buffer / count │
│ │
│ 对外操作:put() / get() │
│ │
│ 条件变量:not_full / not_empty │
│ │
│ 进入方法时自动保证互斥 │
└──────────────────────────────────┘
↑
外部线程只调接口
管程的价值不在于它能神奇地消灭所有并发错误,而在于它把"数据"和"保护数据的规则"放在同一个边界内。相比让调用方在任何地方手写 lock、unlock、wait、signal,这种封装更容易维护正确性。
2. 管程中的条件变量
管程自动互斥只能解决"同时进入"的问题,却不能解决"条件还不满足怎么办"。因此管程内部还需要条件变量。
以有界缓冲区为例:
text
生产者调用 put(item)
缓冲区满 → 在 not_full 上等待
缓冲区未满 → 插入数据,通知 not_empty
消费者调用 get()
缓冲区空 → 在 not_empty 上等待
缓冲区非空 → 取出数据,通知 not_full
抽象伪代码如下:
text
monitor BoundedBuffer {
item buffer[N];
int count = 0, in = 0, out = 0;
condition not_full, not_empty;
procedure put(item x) {
while (count == N)
not_full.wait();
buffer[in] = x;
in = (in + 1) % N;
count++;
not_empty.signal();
}
procedure get() returns item {
while (count == 0)
not_empty.wait();
item x = buffer[out];
out = (out + 1) % N;
count--;
not_full.signal();
return x;
}
}
不同教材会介绍 Hoare 管程和 Mesa 管程的不同唤醒语义:前者强调 signal 后立刻交接管程控制权,后者只让被唤醒线程进入就绪队列、稍后重新竞争进入。现实中的 Java wait/notify、Pthreads 条件变量更接近后者,所以无论哪种实现,实践中都应该遵循:等待条件一律用 while 重查。
3. 现实语言中的管程思想
很多语言未必提供名为"Monitor"的关键字,但会以对象锁和条件等待的方式体现同一种思想。Java 的 synchronized 方法或代码块提供对象级互斥,wait()、notify()、notifyAll() 提供条件等待和通知:
java
class BoundedBuffer {
private final int[] buffer = new int[100];
private int count = 0, in = 0, out = 0;
public synchronized void put(int item) throws InterruptedException {
while (count == buffer.length) {
wait();
}
buffer[in] = item;
in = (in + 1) % buffer.length;
count++;
notifyAll();
}
public synchronized int get() throws InterruptedException {
while (count == 0) {
wait();
}
int item = buffer[out];
out = (out + 1) % buffer.length;
count--;
notifyAll();
return item;
}
}
这里 BoundedBuffer 就是一个管程风格的对象:状态是私有的,外部只能调 put/get,进入同步方法时自动获得对象锁,条件不成立则暂时释放锁并等待。
不过也要注意:管程降低了错误概率,不保证程序一定不会死锁。如果多个管程之间存在循环调用、锁顺序不一致,或条件永远不会被满足,仍然可能发生死锁或永久等待。
无论是管程 还是信号量 , 它们最初都是为了解决单一或多 CPU 架构下共享内存访问的互斥问题而设计的。在单一或多CPU系统中,通过将信号量置于共享内存中,并利用如 TSL (Test and Set Lock)或 XCHG (Exchange)等原子指令来保护信号量,可以有效地防止竞争条件的发生。
然而, 在分布式系统环境中, 情况复杂得多:
- 分布式系统可能包含多个 CPU, 每个 CPU 拥有独立的私有内存, 并通过网络进行通信。
- 在这种场景下, 传统的基于共享内存的信号量和管程显得不再适用:
- 信号量虽然提供了基本的同步机制, 但其操作级别较低, 难以直接应用于分布式环境。
- 管程由于语言支持的限制, 在大多数分布式编程场景中无法使用, 尤其因为管程强依赖于共享内存和特定语言环境。
在分布式系统中解决互斥和同步问题的高级方法
为了适应分布式系统的特性, 需要采用更高级和灵活的机制, 例如:
- 分布式锁 :
- 使用分布式协调工具(如 Zookeeper 或 Redis)实现锁的获取和释放, 保证多个节点间对共享资源的互斥访问。
- 事务性内存 (Transactional Memory):
- 提供一种乐观的并发控制机制, 通过事务提交和回滚确保操作的原子性和一致性。
- 基于消息传递的并发模型 :
- 通过消息队列(如 RabbitMQ、Kafka)或 Actor 模型(如 Akka)来管理节点之间的通信和同步, 无需依赖共享内存。
二、消息传递:不共享内存,也能通信与同步
1. 为什么进程更需要消息传递
同一进程内的线程拥有相同的地址空间,能通过全局变量或堆内存共享数据;但不同进程各自拥有独立的虚拟地址空间,不能直接读写对方的普通变量。即使两个进程里都出现相同的虚拟地址,它们通常也映射到不同的物理页。
进程间当然可以借助共享内存,但共享内存又会把问题带回"如何互斥访问"上。另一条路线是让操作系统或网络负责搬运数据:发送方把内容组织为消息,接收方从内核缓冲区、消息队列或网络连接中取出。这就是消息传递(message passing)。
最抽象的接口可以写成:
text
send(destination, message)
receive(source, message)
一条消息既可以承载业务数据,也可以承载控制含义。例如"任务已完成""请执行某项请求""缓冲区有一项数据"等。消息因此不仅能传输数据,也能天然表达同步事件。
管道、消息队列、套接字、RPC 等都可用于消息传递,但它们的寻址方式、缓冲能力和边界语义并不相同。比如字节流管道和 TCP 需要应用层约定消息边界:可以使用固定长度记录,或在协议头中携带长度字段,避免发生粘包/拆包后的解析歧义。
2. 按同步方式分类
消息传递可以是同步的,也可以是异步的。
| 类型 | 发送方 | 接收方 | 特点 |
|---|---|---|---|
| 同步通信 | 发送后可能等待接收方接收或确认 | 收不到消息时等待 | 时序明确,双方耦合更强 |
| 异步通信 | 消息进入缓冲区后即可继续 | 可在稍后接收 | 解耦更强,需要队列与容量管理 |
极端的同步通信称为会合(rendezvous):send 和 receive 必须同时配对,任何一方先到都等待另一方。异步通信则依赖消息缓冲区;缓冲区为空时,接收者可能阻塞,缓冲区满时,发送者可能阻塞、返回失败,或按系统策略丢弃消息。
从这个角度看,消息队列同样能承担生产者---消费者的角色:生产者 send,消费者 receive,队列容量决定背压如何传递。
3. 按通信对象分类:直接通信与间接通信
直接通信要求进程显式知道对方:
text
send(进程B, message)
receive(进程A, message)
它简单直接,适合固定的点对点关系;缺点是发送方和接收方在身份上紧密耦合,服务端替换、扩缩容或一个发送者面对多个接收者时,管理会更复杂。
间接通信则引入中间实体,例如消息队列、主题或信箱:
text
send(mailbox, message)
receive(mailbox, message)
此时发送者只需知道"投递到哪个信箱",接收者只需知道"从哪个信箱取"。双方不必直接认识彼此,便于解耦、异步处理、广播或组通信。代价是系统还要负责队列持久化、容量、顺序、重复投递和失败恢复等问题。
4. 基于信箱(Mailbox)的编址
信箱可以看作一个由操作系统或运行时管理的消息容器,拥有自己的标识符和容量。典型工作流程是:
- 创建信箱,并获得唯一标识;
- 发送者按信箱标识投递消息;
- 信箱按 FIFO、优先级等策略暂存消息;
- 接收者从信箱取出消息并处理。
按访问关系,信箱可以是:
- 私有信箱:主要为一个进程或服务端点使用,适合严格点对点;
- 共享信箱:多个发送者、多个接收者都可访问,适合任务队列、组通信或发布订阅。
按容量,信箱可以是有限缓冲,也可以在逻辑上动态扩容。所谓"无限容量"只是抽象概念,真实系统始终受内存、磁盘或配额限制。有限容量非常重要,因为它决定了生产速度超过消费速度时怎么办:阻塞发送者形成背压、直接失败、丢弃最新/最旧消息,都是需要在协议中明确的策略。
5. 消息传递的优缺点
消息传递的优势是隔离与解耦:发送者不必直接操作接收者的内存,跨进程、跨机器都能沿用同一模型,也能减少共享可变状态带来的数据竞争。
但它并非没有同步问题。消息复制、内核切换或网络传输带来额外开销;系统还要处理消息排序、队列积压、超时、重复、丢失和消费失败。也就是说,消息传递把"锁保护一块内存"的复杂性,部分转换成了"如何设计可靠通信协议"的复杂性。
三、屏障:不是轮流进入,而是所有人到齐再继续
1. 屏障的概念
**屏障(barrier)**是面向一组线程或进程的同步点。参与者完成当前阶段后调用 barrier_wait:先到的人暂停等待,直到约定数量的参与者全部到达;最后一个到达时,屏障打开,所有等待者一起进入下一阶段。
text
线程1:阶段一完成 ─┐
线程2:阶段一完成 ─┤
线程3:阶段一完成 ─┼── 屏障 ──> 全部进入阶段二
线程4:阶段一完成 ─┘
它特别适合分阶段并行计算,例如矩阵计算的一轮结束后必须等待所有分块结果就绪,才能开始下一轮;又如游戏房间中所有玩家加载完成后才能正式开局。
屏障和互斥锁解决的问题完全不同:
| 原语 | 解决的问题 |
|---|---|
mutex |
同一时刻只允许一个线程进入临界区 |
barrier |
指定的一组线程必须全部抵达某点,才能共同继续 |
互斥锁的直觉是"你先进去,我在外面等你出来";屏障的直觉是"你先到了,也得等所有人到齐"。
2. Pthreads 屏障接口
POSIX 提供了屏障类型和基本接口:
c
pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, participant_count);
/* 每个参与者在阶段结束时调用 */
int rc = pthread_barrier_wait(&barrier);
pthread_barrier_destroy(&barrier);
pthread_barrier_init 的第三个参数不是"创建多少子线程",而是本轮必须调用 pthread_barrier_wait 的参与者总数。如果主线程也要在屏障处等待,那么它同样要计入人数。
一个有趣的细节是:屏障放行时,所有线程都能继续,但其中恰有一个线程的 pthread_barrier_wait 返回 PTHREAD_BARRIER_SERIAL_THREAD,其他线程返回 0。这个特殊返回值不表示该线程更重要,也不保证它先执行;它只是给程序一个机会,让"所有人到齐后只需执行一次"的收尾工作由某一个线程完成。
c
int rc = pthread_barrier_wait(&barrier);
if (rc == PTHREAD_BARRIER_SERIAL_THREAD) {
/* 只由一个通过屏障的线程执行一次 */
summarize_phase_result();
}
你可以把它想象成 4 个人一起跑到集合点:
A ──┐
B ──┤
C ──┤ → 集合点
D ──┘
人到齐后,老师说:
"好了,大家都可以继续走。另外我随机点一个人,帮我把门关一下。"
3. 用互斥锁和条件变量实现屏障时的注意点
屏障的最简思想是用 count 记录到达人数:每个线程到达时加一;最后一个线程广播唤醒所有等待者。但若同一个屏障会被反复使用,只用一个 count 并在归零后广播是不够的:上一轮尚未完全离开时,下一轮线程可能已经到来,导致"串代"。
可重用屏障通常要增加一个**代数(generation)**或阶段编号:等待者记住自己进入时的代数,只有观察到代数已经改变才离开等待。这个细节也说明,屏障虽然概念直观,实现上仍要认真处理可重用性和虚假唤醒。
四、避免锁:从读写锁到读---复制---更新(RCU)
1. 锁机制还能如何优化
互斥锁简单可靠,但在读多写少的场景中可能过于保守:多个读线程只读取同一份数据,彼此并不修改内容,却仍被迫一个接一个地获取锁。
读写锁利用了冲突关系的不对称性:
text
读 + 读 → 可以并发
读 + 写 → 不能并发
写 + 写 → 不能并发
因此它允许多个读者同时持有读锁,而写者需要独占写锁。对于读远多于写的配置数据、缓存元数据等场景,读写锁能明显提高并发度。
但继续追问会发现:若读者可以接受一个稍旧但完整的数据版本,读操作是否连读锁也不必拿?这就引出了乐观读、写时复制、版本号、MVCC 和 RCU 等更激进的设计。
所谓"无锁读"绝不是允许读者随便看写到一半的数据。假设写线程要把:
text
name = 张三, age = 20
更新为:
text
name = 李四, age = 30
若直接逐字段原地修改,读者可能观察到 name = 李四, age = 20 这样的混合状态。高并发读优化真正追求的是:读者可以读旧版本,也可以读新版本,但不能读到不一致的中间版本。
2. RCU 的基本思想
**RCU(Read-Copy Update,读---复制---更新)**是 Linux 内核中广泛使用的一类同步技术,适合读操作极其频繁、写操作相对稀少的数据结构。
其思路可概括为三步:
- Read:读者以极低开销读取当前版本,通常不获取传统互斥锁;
- Copy:写者不直接破坏仍可能被读者访问的旧对象,而是创建新版本或修改副本;
- Update:写者用原子方式发布新版本,让之后的读者看到新对象;旧版本则要等确认没有旧读者仍在使用后,才能回收。
text
读者 A:读取旧版本 ───────────────> 安全完成
写者:复制旧版本 → 修改副本 → 原子发布新版本 → 等待宽限期 → 回收旧版本
新读者 B: 读取新版本 ───> 安全完成
这里的关键是宽限期(grace period):写者不能刚发布新指针就释放旧对象,因为已经开始读取的线程可能还握着旧指针。只有当系统确认所有先前的读侧临界区都已结束,旧版本才可以释放。
RCU 优化的是读路径,而不是让所有写操作都不需要协调。多个写者同时修改同一逻辑结构时,依然常常需要互斥锁、原子操作或其他写侧同步手段来确定写入顺序。它也不承诺读者一定看到最新值,只承诺读者看到的是一个完整、可用的版本。
因此,RCU 最适合:读多写少、读取路径对延迟极敏感、并且数据版本可以共存一段时间的场景。它实现复杂,尤其是内存回收、生命周期和写侧并发控制都需要严格设计;学习阶段抓住"读者近乎无锁,写者复制后发布,旧数据延迟回收"这条主线即可。
总结
从一个双缓冲区案例出发,可以把这些机制放在同一张思维图里:
- 条件变量配合互斥锁,让线程既能安全访问共享数据,又能按条件等待;
- 管程把共享数据、操作接口、互斥与条件变量封装在一起,降低共享内存并发程序的维护难度;
- 消息传递通过内核缓冲区、队列或网络在进程/节点之间交换数据,适合没有共享地址空间的场景;
- 屏障解决的是"所有参与者完成当前阶段后再一起前进";
- 读写锁和 RCU 则从性能角度减少不必要的读侧等待,在读多写少时提升并发度。
选择原语之前,不妨依次问自己:数据是否共享内存?需要的是独占访问、条件等待,还是阶段会合?读者是否必须看见最新值?把问题的协作关系说清楚,才能找到正确的同步工具。
如果这篇文章对你有帮助,欢迎点赞、评论、关注、收藏。你们的支持是我前进的动力!