进程间通信机制

进程通信(Inter-Process Communication, IPC)是指进程之间的信息交换。其所交换的信息量,少则是一个状态或数值,多则是成千上万个字节。多个进程为了协调完成一项工作,相互之间必须能够进行通信。进程的互斥和同步可归结为低级通信。进程的高级通信是指用户可直接利用系统所提供的一组通信命令,高效地传送大量数据的一种通信方式。操作系统隐藏了进程通信的实现细节,即对用户来说是透明的。这样就大大简化了通信程序编程上的复杂性。

进程间通信机制

Linux 操作系统支持以下几种进程间通信机制:

  • Unix IPC 机制
    • 信号(signal)
    • 管道(pipe)和命名管道(named pipe)
  • System V 的 IPC 机制
    • 信号量(semaphore)
    • 消息队列(message queue)
    • 共享内存(shared memory)
  • 网络通信的套接字机制
    • 套接字(socket)
  • 全双工管道机制

以上各种通信机制都提供了相应的接口,IPC 结构图如下所示:

IPC

信号

信号是用来向一个或多个进程发送异步事件的软件机制,它类似于硬件中断,所以也叫软中断。信号不仅可以从键盘中断中产生,进程对虚拟内存的非法存取等系统错误环境下也会有信号产生,此外信号还被 shell 程序用来向其子进程发送任务控制命令。

系统中有一组被详细定义的信号类型,使用 man 7 signal 可以看到详细介绍。除了 SIGSTOP(进程终止执行)和 SIGKILL(进程退出)两个信号外,进程可以忽略其余信号。信号没有相对优先级,如果在同一时刻对于一个进程产生了两个信号,则它们将可能以任意顺序到达进程并进行处理。同时 Linux 并不提供处理多个相同类型信号的方式。

信号个数受到处理器字长的限制。32 位字长的处理器最多可以有 32 个信号,而 64 位处理器可以有最多 64 个信号。Linux 通过存储在进程 task_struct 中的信息来实现信号。当前未处理的信号保存在 signal 域中,并带有保存在 blocked 中的被阻塞信号的屏蔽码。除了 SIGSTOP 和 SIGKILL 外,所有的信号都能被阻塞。

系统中只有核心和超级用户进程可以向其他所有进程发送信号,普通进程只能向具有相同 uid 和 gid 的进程或者在同一进程组中的进程发送信号。信号是通过设置 task_struct 结构中 signal 域里的某一位来产生的。如果进程没有阻塞信号并且处于可中断的等待状态,则可以将其状态改成 Running,同时如确认进程还处在运行队列中,就可以通过信号唤醒它。这样系统下次发生调度时,调度管理器将选择它运行。信号必须等待到进程再次运行时才交给它,每次进程从系统调用中退出前,它都会检查 signal 和 blocked 域,看是否有可以立刻发送的非阻塞信号。对当前不可阻塞信号的处理代码放置在 sigaction 结构中。

管道

管道是一个先进先出、大小固定的缓冲区,容量为 1 页(4KB),用于两个进程之间的单向数据传递。当管道有空间时,写者进程把数据送入管道,否则将被阻塞;如果管道中没有数据或读者进程需要的数据多于其中的数据,读者进程会被阻塞,否则执行读者进程的请求。整个过程由操作系统监控完成,互斥地访问管道。当传送的数据量大于管道的容量时,可以通过同步机制分次传送数据。

无名管道

例如:Linux shell 程序中的重定向操作:

1
$ ls -l | more

在这个管道应用中,ls 列出当前目录的输出被作为标准输入送到 more 程序中按格式显示处理。无名管道是将两个相关联的进程联系在一起。shell 程序负责在进程间建立临时的管道。

在 Linux 中,管道是通过指向同一个临时 VFS(Virtual File System,虚拟文件系统)inode 的两个 file 数据结构来实现的,此 VFS inode 指向内存中的一个物理块。当写者进程向管道中写入数据时,字节被复制到共享数据页面中,当读者进程从管道中读时,字节从共享数据页面中复制出来。

Linux 必须同步对管道的访问。它必须保证读者和写者进程以确定的步骤执行,为此需要使用锁、等待队列和信号等同步机制。以写数据为例,如果没有足够的空间容纳对所有写入管道的数据,或者管道被读者进程加锁时,当前进程将在管道 inode 的等待队列中睡眠,同时调度管理器开始执行以选择其他进程来执行。当有足够的空间或者管道被解锁时,它将被读者唤醒,然后执行写操作。当两个进程对管道的使用结束时,管道 inode 和共享数据页面将同时被释放。

命名管道

Linux 还支持命名管道(named pipe),即 FIFO 管道,为两个不相关的进程提供通信手动。命名管道不是临时对象,它们是文件系统中的实体并且可以通过 mknod 命令来创建。进程只要拥有适当的权限就可以自由使用 FIFO 管道。在写者进程使用之前,Linux 必须让读者进程先打开此 FIFO 管道;任何读者进程从中读取之前必须有写者进程向其写入数据。

消息队列

Linux 系统也支持 System V 的进程间通信机制,包括消息序列、信号量和共享内存。所有的 IPC 对象在 Linux 中有一个公共的 ipc_perm 结构,它含有进程拥有者、创建者和组标志符,对此对象(拥有者,组及其他)的存取模式以及 IPC 对象键。

消息是按一定格式封装起来的消息。每个进程都有一个与之关联的消息队列,接收进程按时间顺序或消息类型从消息队列取走消息。如果进程向一个满队列发送消息或从一个空队列取走消息都会被阻塞。

Linux 系统维护着一个 msgque 消息队列链表,其中每个元素指向一个描述消息队列 msqid_ds 结构,该结构完整地描述一个消息队列。每个 msqid_ds 结构包含一个 ipc_perm 结构和指向已经进入此队列消息的指针。另外,Linux 保留有关队列修改时间信息,如上次系统向队列中写入的时间等。msqid_ds 包含两个等待队列:一个为队列写入进程使用而另一个由队列读取进程使用。

每次进程试图向写入队列写入消息时,系统将把其有效用户和组标志符与此队列的 ipc_perm 结构中的模式进行比较。如果允许写入操作,则把此消息从该进程的地址空间复制到 msg 数据结构中,并放置到此消息队列尾部。如果消息队列的长度已满,则该写入进程将被阻塞,并调度新进程运行。若有消息被取走时,该进程将被唤醒。读进程执行时将选择队列中第一个消息或者某特定类型的消息。如果没有消息可以满足此要求,读进程将被阻塞,并运行调度程序。当有新消息写入队列时,进程将被唤醒。

信号量

信号量是用一个整数表示系统当前资源的使用情况,当信号量大于或等于 0 时,其值表示可用资源的数量,当它小于 0 时,其值表示等待该资源的进程数。信号量是 wait 和 signal 原语的推广,可以通过它实现进程的同步与互斥。例如用信号量实现临界区(critical region)的互斥,即在某一时刻在此区域内的代码只能被一个进程执行。

数据结构

信号量在 Linux 中使用以下几个数据结构来表示:

  • sem:表示系统中的每个信号量
  • semid_ds:表示信号量的集合
  • sem_queue:表示由每个信号量集合所构成的队列

semid_ds 结构的 sem_base 指向一个 sem 数组,进程可以使用系统调用来操作这些信号量数组。

实现过程

在执行信号量操作时,Linux 首先将检查是否所有操作已经成功。如果操作值与信号量当前数组相加大于 0,或者操作值与信号量当前值都是 0,操作将会成功。如果所有信号量操作失败,Linux 仅仅会把那些操作标志没有要求系统调用为非阻塞类型的进程挂起。进程挂起后,Linux 必须保存信号量操作的执行状态并将当前进程放入等待队列。系统还堆栈上建立 sem_queue 结构并填充各个域。 这个 sem_queue 结构将被放到信号量对象等待队列的尾部。系统把当前进程置入 sem_queue 结构中的等待队列中,然后执行调度程序。

如果所有这些信号量操作都成功则无须挂起当前进程,Linux 将对信号量数组中的其他成员进行相同操作,然后检查那些处于等待或挂起状态的进程。首先,Linux 将依次检查挂起队列(sem_pending)中的每个成员,看信号量操作能否继续。如果可以则将其 sem_queue 结构从挂起链表中删除并对信号量数组发出信号灯操作。Linux 还将唤醒处于睡眠状态的进程并使之成为下一个运行的进程。如果在对挂起队列的遍历过程中有的信号量操作不能完成则 Linux 将一直重复此过程,直到所有信号量操作完成且没有进程需要继续睡眠。

与信号量有关的系统调用

  • semget():创建新的信号量集合或存取一个已有的信号量集合。
  • semop(): 当操作数和信号量的值相加大于或等于 0 时,即进程请求的资源能够满足,操作成功,返回 0;若相加后其中某个值小于 0,资源不能满足,将进程阻塞,操作不成功。
  • semctl():在信号量集合上完成指定的命令操作。

死锁

死锁是信号量使用过程中可能产生的一个最严重的问题。当一个进程进入临界区时它修改了信号量的值,然后在离开临界区时由于运行失败或者被 kill 而没有改回信号量时,死锁将会发生。

Linux 为了避免死锁的发生,为每个进程维护至少一个对应于信号量数组的 sem_undo 结构,它保存了完成信号量操作之前的状态。当对信号量进行操作时,信号量变化的数值被放入进程的 sem_undo 结构的该信号的入口中。当进程被删除时,Linux 将遍历该进程的 sem_undo 集合对信号量数组使用调整值。如果信号量集合被删除而 sem_undo 数据结构还在进程的 task_struct 结构中,则此信号灯数组标志符将被置为无效。此时信号量清除代码只需丢弃 sem_undo 即可。

共享存储区

共享存储区是指被多个进程共享的虚存中的一个数据块,进程利用它来实现通信。此虚拟内存的页面出现在每个共享进程页表中,但位置可以不同。每个进程有相应的读或读写权限,对共享存储区的互斥访问必须依赖于其他机制,如信号量。

每个新创建的共享存储区由一个 shmid_ds 数据结构来表示,它描述共享存储区的大小,进程如何使用以及共享存储区映射到各自地址空间的方式。由共享存储区创建者控制对此内存的存取权限。

每个使用此共享内存的进程必须它能通过系统调用将其连接到虚拟内存上,但在连接时并没有创建共享存储区,只有进程访问它时才创建。当进程首次访问共享虚拟内存中的页面时将产生缺页中断。当取回此页面后,Linux 找到了描述此页面的数据结构。它包含指向使用此种类型虚拟内存的处理函数地址指针。当发生共享内存页面缺页错误时,错误处理代码将在此 shmid_ds 对应的页表入口链表中寻找此页面是否在内存。如果不在,则为其分配物理页面并创建页表入口。同时还将它放入当前进程的页表中,此入口被保存在 shmid_ds 结构中。下个访问此内存的进程就会连接到此页面上。这样,第一个访问虚拟内存页面的进程创建这块内存,随后的进程把此页面加入到各自的虚拟地址空间中。

不再使用共享存储区的进程将断开与之的连接,其对应的页面结构将从 shmid_ds 结构中删除并回收。当前进程对应此共享内存地址的页表入口也将被更新并置为无效。当最后一个进程断开与共享内存的连接时,当前位于物理内存中的共享内存页面将被释放,同时删除 shmid_ds 结构。