【操作系统 | 管程、消息通信、屏障与 RCU:并发协作的几种组织方式】

前面学习信号量、互斥锁和条件变量时,注意力往往放在"这一把锁怎么加、那个线程在哪里等待"。但真正写并发程序时,更重要的问题是:共享数据该由谁管理?线程之间靠什么约定先后关系?没有共享内存的进程甚至分布式节点,又该怎样协作?

这一篇沿着"一个同步互斥案例 → 管程 → 消息传递 → 屏障 → 避免读锁"的路径,梳理几种常见的并发协作方式。它们并非彼此替代:有的围绕共享内存,有的刻意避开共享内存;有的保证互斥,有的让一组线程在同一阶段会合。

先看一个同步互斥问题的案例

1. 两个缓冲区、两个任务

假设 task1 从标准输入读入两段字符串,分别写入 buf1buf2task2 则统计两段内容及其长度。需求不是让两个任务各自无限运行,而是要求严格交替:

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 保护 buf1buf2is_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   │
│                                  │
│  进入方法时自动保证互斥            │
└──────────────────────────────────┘
                 ↑
           外部线程只调接口

管程的价值不在于它能神奇地消灭所有并发错误,而在于它把"数据"和"保护数据的规则"放在同一个边界内。相比让调用方在任何地方手写 lockunlockwaitsignal,这种封装更容易维护正确性。

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)等原子指令来保护信号量,可以有效地防止竞争条件的发生

然而, 在分布式系统环境中, 情况复杂得多:

  1. 分布式系统可能包含多个 CPU, 每个 CPU 拥有独立的私有内存, 并通过网络进行通信。
  2. 在这种场景下, 传统的基于共享内存的信号量和管程显得不再适用:
    • 信号量虽然提供了基本的同步机制, 但其操作级别较低, 难以直接应用于分布式环境。
    • 管程由于语言支持的限制, 在大多数分布式编程场景中无法使用, 尤其因为管程强依赖于共享内存和特定语言环境。

在分布式系统中解决互斥和同步问题的高级方法

为了适应分布式系统的特性, 需要采用更高级和灵活的机制, 例如:

  1. 分布式锁 :
    • 使用分布式协调工具(如 Zookeeper 或 Redis)实现锁的获取和释放, 保证多个节点间对共享资源的互斥访问。
  2. 事务性内存 (Transactional Memory):
    • 提供一种乐观的并发控制机制, 通过事务提交和回滚确保操作的原子性和一致性。
  3. 基于消息传递的并发模型 :
    • 通过消息队列(如 RabbitMQ、Kafka)或 Actor 模型(如 Akka)来管理节点之间的通信和同步, 无需依赖共享内存。

二、消息传递:不共享内存,也能通信与同步

1. 为什么进程更需要消息传递

同一进程内的线程拥有相同的地址空间,能通过全局变量或堆内存共享数据;但不同进程各自拥有独立的虚拟地址空间,不能直接读写对方的普通变量。即使两个进程里都出现相同的虚拟地址,它们通常也映射到不同的物理页。

进程间当然可以借助共享内存,但共享内存又会把问题带回"如何互斥访问"上。另一条路线是让操作系统或网络负责搬运数据:发送方把内容组织为消息,接收方从内核缓冲区、消息队列或网络连接中取出。这就是消息传递(message passing)

最抽象的接口可以写成:

text 复制代码
send(destination, message)
receive(source, message)

一条消息既可以承载业务数据,也可以承载控制含义。例如"任务已完成""请执行某项请求""缓冲区有一项数据"等。消息因此不仅能传输数据,也能天然表达同步事件。

管道、消息队列、套接字、RPC 等都可用于消息传递,但它们的寻址方式、缓冲能力和边界语义并不相同。比如字节流管道和 TCP 需要应用层约定消息边界:可以使用固定长度记录,或在协议头中携带长度字段,避免发生粘包/拆包后的解析歧义。

2. 按同步方式分类

消息传递可以是同步的,也可以是异步的。

类型 发送方 接收方 特点
同步通信 发送后可能等待接收方接收或确认 收不到消息时等待 时序明确,双方耦合更强
异步通信 消息进入缓冲区后即可继续 可在稍后接收 解耦更强,需要队列与容量管理

极端的同步通信称为会合(rendezvous):sendreceive 必须同时配对,任何一方先到都等待另一方。异步通信则依赖消息缓冲区;缓冲区为空时,接收者可能阻塞,缓冲区满时,发送者可能阻塞、返回失败,或按系统策略丢弃消息。

从这个角度看,消息队列同样能承担生产者---消费者的角色:生产者 send,消费者 receive,队列容量决定背压如何传递。

3. 按通信对象分类:直接通信与间接通信

直接通信要求进程显式知道对方:

text 复制代码
send(进程B, message)
receive(进程A, message)

它简单直接,适合固定的点对点关系;缺点是发送方和接收方在身份上紧密耦合,服务端替换、扩缩容或一个发送者面对多个接收者时,管理会更复杂。

间接通信则引入中间实体,例如消息队列、主题或信箱:

text 复制代码
send(mailbox, message)
receive(mailbox, message)

此时发送者只需知道"投递到哪个信箱",接收者只需知道"从哪个信箱取"。双方不必直接认识彼此,便于解耦、异步处理、广播或组通信。代价是系统还要负责队列持久化、容量、顺序、重复投递和失败恢复等问题。

4. 基于信箱(Mailbox)的编址

信箱可以看作一个由操作系统或运行时管理的消息容器,拥有自己的标识符和容量。典型工作流程是:

  1. 创建信箱,并获得唯一标识;
  2. 发送者按信箱标识投递消息;
  3. 信箱按 FIFO、优先级等策略暂存消息;
  4. 接收者从信箱取出消息并处理。

按访问关系,信箱可以是:

  • 私有信箱:主要为一个进程或服务端点使用,适合严格点对点;
  • 共享信箱:多个发送者、多个接收者都可访问,适合任务队列、组通信或发布订阅。

按容量,信箱可以是有限缓冲,也可以在逻辑上动态扩容。所谓"无限容量"只是抽象概念,真实系统始终受内存、磁盘或配额限制。有限容量非常重要,因为它决定了生产速度超过消费速度时怎么办:阻塞发送者形成背压、直接失败、丢弃最新/最旧消息,都是需要在协议中明确的策略。

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 内核中广泛使用的一类同步技术,适合读操作极其频繁、写操作相对稀少的数据结构。

其思路可概括为三步:

  1. Read:读者以极低开销读取当前版本,通常不获取传统互斥锁;
  2. Copy:写者不直接破坏仍可能被读者访问的旧对象,而是创建新版本或修改副本;
  3. Update:写者用原子方式发布新版本,让之后的读者看到新对象;旧版本则要等确认没有旧读者仍在使用后,才能回收。
text 复制代码
读者 A:读取旧版本 ───────────────> 安全完成

写者:复制旧版本 → 修改副本 → 原子发布新版本 → 等待宽限期 → 回收旧版本

新读者 B:                         读取新版本 ───> 安全完成

这里的关键是宽限期(grace period):写者不能刚发布新指针就释放旧对象,因为已经开始读取的线程可能还握着旧指针。只有当系统确认所有先前的读侧临界区都已结束,旧版本才可以释放。

RCU 优化的是读路径,而不是让所有写操作都不需要协调。多个写者同时修改同一逻辑结构时,依然常常需要互斥锁、原子操作或其他写侧同步手段来确定写入顺序。它也不承诺读者一定看到最新值,只承诺读者看到的是一个完整、可用的版本。

因此,RCU 最适合:读多写少、读取路径对延迟极敏感、并且数据版本可以共存一段时间的场景。它实现复杂,尤其是内存回收、生命周期和写侧并发控制都需要严格设计;学习阶段抓住"读者近乎无锁,写者复制后发布,旧数据延迟回收"这条主线即可。

总结

从一个双缓冲区案例出发,可以把这些机制放在同一张思维图里:

  • 条件变量配合互斥锁,让线程既能安全访问共享数据,又能按条件等待;
  • 管程把共享数据、操作接口、互斥与条件变量封装在一起,降低共享内存并发程序的维护难度;
  • 消息传递通过内核缓冲区、队列或网络在进程/节点之间交换数据,适合没有共享地址空间的场景;
  • 屏障解决的是"所有参与者完成当前阶段后再一起前进";
  • 读写锁和 RCU 则从性能角度减少不必要的读侧等待,在读多写少时提升并发度。

选择原语之前,不妨依次问自己:数据是否共享内存?需要的是独占访问、条件等待,还是阶段会合?读者是否必须看见最新值?把问题的协作关系说清楚,才能找到正确的同步工具。

如果这篇文章对你有帮助,欢迎点赞、评论、关注、收藏。你们的支持是我前进的动力!

相关推荐
Canmag 兴隆磁性2 小时前
C 系列充磁机
c语言·开发语言·学习·永磁材料·充磁·退磁
新手unity自用笔记3 小时前
unity基于Socket的网络学习
网络·网络协议·学习·unity·c#·游戏引擎
zyf1044163 小时前
暑期实践日志 Day32:完成第六章字幕添加,推进工作
学习·计算机网络·剪辑·暑期实践·课题任务
小雪崩3 小时前
嵌入式学习 day27:标准IO
linux·c语言·学习
hsjiasb4 小时前
FreeRTOS学习(三十五)——系统架构综合项目
学习·学习笔记·freertos
YM52e4 小时前
语言地区选择器-鸿蒙ArkTS国际化页面设计
学习·华为·harmonyos
不是光头 强4 小时前
Java 后端 AI 技术选型与学习路线
java·人工智能·学习
cloudification4 小时前
virtual
学习
MartinYeung55 小时前
[论文学习]δ-STEAL:基于本地差分隐私的大语言模型窃取攻击
网络·学习·语言模型