第八章 进程间通信
第八章 进程间通信
进程间通信(Inter-Process Communication, IPC)是多进程协作的基础。
类 UNIX 系统中使用许多功能独立且完整的程序(进程)组合来完成复杂的任务。
多个实现不同功能的进程彼此协作的方式主要有以下三个优势:
- 将功能模块化,避免重复造轮子:将一些共性功能提出来,让不同的进程调用。避免了,每一个需要用到这些功能的进程在进程内部重新实现。
- 增强模块间的隔离,提供更强的安全保障:把应用的所有模块放在一个进程里,虽然内部交互较为方便,但是隔离性相对较弱,只要攻击者控制其中任何一个模块,就可以进而攻击其他模块。相反,如果将应用的模块分别部署到不同的进程中,如果其中一个进程受到了攻击,也只是危害者一个进程中的计算任务,其他进程在做好接口检查的前提下,仍然能保证敏感代码和数据的安全。:
- 提高应用的容错能力:和隔离性类似,如果将所有的数据和代码都在同一个进程地址空间内,那么任何一个运行错误都可能导致整个应用的崩溃。
8.1 进程间通信基础
进程间通信至少需要两方参与。根据信息流动的方向,这两方通常被称为发送者和接收者。
在实际使用中,IPC 经常被用于服务调用,因此参与 IPC 的两方又被称为调用者和被调用者,或者客户端和服务端。
8.1.1 进程间通信接口
从操作系统抽象看进程间通信。虚拟内存的隔离性限制了两个进程的直接交互和通信。为了交互,必须需要开启一个信道。
信道的设计又多种选择:从内核是否介入角度,信道可以在用户态(内核不介入或者很少介入,如共享内存),也可以在内核态(内核介入,如管道);从操作系统接口的角度,信道可以是内存接口(如共享内存),也可以是文件接口(如管道),甚至是新的接口(如消息接口)。

消息接口。除了传统的内存、文件等接口之外,操作系统通常还会提供消息接口用于进程间通信。
消息接口将数据抽象成一个个的消息在两个(或多个)进程间传递。
消息接口通常包括:
- 发送消息:Send(message)
- 接收消息:Recv(message)
- 远程方法调用:RPC(req_message, resp_message)
- 回复消息:Reply(resp_message)
发送者可以通过 Send 将一个消息发送给接收者,接收者会使用 Recv 来接收该消息
远程方法调用(Remote Procedure Call, RPC)通常可以理解成(发送端)调用 Send 接口后紧接着调用 Recv 接口。
Reply 通常用作回复远程方法调用。
这些操作接口可以是操作系统提供的,也可以是用户态库封装提供的。
8.1.2 一个简单的进程间通信设计
本节简单 IPC 只设计两个进程:发送者进程和接收者进程。通信的过程由发送者发起,将一段定长的护具发送给接收者,之后发送者会等待返回数据(即 RPC 接口)。
IPC 通常包含两个阶段:
- 准备阶段:需要在通信的进程间建立一个通信连接,这个通信连接是打通两个隔离的进程的信道。
- 通信阶段:
- 数据传递
- 通信机制
通信连接。假设内核已经为两个进程映射了一段共享内存。共享内存打破了进程之间的地址空间隔离,使两个进程有了一个可以交换数据的缓冲区。基于共享内存,简单 IPC 方案的通信连接是在建立共享区域的一瞬间完成的。
数据传递:由于简单 IPC 使用消息接口,其通信数据抽象是消息(Message)。发送者将数据以消息的格式传递给接收者,接收者将结果数据同样以消息的格式返回给发送者。
消息包含如下内容:
- 头部(Header):
- 魔数(Magic Number)
- 长度
- 状态
- 等
- 数据内容(Payload)
通信过程与通知机制。通信过程如下所示

开始时,两个消息的状态都是无效的,接收者不断轮询发送者消息的状态信息。当开始 IPC 事,发送者会将要传输的数据内容拷贝到发送者消息上,然后依次设置消息的头部字段,最后一步是将状态设置为“准备就绪”,之后开始轮询接收者消息。一直在轮询的接收者一旦观测到发送者消息的状态为“准备就绪”,就表示发送者发出了一个消息。接收者在读取发送者的消息后,将发送者消息的状态设置为“无效”。此后,接收者根据消息中的数据处理请求,将返回数据写入接收者消息,设置消息的头部字段,最后将消息状态设置为“准备就绪”。当发送者观测到接收者消息的状态为“准备就绪”后,即表示收到了返回的结果。类似地,发送者读取消息后,需要将接收者消息的状态设置为“无效”。这就完成了一次简单 IPC 的通信发起和回复过程。
在上述过程中,简单 IPC 采用轮询的方式作为通知机制。
8.1.3 数据传递
基于共享内存的数据传递
特点:操作系统在通信过程中不干预数据传输。
操作系统辅助的数据传递
操作系统辅助的数据传递指内核为用户态提供通信的接口,如 Send 和 Recv 等,直接使用这些接口,将数据传递给另一个进程,而不需要建立共享内存和轮询内存数据等操作。
共享内存和操作系统辅助传递的对比
从数据传递性能来看:
-
使用共享内存的方式,可以实现理论上零内存拷贝的传输。
-
使用操作系统辅助传递的方式,则通常需要两次内存拷贝:一次是将数据从发送者用户态内存拷贝到内核内存,另一次是从内核内存拷贝到接收者用户态内存。不过,两次拷贝并非绝对必要的,可以利用内存重映射技术,就能够做到一次拷贝。
操作系统辅助传递同样有优于共享内存的地方:
- 操作系统辅助传递的抽象更加简单。
- 操作系统辅助传递的安全性保证通常更强,并且不会破坏发送者和接收者进程的内存隔离性。
- 在多方通信时,在多个进程间共享内存区域是复杂且不安全的,而操作系统辅助传递可以避免此问题。
8.1.4 通知机制
通知机制:在新的数据(消息)到来时通知通信的接收方
在前面介绍的简单 IPC 方案中,进程依赖于轮询内存数据来检查是否有消息到来,但是这样会浪费大量系统 CPU 计算资源。
操作系统支持的 IPC 方案中,内核通常会基于控制流转移来实现通知机制。
IPC 中的控制流转移,通常是利用内核对于进程的运行状态和运行时间的控制来实现的。
常见过程:
- 接收者进程初始化之后将自己阻塞起来等待消息的到来(如执行阻塞的 Recv)
- 发送者进程发起通信(RPC)
- 在处理该操作时,内核首先将发送者发送的消息传递给接收者,然后让发送者进程进入阻塞状态(等待接受者进程回复消息),并将接收者进程从阻塞状态中唤醒到可运行状态。
- 对接受者进程而言,会看到阻塞的 Recv 返回了一个消息,表明接收到了来自发送者的消息。
8.1.5 单向和双向
IPC 通常包含三种可能得方向:仅支持单向通信、仅支持双向通信、单向和双向通信均可。

单向通信:只能从一端到另一端
双向通信:允许双方互发消息
单 / 双向均可:根据具体的配置判断是否支持单向或双向的通信
8.1.6 同步和异步
同步 IPC 指 IPC 操作(如 Send)会阻塞进程直到该操作完成。
异步 IPC 则通常是非阻塞的,进程只要发起一次操作即可返回,不需要等待其完成。
同步 IPC 的 RPC 可以看成一个线性的控制流:调用者发起 RPC 请求,然后控制流切换到别调用者。被调用者处理请求是,调用者处于阻塞状态。当被调用者执行完任务后,控制流会切回调用者中。调用者得到返回的结果后才可以继续执行。
异步 IPC 是多个并行的控制流,当调用者发起 IPC 后,被调用者接收到通信的数据和请求后开始响应。同时,调用者的 IPC 调用不会等待被调用者执行,而是直接返回。异步 IPC 通常通过轮询内存状态或注册回调函数来获取返回结果。
8.1.7 超时机制
进程间的隔离性为通信带来的一个问题是:通信的双方很难确认对方的状态。
为了解决上述问题,IPC 的设计中引入了超时机制。
允许调用者/被调用者指定它们发送/接收请求的等待时间。
然而,在实际情况中,大部分进程很难决定合理的超时时间。
目前内核常常引入两个特殊的超时选择:“阻塞”和“立即返回”。
阻塞就是和引入超时机制之前的机制是类似地,就是死等,直到等到消息。
立即返回则意味着只有当前被调用者处于可以立即响应的状态时才会真的发起通信,否则立即返回。
8.1.8 通信连接
IPC 的通信连接一般分为两类:
- 直接通信
- 间接通信
直接通信是指通信的进程一方需要显示地标识另一方。直接通信下连接的建立是自动的,在具体交互时通过标识的名字完成。
间接通信需要经过一个中间信箱来完成通信。每个信箱有自己唯一的标识符,而进程间通过共享信箱来交换消息,即进程间连接的建立发生在共享信箱时。
8.1.9 权限检查
进程间的通信通常依赖于一套权限检查机制来保证连接的安全性。
微内核系统中常用基于 Capability 的安全检查机制,而在如 Linux 这样的宏内核系统中,通常会见安全检查机制和文件的权限检查结合在一起处理。
Capability 机制会将所有的通信连接抽象为一个内核对象,而每个进程对于内核对象的访问权限由 Capability 来刻画。当一个进程企图和某其他进程通信时,内核会检查该进程是否拥有 Capability,是否有足够的权限访问一个连接对象并且对象是指向目标进程的。
宏内核通常会复用其有效用户 / 有效组的文件的权限,从而刻画进程对于某个连接的权限。
System V 进程间通信权限管理
System V 进程间通信通常指宏内核下三种具体的进程间通信机制:即 System V 消息队列、System V 信号量和 System V 共享内存。在 Linux 中,这三种通信机制共享一套权限管理方法。
权限检查机制通常依赖于进程的用户分类(所有者用户 / 用户组用户 / 其他用户),并且是基于文件的权限抽象(可读 \ 可写 \ 可执行)来判定的。
每个文件都会存储一个根据三类用户以及三种可能得权限组合而成的访问模式(Mode)。
将上述权限检查机制应用到进程间通信场景时,内核会将通信连接抽闲成一个个具体对象。这样,只要 Linux 内核能够为每个通信的对象准备一个类似文件中的“访问模式”,就可以根据它来检查进程的操作是否合法。
这个“访问模式”的内容,就是 Linux 内核中负责进程间通信权限管理的 IPC_PERM 结构。
IPC_PERM 结构代码片段
struct ipc_perm {
key_t key;
uid_t uid; // 所有者的 uid 和 gid
gid_t gid;
uid_t cuid; // 创建者的 uid 和 gid
gid_t cgid;
mode_t mode; // 访问模式
}
该结构中的 key 是 System V IPC 对象的标识符。进程可以根据 key 来索引 IPC 对象(或 IPC 连接)
8.1.10 命名服务
检查机制的引入保证了安全性。但另一个问题是,如何分发权限呢?通常权限的分发会通过一个用户态的服务——命名服务(Naming Service)。
什么是命名服务?
命名服务像是一个全局的看板,协调服务端进程和客户端进程之间的信息。
服务短进程可以将自己提供的服务告诉命名服务进程,而客户端进程可以在命名服务中查询该当前服务,并选择自己希望建立的服务去尝试获取权限。具体是否分发权限给客户端进程,是由命名服务和对应的服务端进程根据特定的策略来判断的。
除了命名服务外,另外一种常见的方式是通过继承来分发连接权限。比如,Linux 中,匿名管道通常用于父子进程之间的通信,内核通过 fork 操作的时候复制文件描述符来建立父子进程间的连接。
8.1.11 总结
宏内核进程间通信机制的对比

8.2 文件接口 IPC:管道
管道(Pipe)是宏内核场景下重要的进程间通信机制。Linux 中的 ps aux | grep target 通过管道符 “|” 将第一个命令的输出投递到一个管道,而管道对应的出口是第二个命令的输入。管道符 “|” 通常是利用操作系统提供的管道进程间通信机制实现的。
管道是单向的 IPC ,内核中通常有一定的缓冲区来缓冲消息,而通信的数据是字节流,需要应用自己对数据进行解析。

8.2.1 Linux 管道使用案例
以下代码片段展示了一个 Linux 手册中给出的管道使用案例。
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[]) {
int pipefd[2];
pid_t cpid;
char buf;
if (argc != 2) {
fprintf(stderr, "Usage: %s <string>\n", argv[0]);
exit(EXIT_FAILURE);
}
if (pipe(pipefd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
cpid = fork();
if (cpid == -1) {
perroe("fork");
exit(EXIT_FAILURE);
}
if (cpid == 0) { // 子进程:从管道中读取数据
close(pipefd[1]); // 关闭管道写端口
while (read(pipefd[0], &buf, 1) > 0)
write(STDOUT_FILENO, &buf, 1);
write(STDOUT_FILENO, "\n", 1);
close(pipefd[0]);
_exit(EXIT_SUCCESS);
} else { // 父进程从输入中获取一个字符串
close(pipefd[0]); // 关闭管道读端口
write(pipefd[1], argv, strlen(argv[1]));
close(pipefd[1]); // 管道读者会看到 EOF
wait(NULL);
_exit(EXIT_SUCCESS);
}
}
该程序会通过进程 fork 创建一个子进程,父进程会从命令行读入一个字符串,并通过管道将字符串传递给子进程,而子进程将会从管道中读出该字符串并将内容打印出来。
在管道通过 pipe(pipefd) 创建之后,会得到两个文件描述符,后续对管道的使用和对文件的使用几乎是完全相同的。
在管道中我们可以通过文件接口来实现进程之间通信的目的
在 UNIX 中,管道会被当做一个文件。内核会为用户态提供代表管道的我那几年描述符,让其可以通过文件系统相关的系统调用来使用。
管道的创建会返回一组(两个)文件描述符,放在 pipefd 中。不过实际上管道并不使用存储设备,而是使用内存作为数据的缓冲区。
管道的行为和 FIFO 队列非常像,最早传入的数据最先被读出来。一个进程输入后,另一个进程可以通过管道读到数据。如果还没有数据写入,拿到输出端的进程就开始尝试读取数据,有两种情况
- 如果系统发现当前没有任何进程有这个管道的写端口,则会看到 EOF
- 阻塞在这个系统调用上,等待数据到来。
8.2.2 Linux 中管道进程间通信的实现
管道的创建是由 pipe 系统调用完成的,这个系统调用会返回两个文件描述符,对应管道的两端。
用户态程序可以通过文件接口来使用这两个文件描述符,从而实现进程间通信。
Linux 中通过 pipe_inode_info 这个结构体来管理管道在内核中的信息。在这个结构体中,内核会维护 bufs 的管道缓冲区。
struct pipe_inode_info {
struct mutex mutex; // 保护管道
wait_queue_head_t rd_wait, wr_wait; // 读者和写者的等待队列
unsigned int head; // 缓冲区头
unsigned int tail; // 缓冲区尾
unsigned int readers; // 并发读者数
unsigned int writers; // 并发写者数
struct pipe_buffer *bufs; // 管道缓冲区
}
为了从缓冲区中读取数据,首先内核会锁住整个管道,避免在读的过程中发生管道状态相关变化。随后,内核尝试从缓冲区中读取数据。如果当前有数据,那么内核会在读取到足够的数据后返回。如果当前没有数据,那么内核会尝试唤醒等待的写者,让其开始写入数据,并且使自己陷入阻塞状态。在进入阻塞状态前需要释放管道锁,否则写者即使被唤醒也无法进行相应的操作。当写者完成写操作后,会唤醒当前等待的读者,使其开始尝试新一轮的读操作。
8.2.3 命名管道和匿名管道
在经典 UNIX 实现中,管道通常有两类——命名管道和匿名管道,主要区别在于它们的创建方式。匿名管道是通过 pipe 系统调用创建的,在创建的同时进程会拿到读写的端口(两个文件描述符)。由于整个管道没有全局的名字,因此只能通过这两个文件描述符来使用它。在这种情况下,通常要结合 fork 来使用,即用继承的方式来建立父子进程间的连接。
在完成继承后,父子进程会同时拥有管道的两端,此时需要父子进程主动关闭多余的端口,否则可能导致出错。这种连接方式对于父子进程等有着创建关系的进程来说比较方便,但是对于两个关系较远的进程就不太适用。
命名管道适用于两个关系较远的进程。命名管道是由另一个命令 mkfifo 来创建的。
创建过程中会指定一个全局的文件名,由这个文件名来指代一个具体的管道。通过这种方式,只要两个进程通过一个相同的管道名进行创建,就可以实现任意两个进程间建立管道的通信连接。
8.3 内存接口 IPC:共享内存
为什么需要共享内存?
使用共享内存的很重要的原因是性能。
其他进程间通信机制,包括消息队列、管道等,都依赖内核提供完整的缓冲数据、接收消息、发送消息等一系列进程间接口。
虽然这些完善的接口方便了用户进程的使用,但其中涉及的数据拷贝和控制流转移等处理逻辑影响了这些抽象的性能。
共享内存的思路是内核为需要通信的进程建立共享区域。一旦共享区域建立完成,内核基本上不需要参与进程间通信。
介绍 System V 共享内存。
8.3.1 共享内存
内核会为全局所有的共享内存维护一个全局的队列结构,也即图中的共享内存队列。队列的每一项(shmid_kernel 结构体)都是和一个 IPC key (涉及到通信权限管理)绑定的。进程可以通过同样的 key 来找到并使用同一段共享内存区域。虽然这样的 key 是全局唯一的,但是能否使用这段共享内存,是通过 System V 的权限检查机制来判断的。只要进程有对应的权限,就能通过内核接口(shmat)讲一段共享内存的区域映射到自己的虚拟地址空间中。
每段共享内存是由结构体 shmid_kernel 封装的,其中包含一个 file 类型的结构体。这是因为在 Linux 的系统设计中,将共享内存的机制封装在了一个特殊的文件系统上(共享内存文件系统)。这个 file 类型的结构体通过文件系统最终指向一段共享内存页的集合。
存在这么多层的封装,除了便于利用 Linux 内核里的现有其他组件的功能,也是为了支持共享内存的换页(Swap)和内存动态分配(Demand Paging)

进程 1 和进程 2 分别对同一个共享内存建立了映射(shmat)之后,内核会为它们分配两个 VMA(Virtual Memory Area)结构体,让他们都指向 file。这里的 VMA 会描述进程的一段虚拟地址空间的映射。建立了这两个 VMA ,内核能够从用户进程的虚拟地址找到对应的 VMA,从而知道这是一个共享的区间。
当进程不再希望共享虚拟内存时,可以将共享的内存从虚拟内存中取消映射(shmdt)接口。只影响当前进程的映射,其他仍在使用的进程不受影响。
8.3.2 基于共享内存的进程间通信
将介绍基于共享内存的进程间通信的一个经典问题——生产者 - 消费者问题。
buffer 对应的是共享缓冲区。这里给定了缓冲区的大小,即 BUFFER_SIZE 个元素。缓冲区中的每一个元素由一个结构体 item 表示,这个结构体对应着消息的抽象。
#define BUFFER_SIZE 10
typedef struct {
struct msg_header header;
char data[0];
} item;
item buffer[BUFFER_SIZE];
volatile int buffer_write_cnt = 0; // 生产者放置新消息的位置
volatile int buffer_reade_cnt = 0; // 表示下一个能读取消息的位置
volatile int empty_slot = BUFFER_SIZE; // 当前缓冲区上空置的区域的个数
volatile int filled_slot = 0; // 当前缓冲区上包含消息的区域的个数
生产者代码
// 生成新消息
int send(item msg) {
while (empty_slot == 0) {
;// 没有空闲缓冲区
}
empty_slot--;
buffer[buffer_write_cnt] = msg;
buffer_write_cnt = (buffer_write_cnt + 1) % BUFFER_SIZE;
filled_slot++;
...
}
消费者代码
// 消费新消息
item send(void) {
item msg;
while (filled_slot == 0) {
;// 没有空闲缓冲区
}
filled_slot--;
msg = uffer[buffer_reade_cnt];
buffer_reade_cnt = (buffer_reade_cnt + 1) % BUFFER_SIZE;
empty_slot++;
return msg;
}
8.4 消息接口 IPC:消息队列
为什么需要消息队列?
相比于之前介绍的其他通信机制,消息队列是唯一以消息为(内核提供)数据抽象的通信方式。
介绍 System V 消息队列
消息队列是一种非常灵活的通信机制,支持多个发送者和接收者同时存在。
8.4.1 消息队列结构
消息队列在内核中的表示是队列数据结构。当创建消息时,内核将从系统内存中分配一个队列数据结构,作为消息的内核对象。
这个对象有其相应的权限,以及消息头部指针。队列的消息由这个头部指针引出,每个消息都有指向下一个消息的指针(或者为空)。这是一种常见的队列的链表设计。
在消息的结构体中,除了“下一个”指针外,就是消息的内容。消息的内容包括两部分:数据和类型。数据是一段内存数据,和管道中的字节流类似。类型是用户态程序为每个消息指定的。

8.4.2 基本操作
消息队列的操作一般被抽象为四个基本操作:msgget、msgsnd、msgrcv、msgctl,这四个操作在 Linux 系统上实现为系统调用。
msgget 允许进程获取已有消息队列的连接,或者创建一个新的队列。消息队列的本质上采用的是信箱的通信方式。
msgctl 可以控制和管理消息队列。
进程可以通过 msgsnd 向消息队列发送消息,通过 msgrcv 从消息队列接收消息。大部分情况下这个两个过程是非阻塞的:对发送者来说,只要队列有空闲的空间就可以向队列发送消息,而接收者只要有未读的消息就可以直接读取消息并完成操作。若发送消息是消息队列没有可用空间或接收消息时没有未读消息,默认的操作时阻塞进程,直到有空间腾或者新的消息到来。
8.5 案例分析:L4 微内核的 IPC 优化
8.5.1 L4 消息传递
L4 根据要传递的消息大小,将其分为短消息和长消息。
短消息
当传递的消息较短时,L4 会直接使用寄存器的参数传递方式来实现零拷贝传输。在调用的接口上,发送者进程将参数设置在寄存器上。下陷到内核后,内核可以直接从发送者的上下文切换到接收者的上下文,并且不会修改存放在寄存器中的参数。
这种依靠寄存器传递参数的一个缺陷是:能够传递的数据量依赖于具体的硬件架构。初次之外,内核和用户态之间交互的接口也会影响能够通过寄存器传递的数据量。
Pistachio(L4系列中的一个变体)引入了虚拟消息寄存器(Virtual Message Register),允许用户态自定义虚拟消息寄存器集合的大小。
将“寄存器参数”和具体的寄存器解耦。
内核会将其中一些虚拟消息寄存器映射到物理寄存器,而物理寄存器放置不下的部分则包含在每个线程固定地址的内存空间中。
由于内存中的虚拟消息寄存器不会很多,传输的开销(虚拟寄存器需要拷贝)仍然在可控的范围内。
长消息
L4 长消息的传输本质上是由内核辅助的,通常需要两次拷贝:发送者用户态拷贝到内核缓冲区,内核缓冲区拷贝到接收者用户态。L4 对长消息的传输做了优化
- 拷贝次数的优化:在 L4 中,内核通过建立一个临时的映射区域来传输数据。可以实现一次拷贝的数据传输。如图所示,L4 在每个进程的内核地址空间中预留一段区域,用作建立临时缓冲区。这段区域在不同的进程中是不共享的。当发送者发起消息传输时,内核在发送者上下文中执行,并将发送者临时缓冲区的虚拟地址空间映射到接收者接收消息的物理内存区域上。完成这次映射后,内核可以直接将消息从发送者用户态内存区域拷贝到物理内存区域,从而完成将数据从发送者拷贝给接收者的过程。
- 接口层面的优化:消息传递接口通常要求传递的数据落在一段连续的虚拟地址区域,如果消息落在不同区域中,通常只能多次通信来将它们分别发送出去。

8.5.2 L4 控制流转移
L4 中引入的两个优化控制流转移性能的机制:惰性调度(Lazy Scheduling)和进程直接切换(Direct Process Switching)
惰性调度
通信的控制流转移往往是通过内核的线程 / 进程管理和调度实现的。
在 L4 同步的 IPC 模型下,线程的状态经常在就绪和阻塞中交替(发送消息后进入阻塞状态)。这意味着在通信的过程中会发生频繁的调度队列操作:一个线程会在短时间内多次被移入移出就绪调度队列。这些额外执行的代码和访问的数据会在通信过程中引入 TLB 不命中、缓存不命中等开销。
位次,L4 提出了惰性调度的优化方式。
当线程在 IPC 操作上阻塞时,内核会在线程管理结构(Thread Control Block, TCB)中更新其状态,但会将线程保留在就绪队列中。调度器在调度线程时会遍历就绪队列,忽略这些处于阻塞状态的线程,直到找到真正可运行的线程。这样的设计可以避免大量的队列操作:IPC 过程中及时线程状态发生变化,也只需要修改 TCB 中的结构,而不需要调度队列的操作。这种方案是基于一个假设的:IPC 相关线程的阻塞状态会很快结束
惰性调度优化导致调度器的就绪队列中可能存在大量的阻塞线程,从而使调度器的执行时间与系统的线程数机器 IPC 执行情况相关。
直接进程切换
调度的不确定性会严重影响 IPC 的时延。因此,L4 将调度程序从控制流转移的关键路径上移除,即直接完成从调用者到被调用者的切换(返回消息的过程类似)。这就是直接进程切换。
直接进程切换也存在一些问题,可能破坏实时场景下任务的优先级。
如果只要调用者发起通信,被调用者就一定响应,那么在一些实时的场景下将无法保证对不同优先级任务的区别处理。
8.5.3 L4 通信连接
L4 系列的微内核经历了直接通信到间接通信的转变。早期 L4 的设计使用线程作为通信的目标。
线程作为通信目标的方案有一些问题:
- 线程 ID 必须是全局唯一的标识符。全局 ID 在后续的系统工作中被证明会引入潜在的隐蔽信道(Covert Channle)的危险,而这会导致攻击和信息泄露的发生。
- 这种模型的信息隐藏性差,多线程服务必须向客户端进程公开其内部结构。
8.5.4 L4 通信控制(权限检查)
直接通信下,只要有对应的标识符(如进程号),一个进程就可以尝试和另一个进程通信。在 L4 的早期设计中,内核在通信过程中会将发送者的一个“不可伪造的标识符”传递给接收者,帮助接收者判断是否响应发送者的消息。然而,恶意的发送者进程可能用大量的消息来“轰炸”接受者进程。接受者进程需要花费大量的时间来检查病丢弃恶意消息,这也会影响其执行有用的工作。这样的“轰炸”其实构成了拒绝服务攻击。
早期 L4 通过氏族和酋长的机制来解决上述问题。
整个系统内的进程按照“氏族”的层次结构进行组织,每个氏族都有一个指定的“酋长”。在氏族的内部,所有消息是自由传输的。而跨氏族的消息都将重定向到酋长,尤其来控制消息的流向。如果存在某个不受信任的进程希望将在内部获取得敏感信息传递到外部进程,那么会被酋长检测到并阻止。
但是氏族和酋长在后续逐渐被舍弃了。其存在以下问题
- 一旦消息需要发送到外部,那么中间会经过几次重定向,通过一层层得酋长往外发送。通信开销比较大
- 酋长本身仍然是可能被攻击得点。恶意得攻击者可以通过攻击酋长机制来限制整个氏族和外部的通信。
在间接通信的场景下,L4 系列的微内核组件采用更灵活的权限机制 Capability 来解决上述问题。
Capability 是对于内核对象的索引,以及对于该内核对象的权限。微内核会在内核中为每个通信连接维护一个 IPC 内核对象。这个内核对象中包含接收者、发送者、缓冲区等和通信相关的信息。发送者进程要发起通信时,需要告知内核一个特定的 Capability 来发起通信,内核负责检查 Capability 的正确性及其权限,然后通过 Capability 找到对应的内核对象以及与之对应的接收者,从而开始通信。两个进程要通信,必须首先通过命名服务等方式获取对应的 Capability。
现代的主流微内核系统中,几乎都会使用 Capability 来管理内核对象并负责通信权限的检查。
8.6 案例分析:LRPC 的迁移线程模型
优化 IPC 性能的大部分工作关注两个部分:优化控制流切换的性能和优化数据传输的性能。
迁移线程认为,可以将其他的 IPC 设计看成将需要处理的数据发送到另一个进程处理,这也是为什么控制流切换和数据传输会成为主要的瓶颈。
如果换一个角度,将另一个进程处理数据的代码拉到当前进程,那么是不是就可以避免控制流的切换以及数据传输?
者就是迁移线程的思路。
8.6.1 迁移线程模型
迁移线程的基本原则:简化控制流转换,让客户端线程执行“服务端代码”;简化数据传输,共享参数栈和寄存器;简化接口,优化序列化等开销;优化并发,避免共享的全局数据结构
要做到“将代码拉到本地”,迁移线程首先需要对线程结构进行解耦,明确线程中那些部分是对通信请求处理起关键作用的。然后,这部分线程允许被调用者运行在调用者的上下文中,将跨进程调用变成更接近函数调用的形式。
采用迁移线程模型,在进程间通信过程中,内核不会阻塞调用者线程,但是会让调用者线程执行被调用者代码。
内核不会进行完整的上下文切换,而是只切换地址空间(页表)等和请求处理相关的系统状态。
8.6.2 LRPC 设计
LRPC(Lightweight Remote Procedure Call, LRPC)是一种同步的进程间通信设计,客户端通过进程间通信让服务端来执行一个 方法(接收来自客户端的参数),并将计算结果返回。
共享参数栈和寄存器
LRPC 主要通过参数栈和寄存器来传递数据。
参数栈中存放着远程方法调用中客户端向服务端传递的参数。系统内核为每一个 LRPC 连接预先分配好一个参数栈,并将其同时映射在客户端进程和服务端进程地址空间中。客户端只需要将参数准备到参数栈即可,无需额为的内核拷贝。
LRPC 在通信调用的过程中不会切换通用寄存器,而是直接使用当前的通用寄存器。
客户端进程会优先使用寄存器,在寄存器不够的情况下则用参数栈传递参数。
通信连接的建立
内核需要为通信的服务端提供一个服务的抽象,即服务描述符。所有支持客户端调用的服务端进程将自己的处理函数等信息注册到服务描述符中。在系统内核中,需要为每个服务描述符准备两个资源:第一是参数栈,第二是连接记录(Linkage Record),参数被同时映射到调用者进程和被调用者进程。而连接记录主要是记录调用过程中的信息,类似于函数调用中往栈上压入的返回地址等。当一个服务被调用,服务端进程执行完请求需要返回时,内核会从连接记录中获得返回地址等信息。当客户端和服务端建立连接时,内核会分配参数栈和连接记录,并返回给客户端进程一个绑定对象(Binding Object),后续客户端可以通过绑定对象发起通信。绑定对象的获得意味着客户端和服务端建立了连接。
通信过程
当调用者发起一次通信时:内核验证绑定对象的正确性,并找到正确的服务描述符;内核验证参数栈和连接记录的正确性;内核检查是否有并发调用;内核将调用者的返回地址和栈指针放到连接记录中;内核将连接记录放到线程控制结构体中的栈上;内核切换到被调用者进程地址空间;内核找到被调用者进程的运行栈;内核将当前线程的栈指针设置为被调用者进程的运行地址;内核将代码指针指向被调用者地址空间中的处理函数。
LRPC 中使用的迁移线程模型和 L4 中的直接进程切换有相似的地方,它们都选择了绕过内核调度。不同点在于,L4 仍然完成了两个线程切换的任务,LRPC 没有切换线程。

浙公网安备 33010602011771号