操作系统(三):进程间通信

在现代操作系统中,使用多个进程来实现应用和系统是一种广泛使用的方法。进程间通信(Inter-Process Communication) 是多进程协作的基础。

一般来说,IPC 至少需要两个进程参与。根据信息流动的方向,这两方可以被称为发送者接收者。在实际使用中,IPC 常被用于服务调用,因此参与 IPC 的双方又被称为调用者被调用者,或者客户端服务端

为了有效地协同工作,进程间通信一般伴随着数据的传输。一种常用的通信数据的抽象是**消息(message)。消息一般包含一个头部(header)和一个数据段(payload)**。头部中通常含有魔数、消息长度、校验码等信息,而数据段中则既可以包含“纯”数据,也可以包含系统资源(如文件描述符等)。

下面,本文将简略地介绍 IPC 的相关内容。

一个简单的 IPC 设计

考虑一个简单的 IPC,内核将两个进程的一段虚拟地址映射到同一段物理地址上,即两个进程之间有一段共享内存。在通信时,发送者将要传输的数据内容拷贝到发送者消息上,然后依次设置头部中的状态(设置为“准备就绪”)等信息。接收者会不停地轮询发送者消息的状态,一旦观测到状态信息为“准备就绪”,就表示发送者发出了一个消息。发送者一发送完消息,就开始轮询接收者消息的状态,等待其变成“准备就绪”。接收者在读取到发送者的消息后,处理请求,并在接收者消息上准备返回结果。当发送者观测到接收者消息的状态为“准备就绪”后,即表示收到了返回的结果。

这样,我们就利用共享内存+轮询完成了一个简单的 IPC。但是这个 IPC 有许多缺点,例如:轮询时会浪费 CPU 资源、多个线程进行通信时共享内存较为不方便等。

IPC 基础

数据传递

从以上简单的 IPC 方案可以看出,IPC 的一个重要功能是在进程间传递数据。消息传递(message passing)是 IPC 中常用的数据传递方式:将数据抽象成一个个的消息进行传递。消息传递往往需要一个“中间人”(如共享内存、内核等)。消息传递的基本接口有以下四个:

1
2
3
4
发送消息:Send(message)
接收消息:Recv(message)
远程过程调用:RPC(req_message, recv_message)
回复消息:Reply(resp_message)

发送消息和接收消息的语义比较直接,远程过程调用(Remote Procedure Call,RPC)可以理解为发送端调用 Send 接口后紧接着调用 Recv 接口。Reply 通常用于回复一个远程过程调用。

基于共享内存的消息传递

在前面简单的 IPC 方案中,我们已经看到了进程是如何使用共享内存来进行通信的。这种方式的一个特点是:操作系统在通信过程中不干预数据传输。从操作系统的角度看,共享内存为两个(或多个)进程在其虚拟地址空间中映射了同一段物理内存,这些方案里操作系统通常不参与后续的通信过程。

操作系统辅助的消息传递

操作系统辅助的消息传递指内核对用户态提供通信的接口,如 SendRecv 等。进程可以直接使用这些接口,将消息传递给另一个进程,而不需要建立共享内存和执行轮询内存数据等操作。

当两个进程 $P$ 和 $Q$ 通过内核接口进行 IPC 时,其需要

  1. 通过特定的内核接口建立一个通信连接。这里的建立通信链接更多的是一个抽象的建立过程,具体的形式由内核决定,如内核可以通过维护一个数据结构来记录建立好连接的进程对。
  2. 通过 SendRecv 接口传递消息。

共享内存和操作系统辅助传递的对比

从性能的角度来看,共享内存在传递消息时可以实现理论上的零内存拷贝传输。这里的内存拷贝,指的是将数据从内存的一片区域拷贝到内存中的另一片区域。而在操作系统辅助传递方式中,通常需要将数据从发送者的用户态内存拷贝到内核内存,然后将数据从内核内存拷贝到接收者的用户态内存,这个过程包含两次拷贝。

当然,操作系统辅助传递也有优点。第一,操作系统辅助传递的抽象更简单,内核可以保证每一次通信接口的调用都是一个消息被发送或接收,并且可以很好地支持变长消息,而共享内存则需要用户态软件来封装实现这一功能。第二,操作系统辅助传递的安全性更强,可以保证进程之间的隔离性。第三,在多方通信时,在多个进程之间共享内存是不安全的,而操作系统辅助传递则没有这个问题。

控制流转移

对内核来说,实现消息传递机制除了要考虑数据的传输,往往还要附带控制流转移的功能:当一个通信发生时,内核将控制流从发送者进程切换到接收者进程(返回时类似)。在共享内存的方案中,如果没有控制流转移的功能,只能通过不断地轮询来检查是否有消息到来,而这可能会浪费大量 CPU 资源。

一个常见的流程如下:
首先,接收者进程完成初始化后将自己阻塞起来等待消息的到来(如执行阻塞的 Recv)。
之后,发送者进程发起通信(RPC)。在处理该操作时,内核首先将发送者的消息传递给接收者,然后令发送者进程进入阻塞状态,并将接收者进程从阻塞状态唤醒为预备状态。对接收者进程而言,会从阻塞的 Recv 函数中返回一个消息,表明收到了发送者的一个消息。
接收者完成处理,发送消息,然后自己进入阻塞状态。内核将数据传送到发送者进程,并将其唤醒。

结合内核中的调度以及对进程的调度状态的修改,控制流转移可以避免轮询操作,从而将消息高效地进行传递。

单向通信与双向通信

IPC 通常包含三种可能的方向:仅支持单向通信、仅支持双向通信、单向和双向通信均可。

通常来说,单向通信其实是系统软件实现 IPC 的一个基本单元,双向通信时可以基于单向 IPC 来搭建的。在接口上,如果通信建立后,通信的双方分别只能使用 SendRecv 接口,那么这通常对应于单向通信。实际中,很多系统选择的是单向和双向皆可的策略,这样可以比较好地支持各种场景。当然,如管道、信号等只支持单向通信的机制在实际中同样有较多应用。

同步与异步 IPC

IPC 的另一种分类是:同步 IPC 和异步 IPC。简单来说,同步 IPC 在操作时会阻塞进程直到该操作完成;而异步 IPC 则通常是非阻塞的,进程只需要发起一次操作即可返回,而不需要等待其完成。异步 IPC 通常通过轮询内存状态或注册回调函数(如果内核支持)来获取返回结果。

同步 IPC 往往是双向 IPC(或 RPC),即发送者需要等待返回的结果。不过也存在单向 IPC 是同步的,这种情况下发送者虽然不会等待接收者返回结果,但是其会等待接收者接收消息。

在早期的微内核系统中,同步 IPC 往往是唯一的 IPC 方式。这是因为相比异步 IPC,同步 IPC 有更好的编程抽象。例如使用同步 RPC 时,调用者可以将进程间通信看作是一种函数调用,调用返回时也意味着结果返回了。但是同步 IPC 在操作系统发展的过程中,逐渐表现出一些不足。一个典型的问题是并发。当一个服务进程要响应很多客户进程的通信时,在同步 IPC 的实现下为了服务进程往往需要创建大量工作线程去响应不同的客户进程,否则可能出现客户进程阻塞的现象。但是这里也有一个问题,过多的工作线程会浪费系统资源。而使用异步 IPC 则可以在并发通信时避免这些问题。总的来看,目前大部分操作系统都会选择同时实现同步和异步的 IPC,以满足不同的应用需求。

超时机制

由于进程间有隔离性,因此有一个问题时:通信的双方很难确认对方的状态。例如在同步 IPC 中,当控制流从调用者传递到被调用者后,如果被调用者恶意地不返回到调用者,那么就会使得调用者无法继续执行。异步 IPC 也有类似的问题。即使控制流不会被被调用者恶意地抢占,调用者仍然有可能花费很长时间等待一个请求的处理,例如当被调用者十分忙碌时。

为了解决这个问题,IPC 的设计中引入了超时机制。超时机制扩展了 IPC 通信双方的接口,允许发送者/接收者指定它们发送/接收请求的等待时间。例如,一个用户进程可以花费 5 秒等待文件系统进程的 IPC 请求处理操作。如果超过这个时间仍没有反馈,则由操作系统内核结束这次 IPC 调用,返回一个超时错误。超时机制允许进程为一次通信的等待时间设置一个上限,从而避免类似拒绝服务的攻击情况出现。

但是在实际中,大部分进程难以决定一个合理的超时。例如前面等待文件系统进程的 5 秒,当处理的数据量非常大时,5 秒可能不够文件系统进程处理请求。一个过短的超时显然不行,但是一个过长的超时又容易使得调用者无法察觉被调用者的异常。因此,内核常引入两个特殊的超时选择:“永不返回”和“立即返回”。“永不返回”和引入超时机制之前的状态类似;而“立即返回”则是说只有当前被调用者处于可以立即响应的状态才会真的发起通信,否则就直接返回。更注重安全性时应该选择“立即返回”,更注重功能性时则应选择“永不返回”,这由发送者决定。

直接通信与间接通信

在建立一个连接时,虽然不同的系统有不同的实现,但是通常可以分成两类:直接通信间接通信。在直接通信中,通信的进程一方需要显式地标识另一方。例如,Send(P,message) 代表向 $P$ 进程发送一则消息;Recv(Q, message) 代表从 $Q$ 进程接收一则消息。直接通信下连接的建立是自动的,在具体交互时通过标识的名称完成。这就意味着,一个连接会唯一地对应一对进程。连接本身可以是单向的,也可以是双向的。

在间接通信中,消息传递通过一个中间的信箱来完成。每个信箱有其唯一的标识符,而进程间通过同一个信箱来交换消息。也就是说,进程间连接的建立发生在共享一个信箱时。而每对进程可以通过共享多个信箱来建立多个连接。连接同样可以时单向的或双向的。

权限检查

进程间的通信通常依赖于一套权限检查的机制来保证连接的安全性。在如 Linux 这样的宏内核系统中,通常将安全检查机制和文件的权限检查机制结合在一起。一个进程和其他进程通信,必须要通过权限的检查。例如,seL4 等微内核系统中的 Capability 机制,会将所有的通信连接抽象成一个个的内核对象。而每个进程对内核对象的访问权限由 Capability 来刻画。在宏内核系统中,如 Linux 中,通常会复用其有效用户/有效组的文件权限,以刻画进程对某个连接的权限。

命名服务

权限检查机制带来了安全性,但是权限如何分发呢?通常,权限的分发会通过一个用户态的服务——**命名服务(naming server)**来处理。命名服务进程则指代提供该服务的具体进程。

命名服务就像一个全局看板,可以协调服务端进程和客户端进程之间的通信。简单来说,服务端进程可以将自己提供的服务告诉命名服务进程,例如文件系统进程可以注册一个“文件系统服务”。客户端进程可以去命名服务上查询当前服务,并选择自己希望建立连接的服务去尝试获取权限。具体是否分发权限给对应的客户端进程,是由命名服务和对应的服务端进程根据特定的策略来判断的,例如文件系统进程可能允许任意线程进行连接,一个数据库进程则可能需要客户端提供私钥签名的证书。使用命名服务有很多的好处,比如各个服务不再是内核中的 ID 等抽象的表示,而是对应用更加友好的“名字”。

宏内核系统中的 IPC

一般来说,宏内核系统中 IPC 的方式主要有:管道、消息队列、信号量、共享内存、信号、套接字。

管道

管道(pipe)是宏内核场景下重要的进程间通信机制。正如其名,管道是两个进程间的一条通道,一端负责投递,一端负责接收。例如,我们执行 Linux 下的 shell 命令

1
ps aux | grep target

来查看是否含有关键字为 target 的进程在运行。这里其实有两个命令:ps 和 grep,通过 shell 的管道接口 |,将第一个命令的输出投递到一个管道中,而这条管道的出口则是第二个命令的输入。

管道是单向的 IPC,内核中通常有一定的缓冲区来缓冲消息,而通信的数据(消息抽象)是字节流,需要应用自己去对数据进行解析。

具体实现上,管道在 UNIX 系列的系统中会被当作一个文件。内核会为用户态提供代表管道的文件描述符,让其可以通过文件系统相关的系统调用来使用。管道的行为和 FIFO 队列非常像,最早传入的数据会被最先读出来。

当一个进程尝试在输出端对一个空管道进行读取时,会有两种情况:如果系统发现还没有任何进程有这个管道的写端口,就会得到 EOF(End-of-File);否则,进程会阻塞在这个系统调用上,直到数据到来。第一种情况发生的可能是,管道的两个端口在 UNIX 系列系统的内核中以两个独立的文件描述符存在的,写端口有可能被进程给主动关闭了。对于第二种情况,进程可以通过配置非阻塞来避免阻塞。

命名管道和匿名管道

在经典的 UNIX 实现中,管道有两类:命名管道和匿名管道。主要区别在于它们的创建方式。匿名管道通过 pipe 的系统调用创建,在创建的同时进程会拿到读写的端口(两个文件描述符)。由于整个管道没有全局的“名字”,因此只能通过这两个文件描述符来使用它。这种情况下,通常结合 fork 的使用,即继承的方式来建立父子进程间的连接。例如,父进程创建好了一个管道,然后使用 fork 创建子进程。子进程会有用父进程的系统资源,也就有了该管道的读写端口,因此可以和父进程通过该管道进行通信。这种方式对于父子进程等有着创建关系的进程间通信比较方便,但是对于两个关系较远的进程就不太适用。

命名管道通过 mkfifo 来创建,在创建时会指定一个全局的文件名,由这个文件名(例如 /tmp/namedpipe)来指代一个具体的管道。通过这种方式,只要两个进程通过一个相同的管道名进行创建(并且都拥有权限),就可以实现在任意两个进程间建立管道的通信连接。

System V 消息队列

相比于本文介绍的其他宏内核通信机制, 消息队列是唯一一个以消息为数据抽象的通信机制。应用可以通过消息队列来发送或接收消息,发送和接收的接口是由内核提供的。消息队列是一种非常灵活的通信方式,可以有多个发送者和多个接收者。并且在 Linux 中,消息队列中的每个消息都有其类型抽象,使得发送者和接受者可以根据消息的类型来选择性地处理消息。

消息队列的结构

消息队列在 Linux 内核中的实现形式是消息链表。当创建新的消息队列时,操作系统将在内核中新建一个消息链表,作为消息队列的内核对象。队列的消息有一个消息头部指针,即链表中的头结点,它指向队列中的第一个消息(或者为空)。每个消息都会有指向下一个消息的指针(或者为空)。

在消息结构体中,除了指针外,就是消息的内容。消息的内容有两个部分:类型和数据。数据是一段内存数据,和管道中的字节流类似。类型是用户态程序为每个消息指定的。在消息队列的设计中,内核不需要知道类型的语义,仅仅是保存,以及基于类型进行简单的查找。

消息队列的基本操作

消息队列一般有四个基本操作:msggetmsgsndmsgrcvmsgctl,这四个操作在 Linux 上被实现为系统调用。

msgget 允许进程获取已有的消息队列的连接,或者创建一个新的消息队列。只要有对应的权限,消息队列允许任意数量的进程连接到同一通信连接上(即同一队列上)。
msgctl 可以控制和管理一个消息队列,如修改消息队列的权限信息或删除消息队列。
msgsnd 可以往消息队列上发送消息,msgrcv 可以从消息队列上接收消息。多个进程可以同时往消息队列上发送和从消息队列上接收消息。大部分情况下这两个过程是非阻塞的:对发送者来说,只要队列有空闲的空间就可以向其发送消息;对接收者来说,只要队列中有未读消息就可以从队列中读取消息。如果发送消息时队列中没有空间或者读取消息时队列中没有消息可以读,默认的操作是阻塞进程,直到有空间腾出或者有新的消息到达。

Linux 中的消息队列

在 Linux 中,一个消息队列如果被创建,除非显式地将其删除,否则其生命周期是和内核一致的。其次,消息队列的内存空间是有限制的,因此消息队列并不适合传递长消息,一般建议用共享内存来传递长消息。最后,消息在用户态和内核态之间传递,会有内存拷贝的开销。

System V 信号量

和其他 IPC 机制传递数据的方案不同,信号量在实际的使用中主要是用作进程间的“同步”。有些场景下,多个进程需要依赖于进程间通信来同步彼此的状态,如执行的顺序等。信号量本身能传递的数据量很少,一般来说仅有一个共享的整形计数器,该计数器通常由内核维护,而对信号量的操作则需要经过内核系统调用。

信号量的主要操作有两个原语:P 和 V。P 是荷兰语 Probeer(尝试)的缩写,表示尝试一个操作(在信号量中通常是将一个计数器减 1),该操作的失败会将该进程切换到阻塞状态,直到其他进程执行了 V 操作。
V 是荷兰语 Verhoog(增加)的缩写,在信号量中是将一个计数器加 1。V 可能会唤醒一个因 P 操作而陷入阻塞的进程。P 和 V 的操作都是在信号量结构上进行的,该结构会封装一个计数器。

信号量的一个简单的使用是将其值的范围限定在 0 到 1 之间。但执行 P 原语时,会将计数器减 1;当执行 V 原语时,会将计数器加 1。如果计数器被 P 原语减为负数,则会阻塞该进程,直到其他进程使用 V 原语将计数器修改成非负数时,则会唤醒该进程。

信号量一种用途是同步进程之间的操作。例如将一个信号量限定在 0 到 1 之间,初始值为 0。进程 A 在执行之前调用 P 原语,此时信号量的计数器减去 1,变成 -1,进程 A 被阻塞。进程 B 在执行结束后使用 V 原语,将信号量的计数器增加为 0,此时进程 A 被唤醒。这样,我们就使用一个信号量,保证进程 A 和 B 的执行顺序为先 B 后 A。

信号量的另一种典型用途是控制共享资源的访问,此时 P 原语和 V 原语必须成对出现,在获取资源时调用 P 原语,在释放资源时调用 V 原语。例如,某个资源对应的信号量范围在 0 到 1 之间,初始值为 1。进程 A 先访问该资源,调用 P 原语,将信号量的计数器修改为 0,成功获取资源并执行。进程 B 随后访问该资源,调用 P 原语,信号量的计数器修改为 -1,此时进程 B 被阻塞。过后一段时间,进程 A 释放资源,调用 V 原语,将进程 B 唤醒,进程 B 此时获取了资源并开始执行。进程 B 执行完成后,调用 V 原语释放资源,此时信号量的计数器恢复为 1。

System V 共享内存

对于消息队列、管道、信号量等机制,内核都提供了完整的接口。虽然这些完善的抽象方便了用户进程的使用,但是其中涉及的数据拷贝和控制流转移等处理逻辑影响了这些抽象的性能。共享内存将两个(或多个)进程中的一片虚拟地址空间映射到同一片物理地址空间上,当映射建立完成后,内核就不再参与进程间的通信。这样,在通信时就不存在将数据从用户态内存拷贝到内核态内存的过程,也就增强了 IPC 的性能。因此,共享内存适用于需要传递长消息的场景。

信号

管道、消息队列、共享内存等方式主要关注数据传输的设计,而信号(signal)的一个特点是单向的事件通知。信号量也有通知能力,但是需要进程主动查询计数器或者陷入阻塞状态等待通知。使用信号,一个进程可以随时发送一个事件到特定的进程、线程或进程组等。接收事件的进程不需要阻塞等待该事件,内核会帮助其切换到对应的处理函数中响应信号事件,并且在处理完成后恢复之前的上下文。

信号传递的信息很短,只有一个编号(信号类型)。例如我们在 Shell 中使用 Ctrl + C 中止一个运行中的进程,就是 Shell 发出了一个 SIGINT 信号,使得进程中止。

在通信的场景下,一个进程会为一些特定的信号注册处理函数。当进程接收到对应的信号时,内核会自动地将用户的控制流切换到对应的处理函数中。

在 Linux 中,其早期有 31 个信号(1 ~ 31 号),后续 POSIX 标准又引入了编号从 32 到 64 的其他信号。Linux 传统信号被称为常规信号,POSIX 引入的信号称为实时信号。一个进程如果多次收到某个常规信号,内核只会记录一次,而多个相同的实时信号则不会被丢弃。

发送信号

信号的发送者可以是用户态进程,也可以是内核。一个用户态进程可以通过调用内核提供的系统接口,如

1
2
kill(pid_t pid, int sig)
tgkill(int tgid, int tid, int sig)

向进程或线程发送特定的信号。内核通过不同的系统调用及其参数,来确定接收信号的目标进程或线程,将信号事件添加到其等待队列上。添加操作需要区别实时信号和常规信号,当发送的信号是非实时的信号,并且现在还未处理该信号时,内核会忽略掉这个事件。

处理信号

Linux 提供了一个 sigprocmask 系统调用,允许用户设置队特定信号的阻塞状态。当一个信号被阻塞后,Linux 将不会再触发这个信号的响应函数,直到该信号被解除阻塞。信号被阻塞并不会影响其被添加到等待队列上,当进程解除了对这个信号的阻塞时,其可能需要处理在阻塞期间收到的信号。很多重要的信号是不能被阻塞的,例如 SIGKILL。

在 Linux 等 UNIX 系统的设计中,信号得到处理的时机通常是内核执行完异常、中断或系统调用等返回到用户态的时刻,此时内核会检查一个状态位来判断是否有信号需要处理。如果有,则先去处理该信号事件。Linux 提供了 signalsigaction 等系统调用,允许用户为特定的信号注册一个用户态响应函数。

可重入处理函数

在信号处理函数执行的过程中,如果其他进程又发送了一个该信号事件过来,并且当前进程由于中断等下陷到内核,那么当前进程可能会暂停当前信号处理函数的执行,并且重新切换到处理函数的开头去处理新的信号事件,然后再回到原来的地方继续执行原本的信号响应函数。这种嵌套需要信号处理函数是可重入的。简单来说,就是在信号处理的过程中,有可能会产生并发,并且调用的函数就是信号处理函数本身。假如此时在信号处理函数中使用了全局锁保护一段关键操作时,就有可能导致死锁:信号处理函数在第一次被调用时获得了全局锁,在进行到一半时又被从头执行,此时原本的信号处理函数没有释放获得的锁,因而新的处理函数无法获取全局锁,进而造成死锁的局面。

可重入(reentrant)函数允许多个任务并发使用,而不用担心共享数据的错误。一般来说,实现一个可重入函数有以下几个要求:

  • 不使用静态数据,或者静态数据都是只读的。
  • 尽量只使用本地数据。
  • 在必须使用全局共享数据的情况下,需要保护对全局数据的访问(也需要避免死锁)。
  • 避免在函数中修改自己的代码。
  • 不调用不可重入的函数,很多库函数的实现(例如 malloc)是不可重入的,需要注意。

Socket

**套接字(Socket)**是一种既可用于本地,又可跨网络使用的通信机制。因此应用程序可以使用 Socket 来实现本地进程间的通信和跨机器的通信。在进行本地通信时,通信双方通常使用本地回环地址(127.0.0.1),然后各自绑定在不同的端口上。操作系统网络协议栈会识别回环地址,将通信消息转发到目标端口对应的进程。此外,也可以用本地文件系统的一个路径作为地址,这通常被叫做 UNIX domain socket。使用套接字通常有两种协议,TCP 和 UDP。TCP 传输可以保证数据的正确性,UDP 则在传输性能上更佳。

参考资料

《现代操作系统:原理与实现》,陈海波,夏虞斌等著,机械工业出版社


操作系统(三):进程间通信
https://thumarklau.github.io/2021/03/23/OS-IPC/
作者
MarkLau
发布于
2021年3月23日
许可协议