第 5 章 进程间通信的机制


进程之间、进程与核心之间互相通信,以协调它们的活动。Linux支持一系列进程间通信 机制,信号和管道是其中的两种,此外还有SVR的进程间通信机制。 5.1 信号 信号是unix系统最早使用的进程间通信方法之一。它们用来对一个或多个进程发送异步事 件。信号可以由键盘中断产生,也可以由进程试图读取虚拟存储器中不存在的位置而引 发。另外,信号也可以用于外壳程序向它们的子进程发送作业控制命令。 有一组预先定义的信号,核心可以产生,具有相应优先权的进程也可以产生。使用kill -l 命令可以列出系统的信号集合。例如,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平台而言,数字又有所不同。进程可以选择忽略产生的大部份信号,除了两个 特殊的之外:使进程停止运行的SIGSTOP和使进程退出的SIGKILL。对此外的信号,进 程可以任意选择处理的方法。进程可以阻塞信号,或者由自己的代码处理信号,或者交由 核心来处理信号。 如果是核心处理信号,它将进行这个信号要求的缺省处理。例如,进程收到SIGFPE(浮 点溢出)信号时,缺省处理是core dump并退出。信号之间没有天然的相对优先关系。如 果两个信号同时为同一个进程产生,它们可以以任意顺序交给进程处理。进程也没有任何 方法区别自己收到的是一个还是四十二个SIGCONT信号。Linux用存储在进程的 task_struct中的信息实现信号。所支持的信号数受到字长的限制,32位处理器可有32个信 号,64位处理器就可有64位信号。当前未处理的信号保存在signal域中,blocked中为阻 塞信号的掩码。除了SIGSTOP和SIGKILL之外,一切信号都能够被阻塞。如果一个被阻 塞的信号产生,除非解除其阻塞,否则它不会被处理。Linux持有每一个进程如何处理每 一个可能的信号的信息(放在每个进程的task_struct指向的一组sigaction数据结构中)。 在sigaction之中,要么含有信号处理例程的地址,要么放置标志告诉系统:进程希望忽略 这个信号,或者进程希望核心处理这个信号。进程通过系统调用来修改信号处理方法,这 些系统调用把相应信号的sigaction或者blocked进行修改。 并不是任何一个进程都能够发信号给所有进程,只有核心和超级用户进程才有此权力。普 通进程只能发送信号给具有相同uid和gid的进程,或者同一个进程组中的进程。要产生 一个信号,只要把task_struct中signal域的相应比特设置一下。如果进程没有阻塞信号, 并且处于可中断的等待状态,那么它会被唤醒,转变为运行态,并确认它在运行队列中。 这样,调度程序在下一次调度时将把它作为运行的候选进程之一。如果缺省处理是需要 的,Linux能够优化信号的处理。例如,如果产生了信号SIGWINCH(X-Window改变焦 点),而缺省处理程序正在使用,那么什么也不会做。 信号并非在产生后立刻送给进程,而是要等到进程重新被运行。每当一个进程从系统调用 中退出,它的signal和blocked域都被检查,如果发现未被阻塞的信号,则将送给进程。 这看起来似乎不太可靠,但是事实上每个进程总是在不断进行系统调用,例如把字符写到 终端。如果愿意,进程可以选择等待信号,这是它处于可被信号的来临中断的挂起状态。 Linux的信号处理代码通过查看sigaction结构以便决定处理方法。 如果信号的处理被设置为缺省,那么核心将负责处理它。SIGSTOP信号的缺省处理停止当 前进程的运行并且使用调度程序选择下一个运行的进程。SIGFPE信号的缺省处理使进程 core dump并且让它退出。进程也可以选择指定自己的处理代码。该代码为一个每当信号 产生时可调用的例程,sigaction结构保存有这个例程的地址。核心必须调用进程的信号处 理例程,如何实现这一点与具体的处理器相关,但是无论如何CPU必须注意到当前进程 正处于核心态运行,并且即将返回用户态。通过对栈和寄存器的操作能解决这个问题。进 程的程序计数器被设置到信号处理例程的地址,调用参数被通过调用帧或是寄存器传递。 当进程得以继续时,看来似乎信号处理例程是被正常调用的。 Linux是POSIX兼容的,所以进程能够在信号处理例程调用时指定哪些信号被阻塞。这就 意味着在信号处理例程中改变blocked掩码。例程结束时,blocked掩码必须被恢复原有 值。所以Linux增加了一个清理进程,该进程负责把原始blocked掩码恢复到接收信号进 程的调用栈的顶端。某些情况下,几个信号处理例程需要被用堆栈方式调用,以便保证每 个例程退出时,立刻调用下一个例程,直至清理例程被调用。对此,Linux需要进行优 化。 5.2 管道 普通的Linux外壳都允许重定向。例如 $ ls | pr | lpr 把ls命令的输出文件名通过管道作为pr命令的标准输入,后者对之进行分页 ($$paginate?$$),最后pr的标准输出又通过管道送入lpr的标准输入,lpr把结果打在 缺省打印机上。所以,管道就是连接一个进程的标准输出到另外一个进程的标准输入的单 向字节流。进程无法知道这个重定向,仍然正常工作。负责在进程之间建立临时管道的是 外壳。 在Linux中,管道是通过两个指向同一个虚拟文件系统i节点的file结构来实现的,i节点 本身则指向内存中的一个物理页。图5.1(略)显示,每个file数据结构含有指向不同的文 件操作例程向量的指针,一个用于写管道,另一个用于读管道。 这就隐藏了与读写普通文件的一般的系统调用之间的区别。当写进程在写管道时,字节被 拷到共享数据页上,而当读进程在当读管道时,字节被从共享数据页上拷出来。Linux 必 须对共享数据页的存取进行同步,它使用锁、等待队列和信号来保证读写进程之间的轮 流。 当写进程想写管道时,它使用标准写库函数。这些库函数都传递文件描述符,而文件描述 符是进程的file数据结构集合的索引,每一个代表一个打开的文件或者一个打开的管道。 Linux系统调用使用描述这个管道的file数据结构指向的例程。那个写例程使用表示管道的i 节点中保存的信息来管理写要求。 如果有足够的空间供所有的字节写入管道,只要管道没有为读进程锁住,Linux将会为写 进程锁住管道,并且把所有的待写字节从进程的地址空间拷到共享数据页中。如果管道为 读进程锁住,或者没有足够的数据空间,那么将使当前进程睡眠在管道i节点的等待队列, 调用调度程序运行另外一个进程。进程的状态是可中断的,所以它能收到信号,能在写数 据空间变得足够或是管道被解锁之后被写进程唤醒。写完数据之后,管道的i节点被解锁, 睡眠在i节点等待队列的读进程将被唤醒。 从管道读数据与写数据非常类似。 允许进程做非阻塞读(依赖于打开文件或管道的模式),在此情况下,如果没有数据可读 或者管道被锁住,将返回一个错误。这意味着进程可以继续运行。另一种方法是等在管道i 节点的等待队列里直到写进程完成工作(即阻塞读-- 译者注)。当两个进程都完成了管道上的工作,管道i节点将被与共享数据页一起丢弃。 Linux也支持“有名”管道,也被称为FIFO,因为管道的工作方式是先入先出的。最早写入 这种管道的数据也最早被读出。与一般管道不同的是,FIFO不是临时对象,而是文件系统 中的实体,可以用mkfifo命令创建出来。只要进程有足够的存取权限,就能自由地使用FIF O。打开FIFO的方式和打开管道的方式也稍有不同。一个管道(包括它的两个file数据结构 ,它的虚拟文件系统i节点和共享数据页)是一次性产生的,而FIFO是已经存在的,由用 户负责它的打开和关闭。如果在写进程打开FIFO之前,读进程先打开了它,或者读进程去 读一个没有被写入数据的管道,Linux必须加以处理。除此之外,FIFO与管道完全相同, 因为它们采用的数据结构和操作是一致的。 5.3 套接字 注:在网络章完成后增加。 5.3.1 SVR的进程间通信机制 Linux支持三类SVR首创的进程间通信机制:消息队列、信号灯和共享内存。这些SVR进程 间通信机制都共用相同的认证方法。进程只能通过系统调用向核心传送一个唯一的引用标 识,才能存取这些资源。这些SVR进程间通信对象的存取通过存取权限来控制,与文件存 取的权限控制非常类似。由对象的创造者通过系统调用来设置对象的存取权限。在每一种 机制之中,对象的引用标识被用作资源表的索引。当然,索引本身并不简单,还需要一些 操作来产生。 所有表示SVR进程间通信对象的Linux数据结构都包含一个ipc_perm结构,该结构包含了所 有者和创造者进程的用户和组标识。对象的存取模式(所有者、组和其它)以及对象的ke y(这句似乎少了些什么,但是原文如此-- 译者注)。这个key只是用于定位对象的引用标识的一种方法。支持两类key:公开key和秘 密key。如果key是公开的,那么系统中的任何进程,只要有足够的存取权限,都能找到对 象的引用标识。SVR进程间通信对象绝对不能用key来引用,而只能用引用标识来引用。 5.3.2 消息队列 消息队列允许一个或者多个进程读/写消息。Linux维护一个消息队列表-- msgque向量,其中每一个元素指向一个msqid_ds数据结构,该数据结构将完整地描述消息 队列。当创建一个消息队列时,从系统内存中分配出一个新的msqid_ds数据结构,插入向 量之中。 每一个msqid_ds数据结构包含了一个ipc_perm数据结构和指向队列中消息的一批指针。 另外,Linux保存有队列修改时间,例如最后一次写队列的时间等等。msqid_ds也包含两 个等待队列,一个用于队列的写者进程,另一个用于队列的读者进程。 每当进程试图写消息到写队列中时,它的有效用户标识和组标识将与队列的ipc_perm数据 结构中的存取模式进行比较。如果进程能够写队列,那么消息将被从进程的地址空间中拷 到一个msg数据结构中,并且把该数据结构放到消息队列的尾部。根据应用进程之间的约 定,每个消息被用一个类型标记出来,这里的类型划分是与应用有关的。然而,由于 Linux限制了能向队列中写的消息的长度和数量,队列中剩余的空间可能不足容纳这次要 写的消息。这是,进程将被加入消息队列的写等待队列之中,调用调度程序来选择一个新 的进程运行。当有消息从队列中读出后,写等待队列中的进程将被唤醒。 读消息队列也类似。同样,需要检查进程对于写队列的存取权限。读进程可以选择读取队 列中的第一个消息,而不计其类型,或者选择只读特定类型的消息。如果没有消息满足读 进程的标准,那么它将被加入消息队列的读等待队列,然后运行调度程序。当一个新消息 写入队列时,进程将被唤醒,重新运行。 5.3.3 信号灯 最简单类型的信号灯是内存中一个可以被一个或者多个进程测试并设置的位置。就进程而 言,测试并设置操作是不可中断的,或者说是原子的。测试并设置操作的结果是信号灯当 前值加上了所设置的数值,这个数值可以随便是正的或负的。根据测试并设置操作的结 果,进程可能会被迫睡眠,直到另一个进程改变信号灯的值为止。信号灯可以用于实现关 键区--一次只能有一个进程进入运行的关键代码区域。 例如,假设你有很多进程在同时读写一个数据文件的记录,你想对文件的存取进行严格的 协调。你可以用一个初始值是1的信号灯,在文件操作代码的前后,放上两个信号灯操 作。第一个信号灯操作是测试并且减少信号灯的值,第二个信号灯操作是测试并且增加信 号灯的值。实际运行时,存取文件的第一个进程将试图减少信号灯的值,它当然会成功, 这时信号灯的值变成了0。于是进程能够继续下去,使用数据文件。这时,如果另外一个 进程也想使用文件,当它试图减少信号灯的值的时候,它会失败,返回结果-1。该进程将 会被挂起,直到第一个进程完成该数据文件的操作。第一个进程完成数据文件操作时,它 增加信号灯的值,使之回到1。这时等待进程可以被唤醒,它增加信号灯数值的尝试将会 成功。 每一个SVR信号灯对象描述一个信号灯序列,Linux使用semid_ds数据结构来代表之。系 统中所有的semid_ds数据结构都被semary向量中的一组指针所指引。每一个信号灯序列 中含有sem_nsems个信号灯,每一个信号灯用sem_base指向的一个sem 数据结构描述。 所有有权操作信号灯序列的进程可以通过系统调用来对它们进行操作。系统调用可以指定 很多操作,每个操作用三个输入来描述:信号灯索引,操作值和一组标志。信号灯索引是 信号灯序列中的索引,操作值是将被加到信号灯当前值上的数值。首先Linux测试是否所 有的操作都能成功。操作能够成功当且仅当操作值加到当前值上之后结果大于0,或者操 作值与当前值都是0。如果其中的任何信号灯操作失败,Linux将挂起进程,除非操作标 志要求系统调用是非阻塞的。如果需要挂起进程,Linux将保存信号灯操作的状态,并把 当前进程送入等待队列。实现的方法是创立并填写一个sem_queue数据结构,放在信号灯 对象的等待队列之中(使用sem_pending和sem_pending_last指针),并调用调度程序运 行另外一个进程。(这句话是译者根据自己理解翻译的,未必确切--译者注) 如果所有的信号灯操作都成功并且当前进程不需要被挂起,那么Linux继续下去,对信号 灯序列中适当的成员进行操作。现在Linux必须检查所有的等待、悬挂的进程能否进行它 们的操作了。它依次看每一个信号灯操作等待队列sem_pending,测试这些操作这一次是 否会成功。如果能成功,则从队列中删除sem_queue数据结构,进行信号灯操作,并唤醒 睡眠进程,使它在调度程序下一次运行时具备候选资格。Linux从头检查等待队列,直到 发现无法再进行任何信号灯操作,也不可能有更多进程被唤醒。 信号灯还有一个死锁的问题。当一个进程进入关键区,改变信号灯的数值之后,由于瘫痪 或者被杀而无法离开关键区,就会发生死锁。Linux防止死锁的方法是维护信号灯序列的 矫正表。这里的思想是用这些矫正值把信号灯恢复到操作之前的原有状态。矫正值放在 sem_undo数据结构里,同时在信号灯序列的semid_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数据结构 即可。 5.3.4 共享内存 共享内存允许一个或者多个进程通过同时出现在它们的虚地址空间内的内存进行通信。虚 存的页面由各个进程的页表的入口指引,并不需要共享内存在每一个进程的虚拟内存的地 址都相同。与所有的SVR进程间通信对象一样,共享内存的存取由key和存取权限检查 来控制。一旦内存被共享,无法对进程如何使用它进行检查。必须依赖其它机制,如信号 灯,来对内存的存取进行同步。 每一个新创建的共享内存区域由一个shmid_ds数据结构代表。这些数据接被保存在 shm_segs向量之中。shmid_ds数据结构描述了共享内存区域的大小,使用共享内存区域的 进程的数量,和关于共享内存如何映射到进程的地址空间的信息。正是共享内存的创建者 控制着其存取权限和其key是否公开。如果创建者具有足够的存取权限,它可以把共享内 存锁定在物理内存之中。 每一个希望共享内存的进程必须通过系统调用与虚拟内存相联,从而产生一个 vm_area_struct数据结构,为此进程描述该共享内存。进程可以选择把共享内存放在它的 虚拟地址空间的何处,也可以任由Linux选择一块足够大的空间。新的vm_area_struct结 构放在shmid_ds所指的vm_area_struct结构表之中。vm_next_shared和vm_prev_shared指 针把这些结构串联起来。虚拟内存在相联时并没有真正创建出来,而是在第一次有进程试 图存取时创建出来。 当第一次有进程存取共享虚拟内存的一个页面时,发生一个缺页错误。在Linux处理缺页 错误时,它会找到描述该虚拟内存的vm_area_struct数据结构,其中包含了指向处理该类 共享虚拟内存的例程的指针。共享内存缺页错误处理代码查看该shmid_ds列出的页面对应 的页表入口的表,确定是否有此页存在。如果不存在,就分配一个物理页面,在页表中为 它创建一个入口。该入口不仅放入当前进程的页表之中,也放入该shmid_ds之中。这意味 着当下一个试图存取该内存的进程得到一个缺页错误时(这里似应说明为同一虚存页面-- 译者注),共享内存错误处理代码将使用这个新创建的物理页面。所以,第一个存取共享 内存某页使得它被创建,此后其它进程的存取使它被加入相应进程的虚拟地址空间。 当进程不再想使用虚拟内存时,就与它断联。只要还有其它进程还在使用该内存,断联就 只会影响当前进程。它的vm_area_struct被从shmid_ds数据结构中删除、去配,并修改当 前进程的页表,使原来使用的共享内存区域无效。当最后一个共享该内存的进程与它断 联,当前在物理内存中的共享内存页面被释放,共享内存的shmid_ds数据结构也释放。 如果共享虚拟内存没有锁定在物理内存,就更复杂一些。这时,共享内存的页面在内存使 用频繁时可以被换出到系统的交换磁盘上。共享内存如何换入和换出虚拟内存,见“内存 管理”一章。