Interprocess Communication Mechanisms (进程间通讯机制)

优质
小牛编辑
123浏览
2023-12-01

进程之间互相通讯并和核心通讯,协调它们的行为。 Linux 支持一些进程间通讯( IPC )的机制。信号和管道是其中的两种, Linux 还支持系统 V IPC (用首次出现的 Unix 的版本命名)的机制。

5.1 Signals (信号)

信号是 Unix 系统中使用的最古老的进程间通讯的方法之一。用于向一个或多个进程发送异步事件的信号。信号可以用键盘终端产生,或者通过一个错误条件产生,比如进程试图访问它的虚拟内存中不存在的位置。 Shell 也使用信号向它的子进程发送作业控制信号。

有一些信号有核心产生,另一些可以由系统中其他有权限的进程产生。你可以使用 kill 命令( kill –l )列出你的系统的信号集,在我的 Linux Intel 系统输出:

1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL

5) SIGTRAP 6) SIGIOT 7) SIGBUS 8) SIGFPE

9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2

13) SIGPIPE 14) SIGALRM 15) SIGTERM 17) SIGCHLD

18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN

22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ

26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO

30) SIGPWR

在 Alpha AXP Linux 系统上编号不同。进程可以选择忽略产生的大多数信号,有两个例外: SIGSTOP (让进程停止执行)和 SIGKILL (让进程退出)不可以忽略,虽然进程可以选择它如何处理信号。进程可以阻塞信号,如果它不阻塞信号,它可以选择自己处理或者让核心处理。如果核心处理,将会执行该信号的缺省行为。例如,进程接收到 SIGFPE (浮点意外)的缺省动作是产生 core 并退出。信号没有固有的优先级,如果一个进程同时产生了两个信号,它们会以任意顺序出现在进程中并按任意顺序处理。另外,也没有机制可以处理统一种类的多个信号。进程无法知道它接收了 1 还是 42 个 SIGCONT 信号。

Linux 用进程的 task_struct 中存放的信息来实现信号机制。支持的信号受限于处理器的字长。 32 位字长的处理器可以有 32 中信号,而 64 位的处理器,比如 Alpha AXP 可以有多达 64 种信号。当前待处理的信号放在 signal 域, blocked 域放着要阻塞的信号掩码。除了 SIGSTOP 和 SIGKILL ,所有的信号都可以被阻塞。如果产生了一个被阻塞的信号,它一直保留待处理,直到被解除阻塞。 Linux 也保存每一个进程如何处理每一种可能的信号的信息,这些信息放在一个 sigaction 的数据结构数组中,每一个进程的 task_struct 都有指针指向对应的数组。这个数组中包括处理这个信号的例程的地址,或者包括一个标志,告诉 Linux 该进程是希望忽略这个信号还是让核心处理。进程通过执行系统调用改变缺省的信号处理,这些调用改变适当的信号的 sigaction 和阻塞的掩码。

并非系统中所有的进程都可以向其他每一个进程发送信号,只有核心和超级用户可以。普通进程只可以向拥有相同 uid 和 gid 或者在相同进程组的进程发送信号。通过设置 task —— struct 的 signal 中适当的位产生信号。如果进程不阻塞信号,而且正在等待但是可以中断(状态是 Interruptible ),那么它的状态被改为 Running 并确认它在运行队列,通过这种方式把它唤醒。这样调度程序在系统下次调度的时候会把它当作一个运行的候选。如果需要缺省的处理, Linux 可以优化信号的处理。例如如果信号 SIGWINCH ( X window 改变焦点)发生而使用缺省的处理程序,则不需要做什么事情。

信号产生的时候不会立刻出现在进程中,它们必须等到进程下次运行。每一次进程从系统调用中退出的时候都要检查它的 signal 和 blocked 域,如果有任何没有阻塞的信号,就可以发送。这看起来好像非常不可靠,但是系统中的每一个进程都在调用系统调用,比如向终端写一个字符的过程中。如果愿意,进程可以选择等待信号,它们挂起在 Interruptible 状态,直到有了一个信号。 Linux 信号处理代码检查 sigaction 结构中每一个当前未阻塞的信号。

如果信号处理程序设置为缺省动作,则核心会处理它。 SIGSTOP 信号的缺省处理是把当前进程的状态改为 Stopped ,然后运行调度程序,选择一个新的进程来运行。 SIGFPE 信号的缺省动作是让当前进程产生 core ( core dump ),让它退出。变通地,进程可以指定自己的信号处理程序。这是一个例程,当信号产生的时候调用而且 sigaction 结构包括这个例程的地址。 Linux 必须调用进程的信号处理例程,至于具体如何发生是和处理器相关。但是,所有的 CPU 必须处理的是当前进程正运行在核心态,并正准备返回到调用核心或系统例程的用户态的进程。解决这个问题的方法是处理该进程的堆栈和寄存器。进程程序计数器设为它的信号处理程序的地址,例程的参数加到调用结构或者通过寄存器传递。当进程恢复运行的时候显得信号处理程序是正常的调用。

Linux 是 POSIX 兼容的,所以进程可以指定调用特定的信号处理程序的时候要阻塞的信号。这意味着在调用进程的信号处理程序的时候改变 blocked 掩码。信号处理程序结束的时候, blocked 掩码必须恢复到它的初始值。因此, Linux 在收到信号的进程的堆栈中增加了对于一个整理例程的调用,把 blocked 掩码恢复到初始值。 Linux 也优化了这种情况:如果同时几个信号处理例程需要调用的时候,就在它们堆积在一起,每次退出一个处理例程的时候就调用下一个,直到最后才调用整理例程。

5.2 Pipes (管道)

普通的 Linux shell 都允许重定向。例如:

$ ls | pr | lpr

把列出目录文件的命令 ls 的输出通过管道接到 pr 命令的标准输入上进行分页。最后, pr 命令的标准输出通过管道连接到 lpr 命令的标准输入上,在缺省打印机上打印出结果。管道是单向的字节流,把一个进程的标准输出和另一个进程的标准输入连接在一起。没有一个进程意识到这种重定向,和它平常一样工作。是 shell 建立了进程之间的临时管道。在 Linux 中,使用指向同一个临时 VFS I 节点(本身指向内存中的一个物理页)的两个 file 数据结构来实现管道。图 5.1 显示了每一个 file 数据结构包含了不同的文件操作例程的向量表的指针:一个用于写,另一个从管道中读。这掩盖了和通用的读写普通文件的系统调用的不同。当写进程向管道中写的时候,字节拷贝到了共享的数据页,当从管道中读的时候,字节从共享页中拷贝出来。 Linux 必须同步对于管道的访问。必须保证管道的写和读步调一致,它使用锁、等待队列和信号( locks , wait queues and signals )。

参见 include/linux/inode_fs.h

 

当写进程向管道写的时候,它使用标准的 write 库函数。这些库函数传递的文件描述符是进程的 file 数据结构组中的索引,每一个都表示一个打开的文件,在这种情况下,是打开的管道。 Linux 系统调用使用描述这个管道的 file 数据结构指向的 write 例程。这个 write 例程使用表示管道的 VFS I 节点存放的信息,来管理写的请求。如果有足够的空间把所有的字节都写导管到中,只要管道没有被读进程锁定, Linux 为写进程上锁,并把字节从进程的地址空间拷贝到共享的数据页。如果管道被读进程锁定或者空间不够,当前进程睡眠,并放在管道 I 节点的等待队列中,并调用调度程序,运行另外一个进程。它是可以中断的,所以它可以接收信号。当管道中有了足够的空间写数据或者锁定解除,写进程就会被读进程唤醒。当数据写完之后,管道的 VFS I 节点锁定解除,管道 I 节点的等待队列中的所有读进程都会被唤醒。

参见 fs/pipe.c pipe_write()

从管道中读取数据和写数据非常相似。进程允许进行非阻塞的读(依赖于它们打开文件或者管道的模式),这时,如果没有数据可读或者管道被锁定,会返回一个错误。这意味着进程会继续运行。另一种方式是在管道的 I 节点的等待队列中等待,直到写进程完成。如果管道的进程都完成了操作,管道的 I 节点和相应的共享数据页被废弃。

参见 fs/pipe.c pipe_read()

Linux 也可以支持命名管道,也叫 FIFO ,因为管道工作在先入先出的原则下。首先写入管道的数据是首先被读出的数据。不想管道, FIFO 不是临时的对象,它们是文件系统中的实体,可以用 mkfifo 命令创建。只要有合适的访问权限,进程就可以使用 FIFO 。 FIFO 的大开方式和管道稍微不同。一个管道(它的两个 file 数据结构, VFS I 节点和共享的数据页)是一次性创建的,而 FIFO 是已经存在,可以由它的用户打开和关闭的。 Linux 必须处理在写进程打开 FIFO 之前打开 FIFO 读的进程,以及在写进程写数据之前读的进程。除了这些, FIFO 几乎和管道的处理完全一样,而且它们使用一样的数据结构和操作。

Sockets

注意:写网络篇的时候加上去

    System V IPC mechanisms (系统 V IPC 机制)

    Linux 支持三种首次出现在 Unix 系统 V ( 1983 )的进程间通讯的机制:消息队列、信号灯和共享内存( message queues , semaphores and shared memory )。系统 V IPC 机制共享通用的认证方式。进程只能通过系统调用,传递一个唯一的引用标识符到核心来访问这些资源。对于系统 V IPC 对象的访问的检查使用访问许可权,很象对于文件访问的检查。对于系统 V IPC 对象的访问权限由对象的创建者通过系统调用创建。每一种机制都使用对象的引用标识符作为资源表的索引。这不是直接的索引,需要一些操作来产生索引。

    系统中表达系统 V IPC 对象的所有 Linux 数据结构都包括一个 ipc_perm 的数据结构,包括了创建进程的用户和组标识符,对于这个对象的访问模式(属主、组和其他)和 IPC 对象的 key 。 Key 用作定位系统 V IPC 对象的引用标识符的方法。支持两种 key :公开和四有的。如果 key 是公开的 , 那么系统中的任何进程 , 只要通过了权限检查 , 就可以找到对应的系统 V IPC 对象的引用标识符。系统 V IPC 对象不能使用 key 引用,必须使用它们的引用标识符。

    参见 include/linux/ipc.h

    Message Queues (消息队列)

    消息队列允许一个或多个进程写消息,一个或多个进程读取消息。 Linux 维护了一系列消息队列的 msgque 向量表。其中的每一个单元都指向一个 msqid_ds 的数据结构,完整描述这个消息队列。当创建消息队列的时候,从系统内存中分配一个新的 msqid_ds 的数据结构并插入到向量表中

    每一个 msqid_ds 数据结构都包括一个 ipc_perm 的数据结构和进入这个队列的消息的指针。另外, Linux 保留队列的改动时间,例如上次队列写的时间等。 Msqid_ds 队列也包括两个等待队列:一个用于向消息队列写,另一个用于读。

    参见 include/linux/msg.h

    每一次一个进程试图向写队列写消息,它的有效用户和组的标识符就要和队列的 ipc_perm 数据结构的模式比较。如果进程可以想这个队列写,则消息会从进程的地址空间写到 msg 数据结构,放到消息队列的最后。每一个消息都带有进程间约定的,应用程序指定类型的标记。但是,因为 Linux 限制了可以写的消息的数量和长度,可能会没有空间容纳消息。这时,进程会被放到消息队列的写等待队列,然后调用调度程序选择一个新的进程运行。当一个或多个消息从这个消息队列中读出去的时候会被唤醒。

    从队列中读是一个相似的过程。进程的访问权限一样被检查。一个读进程可以选择是不管消息的类型从队列中读取第一条消息还是选择特殊类型的消息。如果没有符合条件的消息,读进程会被加到消息队列的读等待进程,然后运行调度程序。当一个新的消息写到队列的时候,这个进程会被唤醒,继续运行。 

    Semaphores (信号灯)

    信号灯最简单的形式就是内存中一个位置,它的取值可以由多个进程检验和设置。检验和设置的操作,至少对于关联的每一个进程来讲,是不可中断或者说有原子性:只要启动就不能中止。检验和设置操作的结果是信号灯当前值和设置值的和,可以是正或者负。根据测试和设置操作的结果,一个进程可能必须睡眠直到信号灯的值被另一个进程改变。信号灯可以用于实现重要区域( critical regions ),就是重要的代码区,同一时刻只能有一个进程运行。

    比如你有许多协作的进程从一个单一的数据文件读写记录。你可能希望对文件的访问必须严格地协调。你可以使用一个信号灯,初始值 1 ,在文件操作的代码中,加入两个信号灯操作,第一个检查并把信号灯的值减小,第二个检查并增加它。访问文件的第一个进程试图减小信号灯的数值,如果成功,信号灯的取值成为 0 。这个进程现在可以继续运行并使用数据文件。但是,如果另一个进程需要使用这个文件,现在它试图减少信号灯的数值,它会失败因为结果会是 -1 。这个进程会被挂起直到第一个进程处理完数据文件。当第一个进程处理完数据文件,它会增加信号灯的数值成为 1 。现在等待进程会被唤醒,这次它减小信号灯的尝试会成功。

    每一个系统 V IPC 信号灯对象都描述了一个信号灯数组, Linux 使用 semid_ds 数据结构表达它。系统中所有的 semid_ds 数据结构都由 semary 指针向量表指向。每一个信号灯数组中都有 sem_nsems ,通过 sem_base 指向的一个 sem 数据结构来描述。所有允许操作一个系统 V IPC 信号灯对象的信号灯数组的进程都可以通过系统调用对它们操作。系统调用可以指定多种操作,每一种操作多用三个输入描述:信号灯索引、操作值和一组标志。信号灯索引是信号灯数组的索引,操作值是要增加到当前信号灯取值的数值。首先, Linux 检查所有的操作是否成功。只有操作数加上信号灯的当前值大于 0 或者操作值和信号灯的当前值都是 0 ,操作才算成功。如果任意信号灯操作失败,只要操作标记不要求系统调用无阻塞, Linux 会挂起这个进程。如果进程要挂起, Linux 必须保存要进行的信号灯操作的状态并把当前进程放到等待队列重。它通过在堆栈中建立一个 sem_queue 的数据结构并填满它来实现上述过程。这个新的 sem_queue 数据结构被放到了这个信号灯对象的等待队列的结尾(使用 sem_pending 和 sem_pending_last 指针)。当前进程被放到了这个 sem_queue 数据结构的等待队列中( sleeper ),调用调度程序,运行另外一个进程。

    参见 include/linux/sem.h

    如果所有的信号灯操作都成功,当前的进程就不需要被挂起。 Linux 继续向前并把这些操作应用到信号灯数组的合适的成员上。现在 Linux 必须检查任何睡眠或者挂起的进程,它们的操作现在可能可以实施。 Linux 顺序查找操作等待队列( sem_pending )中的每一个成员,检查现在它的信号灯操作是否可以成功。如果可以它就把这个 sem_queue 数据结构从操作等待表中删除,并把这种信号灯操作应用到信号灯数组。它唤醒睡眠的进程,让它在下次调度程序运行的时候可以继续运行。 Linux 从头到尾检查等待队列,直到不能执行信号灯操作无法唤醒更多的进程为止。

    在信号灯操作中有一个问题:死锁( deadlock )。这发生在一个进程改变了信号灯的值进入一个重要区域( critical region )但是因为崩溃或者被 kill 而没有离开这个重要区域的情况下。 Linux 通过维护信号灯数组的调整表来避免这种情况。就是如果实施这些调整,信号灯就会返回一个进程的信号灯操作前的状态。这些调整放在 sem_undo 数据结构中,排在 sem_ds 数据结构的队列中,同时排在使用这些信号灯的进程的 task_struct 数据结构的队列中。

    每一个独立的信号灯操作可能都需要维护一个调整动作。 Linux 至少为每一个进程的每一个信号灯数组都维护一个 sem_undo 的数据结构。如果请求的进程没有,就在需要的时候为它创建一个。这个新的 sem_undo 数据结构同时在进程的 task_struct 数据结构和信号灯队列的 semid_ds 数据结构的队列中排队。对信号灯队列中的信号灯执行操作的时候,和这个操作值相抵消的值加到这个进程的 sem_undo 数据结构的调整队列这个信号灯的条目上。所以,如果操作值为 2 ,那么这个就在这个信号灯的调整条目上增加 -2 。

    当进程被删除,比如退出的时候, Linux 遍历它的 sem_undo 数据结构组,并实施对于信号灯数组的调整。如果删除信号灯,它的 sem_undo 数据结构仍旧停留在进程的 task_struct 队列中,但是相应的信号灯数组标识符标记为无效。这种情况下,清除信号灯的代码只是简单地废弃这个 sem_undo 数据结构。

    Shared Memory (共享内存)

共享内存允许一个或多个进程通过同时出现在它们的虚拟地址空间的内存通讯。这块虚拟内存的页面在每一个共享进程的页表中都有页表条目引用。但是不需要在所有进程的虚拟内存都有相同的地址。象所有的系统 V IPC 对象一样,对于共享内存区域的访问通过 key 控制,并进行访问权限检查。内存共享之后,就不再检查进程如何使用这块内存。它们必须依赖于其他机制,比如系统 V 的信号灯来同步对于内存的访问。

每一个新创建的内存区域都用一个 shmid_ds 数据结构来表达。这些数据结构保存在 shm_segs 向量表中。 Shmid_ds 数据结构描述了这个共享内存取有多大、多少个进程在使用它以及共享内存如何映射到它们的地址空间。由共享内存的创建者来控制对于这块内存的访问权限和它的 key 是公开或私有。如果有足够的权限它也可以把共享内存锁定在物理内存中。

参见 include/linux/sem.h

每一个希望共享这块内存的进程必须通过系统调用粘附( attach )到虚拟内存。这为该进程创建了一个新的描述这块共享内存的 vm_area_struct 数据结构。进程可以选择共享内存在它的虚拟地址空间的位置或者由 Linux 选择一块足够的的空闲区域。

这个新的 vm_area_struct 结构放在由 shmid_ds 指向的 vm_area_struct 列表中。通过 vm_next_shared 和 vm_prev_shared 把它们连在一起。虚拟内存在粘附的时候其实并没有创建,而发生在第一个进程试图访问它的时候。

在一个进程第一次访问共享虚拟内存的其中一页的时候,发生一个 page fault 。当 Linux 处理这个 page fault 的时候,它找到描述它的 vm_area_struct 数据结构。这里包含了这类共享虚拟内存的处理例程的指针。共享内存的 page fault 处理代码在这个 shmid_ds 的页表条目列表中查找,看是否存在这个共享虚拟内存页的条目。如果不存在,它就分配一个物理页,并为它创建一个页表条目。这个条目不但进入当前进程的页表,也存到这个 shmid_ds 。这意味着当下一个进程试图访问这块内存并得到一个 page fault 的时候,共享内存错误处理代码也会让这个进程使用这个新创建的物理页。所以,是第一个访问共享内存页的进程使得这一页被创建,而随后访问的其他进程使得此页被加到它们的虚拟地址空间。

当进程不再需要共享虚拟内存的时候,它们从中分离( detach )出来。只要仍旧有其他进程在使用这块内存,这种分离只是影响当前的进程。它的 vm_area_struct 从 shmid_ds 数据结构中删除,并释放。当前进程的页表也进行更新,使它共享过的虚拟内存区域无效。当共享这块内存的最后一个进程从中分离出的时候,共享内存当前在物理内存中的页被释放,这块共享内存的 shmid_ds 数据结构也被释放。

如果共享的虚拟内存没有被锁定在物理内存中的话会更加复杂。在这种情况下,共享内存的页可能在系统大量使用内存的时候交换到了系统的交换磁盘。共享内存如何交换初和交换入物理内存在第 3 章中有描述。