进程的通信
进程之间为什么需要专门的”通信”手段?因为每个进程有自己独立的地址空间,进程 A 里的指针拿到进程 B 里毫无意义。这道隔离墙是 OS 为了保护而刻意建起来的,所以想跨过去交换数据,就必须由 OS 开一个受控的口子。
本节要讲清三种口子:共享存储、消息传递、管道。
机制
先把”低级 / 高级”这条分类线搞对
疑问点:"基于数据结构"与"基于存储区"分别指什么
教材称低级通信方式”基于数据结构”,高级通信方式”基于存储区”。 此处的”数据结构”是指某种接口或组织方式吗? “存储区”是否就是一片内存空间,其中的数据格式由两个进程自行约定?
这句话是全节最容易读歪的地方,症结在”基于数据结构”这五个字。它听起来像在说”用什么数据结构实现”,实则不然。这条分类线的真正判据是:一次能传递多少信息。
低级通信:只能传递极少量的控制信息——本质上是”发生了某件事”这一个信号量级的语义。
- 例子:信号量的 P/V 操作、信号(signal)
- 特点:效率低(一次传不了几个 bit),且对用户不透明——数据的传送要程序员自己安排,OS 只提供最原始的机制
高级通信:能传递大批量数据,OS 把过程封装成好用的接口,用户调一下就行。
- 例子:共享存储、消息传递、管道
- 特点:用户不必关心底层怎么搬的
那么”基于数据结构”究竟指什么?它指的是低级通信所传递的东西本身就是一个很小的数据结构(例如一个整型信号量的值),信息量被这个结构的大小卡死。而”基于存储区”指的是高级通信直接划出一片存储区,其中放多少数据由进程自行决定。
据此回答上述两个追问:
- “数据结构”并非接口。它就是字面意义上的一个小数据结构(一个计数器、一个标志位),其容量即通信容量的上限。
- “存储区由两个进程自行约定”这一理解完全正确,并且点破了共享存储的本质——操作系统只负责把这片内存交给双方,至于其中数据如何排列、谁先写谁后读,完全由进程自行约定。 这正是下面要展开的重点。
一句话记法:低级看”能不能传个信号”,高级看”能不能传一大块数据”。
三种高级通信方式
flowchart TB subgraph SM["① 共享存储 最快,OS 不管同步"] direction LR A1(进程 A) <--> M["共享内存区<br/>(双方地址空间都映射到<br/>同一块物理内存)"]:::mem M <--> B1(进程 B) end subgraph MP["② 消息传递 OS 全程代劳"] direction LR A2(进程 A) -->|"send(消息)"| K["OS 内核<br/>消息缓冲 / 信箱"]:::os K -->|"receive(消息)"| B2(进程 B) end subgraph PP["③ 管道 半双工的内核缓冲区"] direction LR A3(写进程) -->|"写满则阻塞"| P["管道<br/>固定大小的内核缓冲区<br/>数据读走即消失"]:::os P -->|"读空则阻塞"| B3(读进程) end classDef mem fill:#dbeafe,stroke:#2563eb,color:#1e3a5f classDef os fill:#fef3c7,stroke:#d97706,color:#78350f
① 共享存储
两个进程各自把同一块物理内存映射进自己的虚拟地址空间。之后 A 往那里写,B 直接就能读到——不经过内核搬运,所以是三种里最快的。
代价是:OS 不负责互斥。A 写到一半被切走,B 读到的就是半截数据。同步必须由进程自己用 P/V 或锁去做。这是共享存储最重要的考点。
疑问点:按 PID 挂载并修改进程内存,是否属于共享存储
共享存储的典型实例是什么? 通过进程 PID 挂载到目标进程并修改其内存(如游戏修改器的做法),是否属于共享存储? 其中标记为只读的内存区域,是否可以通过提升权限来修改?
边界辨析:共享存储与
ptrace式内存访问游戏修改器按 PID 挂载并修改内存这一做法,走的不是共享存储,而是另一条路径。二者的区别恰好可以照亮共享存储的边界。
修改器使用的是 Linux 的
ptrace或 Windows 的ReadProcessMemory这类调试接口。其语义是”在具备权限的前提下读写另一个进程的地址空间”,属于单向侵入,被修改的进程既不知情也未同意。共享存储则是双方合意的:两个进程都主动调用
shmget/shmat映射同一块内存,事先约定了共享关系。至于”只读区域提权即可修改”,这一判断大体成立,但机制需要分清:只读属性由页表中的权限位保护,而页表位于内核空间。因此并非”提权就能读写”,而是具备足够权限的进程可以请求内核修改页表权限位或代为写入。用户态程序自身无法绕过 MMU。
判据:共享存储需双方合意;
ptrace是单方面侵入且需要权限。 考试只涉及前者。
教材还把共享存储细分为两种,这也回应了上面那条分类线:
- 基于共享数据结构:OS 提供一个固定的数据结构(如一个有界缓冲区),进程往里放。容量小、效率低,属于低级通信。
- 基于共享存储区:OS 划出一大片内存,进程随便用。属于高级通信。
② 消息传递
进程之间不直接碰对方的内存,而是把数据打包成格式化的消息(消息头 + 消息体),通过 send / receive 两个原语交给 OS,由 OS 负责送达。
按”要不要指名收件人”分成两类:
- 直接通信:
send(P2, msg),消息直接挂到目标进程 P2 的消息缓冲队列上。必须知道对方是谁。 - 间接通信(信箱通信):
send(信箱A, msg),消息发到一个信箱里,谁来取都行。收发双方不必知道对方,也不必同时在场。
疑问点:信箱属于哪一类通信方式
信箱属于消息传递中的间接通信,是高级通信。它与直接通信的区别即上文所述:中间多了一个独立于收发双方的实体(信箱),双方由此解耦。
疑问点:消息传递与消息队列(MQ)是否等同
关联对照:消息传递与消息队列(MQ)
这一对应关系相当准确。MQ(RabbitMQ、Kafka 等)即间接通信在工程上的放大版:
教材 MQ 信箱 queue / topic sendproducer 发消息 receiveconsumer 消费 收发双方不必同时在场 生产者和消费者可以异步、可以离线 对应关系的失效处:MQ 通常跨机器部署,具备持久化、重试与确认机制;教材中的信箱是同一台机器内、由操作系统内核维护的内存结构,不具备这些工程特性。考试只涉及”解耦”这一层语义。
③ 管道
管道是一个固定大小的内核缓冲区,被当作一个特殊文件来读写。它有三个硬性质,全是考点:
半双工——同一时刻数据只能单向流。要双向就得开两条管道。
同步互斥由 OS 自动完成——这一条和共享存储正好相反,别记混了。写满了写进程自动阻塞,读空了读进程自动阻塞。
数据是流式的、一次性的——从管道读走的数据就从缓冲区里消失了,不能像共享内存那样反复读同一个位置。
疑问点:管道是否支持多个读端或写端
管道是否允许多个进程同时写入或同时读取?
docker logs可以同时查看多个容器的日志、tee可将一份输入写往多个目标,二者是否依赖管道的多读能力? shell 的重定向>与管道|又有何区别?
能否多读多写:技术上允许多个进程持有同一管道的读端或写端,但语义上会出问题——多个读者读同一管道时,一份数据只会被其中一个读到(先取先得),不会每人一份。因此标准用法仍是一写一读。
考试层面记住:管道的数据被读走就没了,不存在”广播”语义。
边界辨析:
docker logs、tee与 shell 重定向这三个例子恰好可以把边界钉死,且三者机制各不相同:
docker logs查看多个容器:这不是一个管道被多方读取,而是每个容器各有独立的日志流,用户打开了多条独立的读取通道。是并行的通道,不是共享的管道。
tee:它之所以能把一份输入送往多个去处,靠的是在用户态将数据复制为 N 份再分别写入 N 个目标。这恰好反证了管道本身不具备广播能力——若管道能广播,tee便无存在必要。重定向
>与管道|:|正是匿名管道的直接体现,ls | grep x中 shell 创建一个管道,将ls的 stdout 与grep的 stdin 接通。>则是把 stdout 替换为一个文件,走的是文件系统而非管道。判据:管道是一根水管,不是一个广播站。需要多份数据,必须自行复制。
信号:先只看 OS
疑问点:信号的定义被类比淹没
此处曾连续两次要求”只讲操作系统层面的信号”。 原因在于先前的讲解将 JS 事件循环、Qt signal 与 OS 信号混在一处,导致 OS 信号自身的定义未能先立住。
因此这里调整顺序:先把操作系统的信号单独讲完,不掺任何类比。
OS 的信号,是内核发给进程的一种异步通知。
它只做一件事:告诉进程”发生了某一类事件”。就这么多。
它不携带数据。信号的全部内容就是一个编号(SIGKILL 是 9,SIGINT 是 2)。进程收到 9 号,就知道”我该被杀了”;收到 2 号,就知道”用户按了 Ctrl+C”。没有附加信息,没有参数,没有正文。
它是异步的。进程不知道信号什么时候会来,也没有在某个地方”等”它。信号随时可能到达。
处理流程分三步:
- 发送:内核(或另一个有权限的进程)把信号”挂”到目标进程的 PCB 上——具体说是在 PCB 里的一个位图中把对应的位置 1。此时进程可能正在运行,也可能正在阻塞,信号只是被记下了,还没被处理。
- 检测:进程在某个时刻检查这个位图,发现有未处理的信号。
- 处理:执行默认动作(终止、忽略、暂停),或者执行进程事先注册的信号处理函数。
三种处理方式:执行默认操作、捕捉并执行自定义处理函数、忽略。但 SIGKILL(9) 和 SIGSTOP(19) 不能被捕捉也不能被忽略——否则就没有任何手段能强制杀死一个流氓进程了。
以上是 OS 信号的全部。读到这里如果清楚了,下面的类比再看;不清楚就先停在这里。
疑问点:
kill -9是否就是在发送信号
是。而且 kill 这一命令名具有相当的误导性——它的动作不是”杀死进程”,而是”发送信号”。kill -9 PID 即向该进程发送 9 号信号(SIGKILL),kill -2 发送 2 号,kill -l 可列出全部信号。只因 9 号信号的默认动作是强制终止,才被习惯性地称作”杀”。
疑问点:为何只在用户态与内核态切换时检查信号
准确的说法是在从内核态返回用户态之前检查。原因有两点,都很实际:
第一,检查信号这一动作本身必须在内核态执行。 信号位图位于 PCB 中,而 PCB 在内核空间,用户态程序无法访问。只有当 CPU 已处于内核态时,才有资格读取该位图。
第二,这个时机是”免费”的。 若要求信号一到就立即处理,内核就必须设法打断正在用户态执行的进程,这需要额外的中断与切换开销。而进程本就会因系统调用、时钟中断、缺页等原因频繁进入内核,内核只需在每次返回用户态之前顺带检查一次位图,几乎不增加成本。
代价是信号处理存在延迟——从信号挂载到被处理,间隔取决于进程下一次进出内核的时机。这正是”异步通知”中”异步”二字的实际含义。
疑问点:事件循环与信号是什么关系
事件循环与操作系统的信号之间是否存在联系? UI 监听机制所传递的是否也是信号?以 JS 或 Qt 为例又当如何理解?
层次辨析:OS 信号、Qt signal 与 JS 事件
这三者正是先前造成混淆的根源。它们名称相近,所处层次却完全不同:
谁发 谁收 在哪一层 OS 信号 内核 进程 内核 ↔ 进程,跨地址空间 Qt signal/slot 对象 同进程内另一个对象 纯用户态,同一进程内的函数调用 JS 事件 + 事件循环 浏览器运行时 回调函数 纯用户态,单线程内的任务队列 关键区别:只有 OS 信号跨越了进程边界并涉及内核。 Qt 的 signal 本质上是一次(可能排队的)函数调用,JS 的事件则是向任务队列压入一个回调——二者都发生在单个进程内部,内核对此一无所知。
三者唯一的共同点是编程模型上都表现为”异步通知加回调”,名称因此撞车。
但有一处确实相连:浏览器与 Qt 的事件循环在底层调用
epoll/select等待 I/O,这些系统调用会使进程阻塞在内核,I/O 就绪时由内核唤醒。这一层是真正的操作系统机制。上层的”事件”属于用户态概念,下层的”唤醒”才是操作系统概念。考试只涉及 OS 信号。此处列出 Qt 与 JS,目的是使三者各归其位,不再互相干扰。
边界
谁负责同步,是区分共享存储和管道的关键
这一对最容易记反:
- 共享存储:OS 不管同步,进程自己用 P/V 或锁
- 管道:OS 自动管同步,写满阻塞、读空阻塞
背后的道理:共享存储只是把内存映射给双方,操作系统随即撒手,它并不知道其中放置了什么结构;管道则是操作系统自行实现和维护的缓冲区,自然清楚它是满是空。
三种方式的数据留存语义
- 共享存储:数据写在那里,可以反复读,直到被覆盖
- 消息传递:消息被
receive之后从队列移除 - 管道:数据被读走立即消失
低级 / 高级 与 直接 / 间接 是两条独立的线
- 低级 vs 高级:看能传多少数据
- 直接 vs 间接:看要不要指名收件人(这条线只在消息传递内部适用)
别把”间接通信”误当成”低级通信”。
对照速查
| 共享存储 | 消息传递 | 管道 | |
|---|---|---|---|
| 速度 | 最快 | 慢(要经内核两次拷贝) | 中 |
| 同步谁管 | 进程自己 | OS | OS |
| 数据量 | 大 | 中(格式化消息) | 受缓冲区大小限制 |
| 方向 | 双向 | 双向 | 半双工 |
| 数据读走后 | 还在 | 移除 | 消失 |
| 典型接口 | shmget/shmat | send/receive | pipe、shell 的 | |
| 分类线 | 判据 | 两端 |
|---|---|---|
| 低级 / 高级 | 一次能传多少 | 信号量、信号 / 共享存储、消息传递、管道 |
| 直接 / 间接 | 要不要指名收件人 | send(P2,msg) / 信箱 |
考点
- 低级 / 高级的判据是数据量,不是”用了什么数据结构”
- 共享存储不提供互斥,管道自动提供(最高频的一对)
- 管道半双工、数据读走即消失
- 信号不传数据,
SIGKILL/SIGSTOP不可捕捉不可忽略 - 信号在从内核态返回用户态之前被检测
链接
- 🏠 返回总览:操作系统第 2 章:进程与线程总览
- ⬅️ 上一节:2.1.4 进程控制
- ➡️ 下一节:2.1.6 线程和多线程模型
- 🔗 同步互斥的工具在 2.3.4 信号量(共享存储要用它自己做互斥)
- 📖 名词库:第 2 章名词库