第13讲 进程间通信

第13讲 进程间通信:共享内存、消息队列与同步语义

本文根据《第13讲 进程间通信.md》的对话转录整理而成。核心问题是:进程间通信 IPC 不只是“两个进程能传数据”,还要解决数据放在哪里、谁来读取、何时等待、如何避免覆盖和读空,以及通信双方是否需要直接认识彼此。


1. 为什么需要进程间通信

进程默认拥有独立的地址空间。一个进程不能随意访问另一个进程的内存,这是操作系统提供隔离性和安全性的基础。

但现实系统中,进程之间经常需要协作。例如:

  • 浏览器进程和渲染进程交换页面数据;
  • shell 通过管道把一个命令的输出交给另一个命令;
  • 服务器主进程把请求分发给 worker 进程;
  • 多个进程共享同一批计算结果;
  • 客户端进程向服务端进程发送请求。

因此,操作系统需要提供 IPC,Inter-Process Communication,即进程间通信机制。

IPC 的核心问题可以分为三类:

  1. 数据如何传递

    • 是放在共享内存中,还是通过内核转发?
  2. 通信双方如何建立关系

    • 是直接指定对方进程,还是通过中间对象通信?
  3. 通信操作如何等待

    • 是同步等待,还是异步返回?

2. IPC 的两条主线:共享内存与消息传递

IPC 机制可以大致分成两类:

类型 基本思想 特点
共享内存 多个进程映射同一块内存区域 快,但同步复杂
消息传递 通过内核或中间对象传递消息 更安全、结构清晰,但开销可能更高

共享内存的优势是速度快。数据放在同一块内存中,进程之间不必每次都通过内核复制大量数据。

但共享内存的困难也很明显:

  • 发送者不能覆盖接收者还没读的数据;
  • 接收者不能读取还没有写入的数据;
  • 多个发送者不能同时写同一个位置;
  • 多个接收者不能重复读或读错位置;
  • 如果一直等待,可能浪费 CPU。

消息传递则把这些问题更多交给内核或消息队列来管理。发送者发送消息,接收者接收消息,通信语义更清晰,但可能需要更多系统调用和数据复制。


本节核心结论: 共享内存追求速度,但需要程序员处理同步;消息传递牺牲一部分性能,换来更清晰的通信结构和更强的隔离性。

3. 共享内存:快,但必须处理同步

共享内存通信的基本模型是:

  1. 操作系统创建或映射一块共享内存区域;
  2. 两个或多个进程都能访问这块区域;
  3. 一个进程向共享区域写入数据;
  4. 另一个进程从共享区域读取数据。

这张图强调:共享内存不是“进程 A 直接进入进程 B 的私有内存”,而是操作系统提供一块双方都能访问的公共区域。

共享内存最大的问题是:

共享数据本身不会自动同步。

也就是说,操作系统可以让两个进程看到同一块内存,但不会自动保证它们读写顺序正确。

例如:

  • 发送者写得太快,可能覆盖接收者还没读的数据;
  • 接收者读得太快,可能读到尚未写入的数据;
  • 多个发送者同时写,可能写到同一位置;
  • 多个接收者同时读,可能重复消费同一数据。

因此,共享内存通常必须配合锁、信号量、条件变量、原子操作等同步机制使用。


4. 环形缓冲区:共享内存中的数据排队方式

环形缓冲区 circular buffer 是共享内存通信中常见的数据组织方式。

它的作用不是直接“解决所有同步问题”,而是解决三个更基础的问题:

  1. 新数据应该写到哪里?
  2. 旧数据应该从哪里读?
  3. 缓冲区什么时候空,什么时候满?

典型结构如下:

1
2
3
item buffer[BUFFER_SIZE];
int in = 0;
int out = 0;

其中:

变量 含义
buffer 共享数据区
in 下一个写入位置
out 下一个读取位置

发送者写入后:

1
in = (in + 1) % BUFFER_SIZE;

接收者读取后:

1
out = (out + 1) % BUFFER_SIZE;

% BUFFER_SIZE 是环形缓冲区的关键。它让下标到达数组末尾后重新回到开头。

例如 BUFFER_SIZE = 10

1
0 -> 1 -> 2 -> ... -> 9 -> 0 -> 1 -> ...

这使得固定大小的数组可以循环复用。


5. 环形缓冲区如何判断空和满

环形缓冲区通过 inout 判断状态。

5.1 缓冲区为空

当:

1
in == out

表示没有新数据可读。

接收者此时不能继续读,否则会读到无效数据。

5.2 缓冲区为满

常见设计中,会空出一个位置来区分“空”和“满”。

当:

1
(in + 1) % BUFFER_SIZE == out

表示再写就会追上接收者,覆盖尚未读取的数据。

发送者此时不能继续写。

这张图说明:环形缓冲区提供了读写位置和空满条件,使发送者和接收者可以围绕这些状态进行协作。


6. 环形缓冲区不能单独完成同步

必须注意:环形缓冲区不是锁,也不是信号量。

它只是提供了数据结构和状态判断条件。真正的同步仍然需要额外机制。

例如,有两个发送者同时执行:

1
2
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;

它们可能同时看到同一个 in,然后都写入同一个位置,造成覆盖。

所以,环形缓冲区只能解决:

  • 数据放哪里;
  • 数据从哪里取;
  • 空和满如何判断;
  • 存储空间如何循环复用。

但它不能单独保证:

  • 多个发送者并发写入一定安全;
  • 多个接收者并发读取一定安全;
  • inout 的更新一定原子;
  • 等待过程不浪费 CPU。

完整的共享内存通信通常应当是:

1
共享内存 + 环形缓冲区 + 同步原语
组成部分 作用
共享内存 让多个进程访问同一块区域
环形缓冲区 管理数据位置、复用空间、判断空满
同步原语 保证并发访问安全,避免忙等或竞争

本节核心结论: 环形缓冲区解决的是共享内存中的“数据排队和空间复用”问题,不是完整的并发同步方案。

7. 忙等问题:为什么不能一直 while

最基础的环形缓冲区实现可能会使用忙等:

1
2
while (((in + 1) % BUFFER_SIZE) == out)
; // 缓冲区满,发送者等待

或:

1
2
while (in == out)
; // 缓冲区空,接收者等待

这种写法逻辑简单,但问题是:等待进程会一直占用 CPU 检查条件。

这叫 busy waiting,忙等。

忙等的问题包括:

  • 浪费 CPU 时间;
  • 降低系统整体吞吐;
  • 对低优先级任务不友好;
  • 在多进程系统中可能放大调度压力。

更合理的做法是使用:

  • semaphore;
  • mutex;
  • condition variable;
  • blocking system call;
  • event notification。

这样,当缓冲区满或空时,进程可以进入阻塞状态,让 CPU 去执行其他任务。条件满足后再被唤醒。


8. 消息传递:通过发送和接收完成通信

与共享内存不同,消息传递不要求进程直接共享地址空间。

它通常抽象为两个基本操作:

1
2
send(message)
receive(message)

消息传递的优势是:

  • 通信结构清晰;
  • 进程隔离性更好;
  • 不需要双方直接共享内存;
  • 更容易跨机器扩展;
  • 内核可以帮助管理队列、权限和阻塞。

缺点是:

  • 可能需要系统调用;
  • 可能发生数据复制;
  • 性能通常不如共享内存直接;
  • 大数据传输成本较高。

消息传递可以进一步分为:

  1. 直接通信;
  2. 间接通信。

9. 直接通信:明确发给某个进程

直接通信 direct communication 的特点是:发送者明确知道接收者是谁。

形式上可以写成:

1
2
Send(P, message)
Recv(Q, message)

含义是:

  • Send(P, message):把消息发给进程 P;
  • Recv(Q, message):从进程 Q 接收消息。

直接通信的关系是:

1
发送者 -> 接收者

它的优点是直观,通信对象明确。

但它也有缺点:

  • 发送者和接收者耦合较强;
  • 发送者必须知道接收者身份;
  • 接收者变化时,发送者可能也要修改;
  • 不适合多个发送者和多个接收者灵活协作的场景。

10. 间接通信:通过中间对象连接双方

间接通信 indirect communication 的核心是:

发送者不是直接把消息发给某个进程,而是发给一个中间对象;接收者也不是直接从某个进程那里取消息,而是从这个中间对象取。

这个中间对象可以是:

  • mailbox;
  • pipe;
  • message queue;
  • channel;
  • topic;
  • broker。

形式上可以写成:

1
2
Send(M, message)
Recv(M, message)

其中 M 是中间对象。

通信关系变成:

1
发送者 -> 中间对象 -> 接收者

这里的“间接”不是说通信一定很慢,也不是说消息绕了很多层,而是说:

通信双方不是靠彼此的进程 ID 建立连接,而是靠共同访问同一个中间对象建立连接。


11. 为什么消息队列是间接通信

消息队列 message queue 是典型的间接通信机制。

它的形式是:

1
发送者 -> 消息队列 -> 接收者

发送者把消息放入队列,接收者从队列取出消息。发送者不一定关心最后由谁接收,接收者也不一定关心消息由谁发送。

它们只需要共享同一个队列,并具有相应权限。

消息队列通常具备以下特征:

特征 含义
队列组织 消息按队列形式保存
FIFO 默认先进入的消息先被读取
消息边界 每条消息是独立单位
类型字段 消息可以带类型
权限控制 只有有权限的进程可以访问
多发送者/接收者 可支持多个进程共同使用

消息队列相比普通字节流更结构化。它传递的是“消息”,而不是单纯的字节序列。

例如,消息可能包含:

1
2
type: 1
data: "request user info"

接收者可以按顺序读取,也可以按类型读取。


本节核心结论: 消息队列之所以是间接通信,是因为发送者和接收者通过共同访问队列建立连接,而不是直接指定彼此通信。

12. 管道与消息队列的区别

管道 pipe 和消息队列都可以作为间接通信方式,但它们的抽象不同。

对比项 管道 Pipe 消息队列 Message Queue
数据形式 字节流 一条条消息
消息边界 通常不保留明确消息边界 保留消息边界
组织方式 固定缓冲区更常见 队列或链表式组织
读取方式 按字节顺序读取 可按顺序或类型读取
通信关系 常用于父子进程或相关进程 可用于多个无关进程
结构化程度 较低 较高

管道更像水流:写进去的是连续字节,读出来也是连续字节。

消息队列更像信箱:每封信有边界,甚至可以带类型。


13. 同步与异步:发起操作后要不要等结果

同步和异步讨论的是:

发起通信操作之后,调用者要不要等待结果。

13.1 同步通信

同步 communication 的特点是:发起操作后等待结果。

例如:

1
Send(message)

如果消息没有成功发出,发送者就等待。

或者:

1
Recv(message)

如果还没有消息到来,接收者就等待。

可以类比为打电话:打过去之后,要等对方接通或回应,才能继续当前事情。

同步的优点是:

  • 逻辑简单;
  • 结果明确;
  • 程序控制流容易理解;
  • 不容易漏处理返回结果。

缺点是:

  • 可能卡住;
  • 对方不回应时,自己无法继续;
  • 大量同步等待会降低并发能力。

13.2 异步通信

异步 communication 的特点是:发起操作后不等待结果,调用者可以继续做别的事情。

例如:

1
AsyncSend(message)

发送请求提交后直接返回。

或者:

1
TryRecv(message)

如果当前没有消息,也不等待,直接返回“没有消息”。

可以类比为发消息:消息发出去后,不必一直盯着对方是否立即回复,可以先去做别的事情。

异步的优点是:

  • 不容易被单个通信操作卡住;
  • 吞吐量更高;
  • 适合高并发;
  • 可以同时处理大量请求。

缺点是:

  • 编程复杂;
  • 需要回调、事件、轮询或状态记录;
  • 错误处理更麻烦;
  • 可能出现消息已发出但未及时处理的情况。

本节核心结论: 同步就是“发起后等待结果”,异步就是“发起后不等待结果”。

14. 阻塞与非阻塞:当前执行流会不会停住

阻塞和非阻塞关注的是:

当前执行流会不会因为某个操作停住。

概念 含义
阻塞 当前执行流停住,直到条件满足
非阻塞 当前执行流不等待,立即返回

在 IPC 中:

  • 阻塞发送:如果消息暂时发不出去,发送者等待;
  • 非阻塞发送:如果消息发不出去,立即返回失败或状态码;
  • 阻塞接收:如果没有消息,接收者等待;
  • 非阻塞接收:如果没有消息,立即返回“无消息”。

在很多操作系统教材中,可以先近似理解为:

通信语义 执行表现
同步 通常表现为阻塞
异步 通常表现为非阻塞

但严格来说,两组概念关注角度不同:

概念组 关注点
同步 / 异步 调用者与结果之间的关系
阻塞 / 非阻塞 当前执行流是否停住

例如,某些异步操作也可能在提交阶段短暂阻塞;某些同步操作也可能通过轮询方式非阻塞地检查结果。因此,在初学阶段可以先记成:

同步就是等,异步就是不等;阻塞就是停住,非阻塞就是不停住。

但进一步学习时要知道:它们不是完全同义词。


15. 超时机制:同步与异步之间的折中

如果一直同步等待,可能永远卡住。
如果完全异步不等,又可能让控制逻辑变复杂。

因此,系统常提供 timeout,超时机制。

形式上可以写成:

1
Send(A, message, Time-out)

含义是:

我愿意等,但最多等 Time-out 这么久。超过时间仍未完成,就返回错误或超时状态。

超时机制相当于一种折中:

模式 行为
无限等待 阻塞 / 同步
完全不等 非阻塞 / 异步
等一段时间 带超时的同步

例如:

1
Recv(message, 5s)

表示最多等待 5 秒。如果 5 秒内收到消息,就正常返回;如果没有收到,就返回超时。

这样既避免了无限卡死,也保留了同步等待的简单控制流。


16. 三个核心问题的统一理解

本讲中的三个重点问题可以统一起来看:

16.1 环形缓冲区回答“数据放哪里”

共享内存只是提供了一块共同区域,但没有规定数据如何排队。环形缓冲区通过 inout 管理写入位置和读取位置,并通过空满条件防止读空和覆盖。

它回答的是:

  • 新数据写在哪里?
  • 旧数据从哪里读?
  • 空间如何循环复用?
  • 什么时候不能写?
  • 什么时候不能读?

16.2 消息队列回答“通信双方如何连接”

消息队列不是让发送者直接找接收者,而是让双方共享一个中间队列。

它回答的是:

  • 发送者是否必须知道接收者?
  • 多个发送者和接收者如何共享通信通道?
  • 消息如何排队?
  • 消息是否有类型?
  • 由谁负责保存未读消息?

16.3 同步/异步回答“调用者是否等待”

同步和异步讨论的是通信操作发起后的控制流问题。

它回答的是:

  • 发起发送后是否要等?
  • 发起接收后是否要等?
  • 没有消息时是否阻塞?
  • 消息发不出去时是否返回?
  • 是否需要超时机制?

这张图展示了 IPC 的三条理解线索:存储位置、连接方式和等待语义。


17. 易混概念整理

易混点 正确理解
环形缓冲区是否等于同步机制 不等于。它是数据组织方式,需要锁、信号量等配合
共享内存是否自动安全 不安全。共享只是能访问,同步还要额外处理
in == out 表示什么 缓冲区为空
(in + 1) % BUFFER_SIZE == out 表示什么 缓冲区满,继续写会覆盖未读数据
消息队列为什么是间接通信 因为通信双方通过共享队列连接,而不是直接指定彼此
直接通信和间接通信区别 直接通信指定进程,间接通信指定中间对象
管道和消息队列区别 管道偏字节流,消息队列保留消息边界和类型
同步和阻塞是否完全一样 不完全一样,但初学可近似理解为同步常表现为阻塞
异步和非阻塞是否完全一样 不完全一样,但初学可近似理解为异步常表现为非阻塞
超时机制属于什么 在无限等待和完全不等之间折中

18. 复习清单

学完本讲后,应能回答:

  • 为什么进程默认不能直接访问彼此内存?
  • IPC 主要解决哪几类问题?
  • 共享内存通信为什么快?
  • 共享内存为什么同步复杂?
  • 环形缓冲区为什么要设计成“环形”?
  • inout 分别表示什么?
  • 如何判断环形缓冲区为空?
  • 如何判断环形缓冲区为满?
  • 环形缓冲区为什么不能单独保证并发安全?
  • 忙等为什么浪费 CPU?
  • 消息传递和共享内存有什么区别?
  • 什么是直接通信?
  • 什么是间接通信?
  • 为什么消息队列是间接通信?
  • 管道和消息队列有什么区别?
  • 同步和异步的核心区别是什么?
  • 阻塞和非阻塞的核心区别是什么?
  • 为什么说同步/异步与阻塞/非阻塞不是完全同一组概念?
  • 超时机制解决了什么问题?

最终总结

进程间通信的核心不是单纯“传数据”,而是围绕三个问题展开:

  1. 数据放在哪里

    • 共享内存把数据放在共同映射区域;
    • 环形缓冲区负责在共享区域中组织数据位置和复用空间。
  2. 通信双方如何连接

    • 直接通信要求发送者明确知道接收者;
    • 间接通信通过消息队列、管道、信箱等中间对象建立联系。
  3. 调用者是否等待结果

    • 同步通信等待结果,逻辑简单但可能卡住;
    • 异步通信不等待结果,并发能力强但编程复杂;
    • 超时机制在二者之间提供折中。

因此,IPC 的学习重点不是背诵机制名称,而是理解每种机制在解决什么问题,又引入了什么新问题。共享内存快,但同步难;消息队列清晰,但可能有额外开销;同步简单,但会等待;异步高效,但控制复杂。真正掌握 IPC,就是能围绕这些取舍判断某个场景下应该选择哪种通信方式。