进程的通信

进程之间为什么需要专门的”通信”手段?因为每个进程有自己独立的地址空间,进程 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”。没有附加信息,没有参数,没有正文。

它是异步的。进程不知道信号什么时候会来,也没有在某个地方”等”它。信号随时可能到达。

处理流程分三步:

  1. 发送:内核(或另一个有权限的进程)把信号”挂”到目标进程的 PCB 上——具体说是在 PCB 里的一个位图中把对应的位置 1。此时进程可能正在运行,也可能正在阻塞,信号只是被记下了,还没被处理。
  2. 检测:进程在某个时刻检查这个位图,发现有未处理的信号。
  3. 处理:执行默认动作(终止、忽略、暂停),或者执行进程事先注册的信号处理函数。

三种处理方式:执行默认操作、捕捉并执行自定义处理函数、忽略。但 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 间接:看要不要指名收件人(这条线只在消息传递内部适用)

别把”间接通信”误当成”低级通信”。

对照速查

共享存储消息传递管道
速度最快慢(要经内核两次拷贝)中
同步谁管进程自己OSOS
数据量大中(格式化消息)受缓冲区大小限制
方向双向双向半双工
数据读走后还在移除消失
典型接口shmget/shmatsend/receivepipe、shell 的 |
分类线判据两端
低级 / 高级一次能传多少信号量、信号 / 共享存储、消息传递、管道
直接 / 间接要不要指名收件人send(P2,msg) / 信箱

考点

  • 低级 / 高级的判据是数据量,不是”用了什么数据结构”
  • 共享存储不提供互斥,管道自动提供(最高频的一对)
  • 管道半双工、数据读走即消失
  • 信号不传数据,SIGKILL/SIGSTOP 不可捕捉不可忽略
  • 信号在从内核态返回用户态之前被检测

链接