IPC - 你要是跟我唠这个,我可就不困了嗷

IPC - 你要是跟我唠这个,我可就不困了嗷

宏内核

匿名管道

就是 Linux 下的 | 表示符,主要原理是通过 int pipe(int fd[2]) 来将消息从用户态传送到内核态缓冲区,再将消息传送到接收者的用户态从而做到进程间的通信。

具体的流程是 Shell 作为主进程来调用 fork() 系统调用来创建子进程,由于 fork 出的子进程会复制主进程的文件描述符 fd[0] 作为输入,fd[1] 作为输出。输入方会将消息放入 fd[1] ,然后传输到接收方的 fd[0]

但是这种方式要求发送方,接收方必须由相同的父进程。

命名管道

命名管道则是命令 mkfifo 生成的文件,没有匿名管道 “双方必须有相同的父进程” 的限制,任何进程都可以将消息放入管道中,也可以从管道中读取。

但是这两种方法都不适合大数据量的传输,频繁的写入与控制流转移会消耗大量的时间。

System V 消息队列

消息队列是唯一一个以消息为数据抽象的通信方式,其发送和接收的接口是内核提供的。

结构

创建新的消息队列时,内核将从系统内存中分配一个队列数据结构,作为详细队列的内核对象。

image-20201212211549834

队头元素中有权限信息(内核具体实现为 IPC_PERM 结构体)与消息头部指针。队内的消息由这个头部指针引出,每个消息都会有指向下一个消息的指针,或者为空。

消息内容包含两部分:

  • 类型
  • 数据

数据是一段内存,数据和管道中的字节流相似。

类型是用户态程序为每个消息而定的消息队列的设计中内核,不需要知道类型的语义,仅仅是保存以及基于类型的简单查找。

基本操作

消息队列的操作一般被抽象为四个基本操作,这四个基本操作在 Linux 系统中被实现为系统调用。

  • msgget 允许进程取已有的连接或者新创建一个消息队列
  • msgctl 可以控制和管理一个消息队列,如修改消息队列的权限信息,或删除消息队列
  • msgsnd 进程可以通过该命令向消息队列发送消息
  • msgrcv 进程可以通过该命令从消息队列上接收消息

消息的发送和接收成功的标志是消息被放在队列上,或者从队列中取出。在大部分情况下,这两个过程是非阻塞的。接收者在消息队列中没有消息时可以通过 NOWAIT 选项指定进程是等待还是返回错误信息,不阻塞用户进程。

Linux 内核实现

一旦一个队列被创建,除非内核重新启动,或者该队列被主动删除,否则其数据都是会被保留的。

其次,消息队列的空间是有限的,管理员可以配置单个消息的最大空间,单个消息队列的最大空间以及全系统的消息队列个数等信息。

通常建议使用共享内存机制来传递长消息,而非使用消息队列。

最后,消息在用户态和内核态之间传输会有拷贝的开销

  • 发送消息时,内核会通过 copy_from_user 来将数据从用户态迁移到内核空间
  • 接收消息时,内核会通过 copy_to_user 将数据搬回到用户态。

System V 信号量

信号量在实际的使用中,主要运作进程间的同步信号量。本身能传递的数据量很少,一般来说,仅有一个共享的整形计数器,该计数器通常由内内核维护,而对信号量的操作,则需要经过内核系统调用。

信号量的主要操作是两个操作与 PV,分别为 “信号量 -1” 和 “信号量 +1” 的语义,同时信号量的范围也在 [-1, 1] 之间跳动,所以有时也称为二值信号量。

PV 在操作上都是在信号量结构上进行的,该结构会封装一个计数器,同时这两个操作是原子的。当进程的信号量由 -1 => 0 时,内核就会唤醒该进程,其状态就从阻塞变为执行。通过这种方式可以保证进程间的执行顺序。

System V 共享内存

为什么要使用共享内存?

使用共享内存的一个很重要的原因就是性能。之前介绍的机制,包括消息队列,信号量,管道等,内核都提供了完整的包括缓冲数据接收,数据发送数据等一系列进程间的通信接口。虽然这些抽象很方便,但其中涉及的数据拷贝控制流转移等处理逻辑,影响了这些抽象的性能,共享内存的思路就是内核为需要通信的进程,提供共享区域,一旦共享区域完成建立,内核基本上不需要参与进程间通信。

共享内存进程间通信

共享内存的核心思路就是允许一个或多个进程,在其所在的虚拟地址空间中映射相同的物理内存页,从而进行通信。

image-20201212220536416

首先,内核会为全局所有的共享内存维护一个全局的队列结构,这个队列的每一项(shmid_kernal 结构体)是和一个 IPC key 绑定的,这和其他的System V IPC 机制是类似的。各进程可以通过同样的一个 key 来找到使用同一段共享物理内存区域。虽然这样的 key 是全局唯一的,但是能否使用这段共享内存,是通过 System V 的权限机制来判断的。只要进程有对应的权限,就能通过内核接口(shm_at),将一段共享内存的区域映射到自己的虚拟地址空间中。每段共享内存是由 shmid_kernal 结构体 封装的,而其包含一个 file 结构体 ,这个 file 结构体 通过文件系统的 inode 结构体,最终指向一段共享物理内存页的集合,这些共享内存也是这段共享内存对应的物理内存。

这里的这么多层封装,除了利用 Linux 内核中现有的其他组件的功能(除文件系统)外,也是为了支持共享内存的 Swap 和内存页动态分配。

当两个进程同时对以共享内存建立映射之后,内核会为他们两个分配 VMA(Virtual Memery Area) 结构体,让他们都指向 file。这里的 VMA 会描述进程的一段虚拟地址空间的映射。值得注意的是,两个 VMA 对应的虚拟地址可以是不同的,但这并不影响他们映射到相同的物理内存上,该机制天然能够支持任意数量的进程来共享一个共享内存区域,只要为他们分配指向 file 结构体的 VMA 即可。

当进程不再希望共享内存时,可以取消共享内存和虚拟内存之间的映射(shm_dt 接口),这里取消映射的操作只会影响当前进程的映射,其他人在使用共享内存的进程是不受影响的。

信号

为什么需要信号?

管道、消息队列、共享内存等方式主要关注在数据传输的设计上,而信号的一个特点是其单向事件通知能力。信号量也有通知功能,但是需要进程主动去查询计数器状态或进入阻塞状态来等待通知。

使用信号,一个进程可以随时发送一个事件到特定的进程、线程或进程组,并且接收事件的进程不需要阻塞等待该事件,内核会帮助其切换到对应的处理函数中响应事件信号,并在处理完之后恢复之前的上下文。

换句话说,信号通信是异步的。

基本介绍

信号传递的信息很短,只有一个编号。一个简单的例子是 Ctrl + C,在 Shell 中终止一个执行中的程序。其背后的逻辑是是要发出了一个 SIGINT 信号,从而导致默认信号处理函数结束了对应的进程。

在通信的场景下,一个进程会为一些特定的信号编号注册处理函数(类似前面的通信中服务端进程的处理函数)。在进程接收到对应的信号时,内核会自动的将该用户的控制流切换到对应的处理函数中。在信号的处理函数中,可以看到的数据主要为信号值(由内核传递)。

以 Linux 为例,Linux 早期使用的信号有 31 个(1 到 31 号),后续的 POSIX 标准又引入了编号从 32 到 64 号的其他信号。Linux 传统信号被称为常规信号,而 POSIX 引入的信号被称为实时信号,主要用于实时通信。一个进程如果多次收到某个常规信号事件,内核只会记录一次,而实时信号的多次信号事件传输则不能丢弃。

信号的发送

信号的发送者可以使其他用户态进程,也可以是内核。一个用户态进程可以通过内核提供的系统调用接口给一个进程或线程(包括自己)发送特定的信号事件。

以 Linux 可为例,内核会为每个进程或线程准备一个事件等待队列。一个进程内的多个线程共享该进程的信号事件等待队列,并拥有自己的等待队列。内核通过不同的系统调用及其参数,来确定接收信号的目标进程或线程,将信号事件添加到其等待队列上。

信号的阻塞 / 屏蔽

Linux 提供了一个专门的系统调 sigprocmask ,用来允许用户程序设置对应信号的阻塞状态。当一个信号被阻塞后,Linux 将不会触发这个信号对应的处理函数,直到该信号被解除阻塞状态。需要注意的是,除了进程间通信外,信号还可以用于进程管理,因此,很多重要的信号是不能被阻塞的。

信号的响应与处理

在如 Linux 等 UNIX 系统的设计中,信号得到处理的实际通常是内核执行完异常、中断、系统调用等返回到用户态的时刻。以 Linux 为例,在内核执行完 ret_to_user 指令后,会检查一个状态位来判断是否有信号需要处理。

内核对信号的处理一般包括以下三种:

  • 忽略
  • 用户处理函数:调用用户注册的处理函数
  • 内核默认处理函数

内核默认的处理函数大多数情况下就是杀死进程或者直接忽略信号。而从通信的角度看,只有用户注册了的处理函数信号才能被用来通信。

Linux 内核提供了 signal, sigaction 等系统调用,允许用户为特定的信号注册一个用户态处理函数。

image-20201213092734564

首先,一个用户态进程调用系统调用,进入了内核。内核在处理完系统调用后发现信号有需要处理,于是切换到用户态处理函数位置,让其处理信号事件。信号处理完之后,信号处理函数会通过系统调用 sigreturn 返回内核。这个系统调用的主要作用就是辅助内核恢复到被信号打断之前的上下文,它不会返回到信号处理函数中,而是直接恢复到之前的用户态。

Socket

**套接字(Socket)**是一种即可用于本地,又可以跨网络使用的通信机制。

在套接字进程间通信下,客户端会通过一个特定的 “地址” 来找到要调用的服务端进程。操作系统网络协议栈会识别回环地址,将通信消息发送到目标端口对应的进程。

以 Linux 为例,创建一个套接字会使用 socket(int domain, int type, int protocal)。其中 domain 指定地址,type 指定通信协议,常用的是 SOCK STREAMSOCK DGRAM ,即数据流和数据报这两类抽象,他们分别对于传输控制协议和用户数据报协议,protocal 指定协议的一些

微内核

在微内核架构下,一个应用程序获取系统服务通常需要通过 IPC 的方式,例如访问文件系统会从原来的一次系统调用变成两次系统调用和中间通信处理逻辑的开销,所以大部分微内核操作系统都会优先从性能角度来设计和实现 IPC。

Mach 端口转发

早期的微内核系统 Mach 通过两种基本的抽象 —— 端口(Port)和消息(Message),设计和实现了一种间接通信:IPC 通信的双方不需要显示的指定另一方,而是通过端口进行通信,进程间通过端口流通的数据就是消息。

端口设计

端口可以分为发送端口和接收端口。拥有发送端口权限的进程,可以向信箱中发送消息,而接收者进程则可以通过信箱读取消息。

需要注意的是,一个信箱可以有多个发送端口,但只能有一个接收端口,这是因为允许多个接受者端口 Mach 内核无法知道一个消息应该发送给哪个接收者。

消息设计

Mach 的消息由一个定长的头部和一段变长的数据段组成。Mach 中的消息还有一个重要的功能 —— 传递端口。

image-20201213095023077

从接口设计来看,Mach 同时支持单向和双向的通信,其中单向的通信通过 msg_send 等接口实现,而双向的通信通过 msg_crp 接口实现。

总结

作为早期的微内核系统,Mach 系统的性能比起当时的宏内核系统还是存在不小的差距,其中一个原因就是 Mach 为了实现大量的目标,如可剪裁性,可移植性导致其内核复杂,且代码量巨大。

L4 短消息 长消息

根据 Mach 的经验,研究人员开始研发 L4 系列的微内核系统。该系统的一个突出思路是,进程间通信是微内核的核心功能,要围绕通信去完成整个系统的设计和实现。

消息传递

抛开上层接口的细节,对于内核来说,L4 根据需要传递的消息的大小,将消息分为了短消息和长消息,并给出了不同的传输方式设计。

image-20201213101641226

短消息

当传递的消息比较短时,要是会直接使用寄存器的参数传递方式来实现零拷贝传输。具体来说,在调用的接口上,发送者进程会将参数设置在寄存器上。当下陷到内核时,内核可以直接从发送者的上下文切换到接收者的上下文,不去修改存放在寄存器中的参数。

但是这种方法存在缺陷,能够传递的数据量依赖于具体的硬件架构,除了硬件的限制外,内核和用户态之间的交互接口系统调用 ABI 也会影响能够通过寄存器传递的数据量。此外,这也会增加移植系统到新硬件或者支持新的 ABI 接口的复杂性。

后来出现的 L4 系列的一个变体,引入了虚拟消息寄存器的概念,允许用户态自定义虚拟消息寄存器集合的大小。实现上,内核会将一些虚拟消息寄存器映射到物理寄存器,而物理寄存器放置不下的其余部分则放置在每个线程固定地址的内存空间中(相当于通过内存来虚拟了一部分额外寄存器)。

长消息

L4 长消息的传输本质上是由内核辅助的,通常需要两次拷贝:

  • 从发送者用户态拷贝到内核缓冲区
  • 内核缓冲区拷贝到接收者用户态

L4 同时为长消息的传输增加了不少优化。其中一点优化是拷贝次数的优化,在 L4 内核通过一个临时建立的映射区来传输数据,可以实现一次拷贝的数据传输。具体来说,L4 在每个进程的内核的地址空间中都会预留一段区域,用作临时缓冲区。这段区域在不同的进程间是不共享的(即使它处于内核段)。在发送者发起消息传输时,内核在发送者的上下文中执行,并将发送者的临时缓冲区的虚拟空间映射到接受者接收消息的物理内存区域上。完成这次映射后,内核可以直接将消息从发送者的用户态内存区域拷贝到临时缓冲区,从而完成将数据从发送者拷贝到接收者的过程。

LRPC 转移线程优化

到目前为止,我们了解到优化 IPC 性能的大部分工作会关注两个部分,优化控制流切换的性能,以及化数据传输的性能。

但是如果换一个角度,将另一个进程处理数据的代码拉到当前进程,那我们是不是就可以避免控制流的切换(仍是当前线程处理)以及数据传输(已准备在当前进程中)?

迁移线程方案被用在 LRPC 和 Mach 等系统中,是目前纯软件进程间通信优化中效果最好的设计之一。

迁移线程的基本原则是

  1. 简化控制流切换,让客户端执行服端代码
  2. 简化数据传输,共享参数栈和寄存器
  3. 简化接口,减少序列化等开销
  4. 优化并发,避免共享的全局数据结构

要做到将代码拉到本地,迁移线程首先要需要对线程结构进行解耦,明确线程中哪些部分是对通信请求处理的起关键作用的,然后这部分允许被调用者(负责处理请求的逻辑)运行在调用者的上下文中,将跨进程调用变为更接近函数调用的形式。

image-20201213103546632

如果使用迁移线程模型,在进程间通信过程中,内核不会阻塞调用者线程,而是会让调用者线程执行被调用者的代码。整个过程没有被调用者线程被唤醒,反而被调用者更像是一个 “代码的提供者”。此外内核不会进行完整的上下文切换,而只是切换地址空间(页表)等和请求处理相关的系统状态。

LRPC 设计

LRPC (Lightweight Remote Procedure Call, 轻量级远程过程调用) 是一个同步的进程间通信设计,客户端通过进程间通信让服务端来执行一个方法,并将计算结果返回。

共享参数栈和寄存器

首先来看数据传输方面的设计,LRPC 主要是通过**参数栈(Argument stack)**和寄存器来传递数据。顾名思义,参数栈中存放着远程过程调用中客户端给服务端传递的参数。系统内核为每一个 LRPC 连接,预先分配好一个参数栈,并将其同时映射在客户端进程和服务端进程的地址空间中。因此,通信过程中客户端进程只要将参数准备到参数栈即可,不需要额外内存拷贝。LRPC 在通信调用过程中不会切换通用寄存器,而是直接使用当前的通用寄存器。

客户端进程会优先使用寄存器,在寄存器不够的情况下使用参数栈传递参数。

通信连接的建立

内核中需要为通信的服务端提供一个服务的抽象,即服务描述符。所有支持客户端调用的服务端进程都要将自己的处理函数等信息注册到服务描述符中。在系统内核里,需要为每个描述符准备两个资源:

  • 参数栈
  • 连接记录(Linkage record

连接记录主要是记录调用过程中的信息,类似于函数调用中往栈中压入返回地址。

当客户端申请和一个服务端建立连接时,内核会分配参数栈和连接记录,并返回给客户端进程一个绑定对象(Binding object),之后客户端可以通过绑定对象发起通信,绑定对象的获得意味着客户端和服务端成功建立起了连接。

通信过程

当调用者发起一次通信时,具体过程如下

  1. 内核验证绑定对象的正确性,并找到正确的服务描述符
  2. 内核验证参数栈和连接记录的正确性
  3. 内核检查是否否有并发调用
  4. 内核将调用者的返回地址和栈指针放进连接记录中
  5. 内核将连接记录放到线程控制结构体的栈上
  6. 内核切换到被调用者进程空间地址
  7. 内核找到被调进程的运行栈(执行代码所使用的栈)
  8. 内核将当前线程的栈指针设置为被调用者进程的运行栈地址
  9. 内核将代码指针指向被调用者地址空间中的处理函数

LRPC 使用的迁移线程模型和 L4 中直接切换线程有相似的地方,如它们都选择绕内核调度。

不同点在于 **L4 仍然完成了两个线程切换的任务,LRPC 中其实没有切换线程。**多核场景下的跨进程间通信中会有显著的不同:LRPC 在通信时可以将远端核上的被调者上下文拉到当前的线程中直接执行,相当于跨核通信变成了单核通信。l4则需要核间中断(IPI)等机制来通知远端核进行处理。

参考

  • 《现代操作系统:原理与实现》