第9章 进程关系

9.1 引言

在上一章我们已了解到进程之间具有关系。首先,每个进程有一个父进程(初始的内核级进程通常是自己的父进程)。当子进程终止时,父进程得到通知并能取得子进程的退出状态。在8.6节说明waitpid函数时,我们也提到了进程组,以及如何等待进程组中的任意一个进程终止。

本章将更详细地说明进程组以及POSIX.1引入的会话的概念。还将介绍登录shell(登录时所调用的)和所有从登录shell启动的进程之间的关系。

在说明这些关系时不可能不谈及信号,而讨论信号时又需要很多本章介绍的概念。如果你不熟悉UNIX系统信号机制,则可能先要浏览一下第10章。

9.2 终端登录

先说明当我们登录到UNIX系统时所执行的各个程序。在早期的UNIX系统(如V7)中,用户用哑终端(用硬连接连到主机)进行登录。终端或者是本地的(直接连接)或者是远程的(通过调制解调器连接)。在这两种情况下,登录都经由内核中的终端设备驱动程序。例如,在PDP-11上常用的设备是DH-11和DZ-11。因为连到主机上的终端设备数是固定的,所以同时的登录数也就有了已知的上限。

随着位映射图形终端的出现,开发出了窗口系统,它向用户提供了与主机系统进行交互的新方式。创建终端窗口的应用也被开发出来,它仿真了基于字符的终端,使得用户可以用熟悉的方式(即通过shell命令行)与主机进行交互。

现今,某些平台允许用户在登录后启动一个窗口系统,而另一些平台则自动为用户启动窗口系统。在后面一种情况中,用户可能仍然需要登录,这取决于窗口系统是如何配置的(某些窗口系统可被配置成自动为用户登录)。

我们现在描述的过程用于经由终端登录至UNIX系统。该过程几乎与所使用的终端类型无关,所使用的终端可以是基于字符的终端、仿真基于字符终端的图形终端,或者运行窗口系统的图形终端。

1.BSD终端登录

在过去 35 年中,BSD 终端登录过程并没有多少改变。系统管理者创建通常名为/etc/ttys的文件,其中,每个终端设备都有一行,每一行说明设备名和传到 getty 程序的参数。例如,其中一个参数说明了终端的波特率等。当系统自举时,内核创建进程ID 为1 的进程,也就是init进程。init进程使系统进入多用户模式。init读取文件/etc/ttys,对每一个允许登录的终端设备,init调用一次fork,它所生成的子进程则exec getty程序。这种情况示于图9-1中。

图9-1中所有进程的实际用户ID和有效用户ID都是0(也就是说,它们都具有超级用户特权)。init以空环境exec getty程序。

getty 对终端设备调用 open 函数,以读、写方式将终端打开。如果设备是调制解调器,则open 可能会在设备驱动程序中滞留,直到用户拨号调制解调器,并且线路被接通。一旦设备被打开,则文件描述符0、1、2就被设置到该设备。然后getty输出“login: ”之类的信息,并等待用户键入用户名。如果终端支持多种速度,则 getty 可以测试特殊字符以便适当地更改终端速度(波特率)。关于getty程序以及有关数据文件(gettytab)的细节,请参阅UNIX系统手册。

当用户键入了用户名后,getty的工作就完成了。然后它以类似于下列的方式调用login程序:

execle("/bin/login", "login", "-p", username, (char *)0, envp);

(在gettytab文件中可能会有一些选项使其调用其他程序,但系统默认是login程序)。init以一个空环境调用getty。getty以终端名(如TERM=foo,其中终端foo的类型取自gettytab文件)和在gettytab中说明的环境字符串为login创建一个环境(envp参数)。-p标志通知login保留传递给它的环境,也可将其他环境字符串加到该环境中,但是不要替换它。图9-2显示了login刚被调用后这些进程的状态。

图9-1 为允许终端登录,init调用的进程

图9-2 login调用后进程的状态

因为最初的init进程具有超级用户特权,所以图9-2中的所有进程都有超级用户特权。图9-2中底部3个进程的进程ID相同,因为进程ID不会因执行exec而改变。并且,除了最初的init进程,所有进程的父进程ID均为1。

login 能处理多项工作。因为它得到了用户名,所以能调用 getpwnam 取得相应用户的口令文件登录项。然后调用getpass(3)以显示提示“Password: ”,接着读用户键入的口令(自然,禁止回显用户键入的口令)。它调用crypt(3)将用户键入的口令加密,并与该用户在阴影口令文件中登录项的pw_passwd字段相比较。如果用户几次键入的口令都无效,则login以参数1调用exit表示登录过程失败。父进程(init)了解到子进程的终止情况后,将再次调用fork,其后又执行了getty,对此终端重复上述过程。

这是UNIX系统传统的用户身份验证过程。现代UNIX系统已发展到支持多个身份验证过程。例如,FreeBSD、Linux、Mac OS X 以及 Solaris 都支持被称为 PAM(Pluggable Authentication Modules,可插入的身份验证模块)的更加灵活的方案。PAM 允许管理人员配置使用何种身份验证方法来访问那些使用PAM库编写的服务。

如果应用程序需要验证用户是否具有适当的权限去执行某个服务,那么我们要么将身份验证机制编写到应用中,要么使用PAM库得到同样的功能。使用PAM的优点是,管理员可以基于本地策略、针对不同任务配置不同的验证用户身份的方法。

如果用户正确登录,login就将完成如下工作。

•将当前工作目录更改为该用户的起始目录(chdir)。

•调用chown更改该终端的所有权,使登录用户成为它的所有者。

•将对该终端设备的访问权限改变成“用户读和写”。

•调用setgid及initgroups设置进程的组ID。

•用login得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户 名(USER和LOGNAME)以及一个系统默认路径(PATH)。

•login进程更改为登录用户的用户ID(setuid)并调用该用户的登录shell,其方式类似于:execl("/bin/sh", "-sh", (char *)0);

argv[0]的第一个字符负号“−”是一个标志,表示该shell被作为登录shell调用。shell可以查看此字符,并相应地修改其启动过程。

login程序实际所做的比上面说的要多。它可选择地打印日期消息(message-of-the-day)文件、检查新邮件以及执行其他一些任务。本章中我们主要关心上面所说的功能。

回忆8.11节中对setuid函数的讨论,因为setuid是由超级用户调用的,它更改所有3个用户ID:实际用户ID、有效用户ID和保存的用户ID。login在较早时间调用的setgid对所有3个组ID也有同样效果。

至此,登录用户的登录shell开始运行。其父进程ID是init进程(进程ID 1),所以当此登录shell终止时,init会得到通知(接到SIGCHLD信号),它会对该终端重复全部上述过程。登录shell的文件描述符0、1和2设置为终端设备。图9-3显示了这种安排。

图9-3 终端登录完成各种设置后的进程安排

现在,登录 shell 读取其启动文件(Bourne shell和Korn shell是.profile,GNU Bourne-again shell是.bash_profile、.bash_login或.profile, C shell是.cshrc和.login)。这些启动文件通常更改某些环境变量并增加很多环境变量。例如,大多数用户设置他们自己的 PATH 并常常提示实际终端类型(TERM)。当执行完启动文件后,用户最后得到 shell提示符,并能键入命令。

2.Mac OS X终端登录

Mac OS X部分地基于FreeBSD,所以其终端登录进程与BSD终端登录进程的工作步骤基本相同。但是,Mac OS X有些不同之处。

•init的工作是由launchd完成的。

•一开始提供的就是图形终端。

3.Linux终端登录

Linux的终端登录过程非常类似于BSD。确实,Linux login命令是从4.3BSD login命令派生出来的。BSD登录过程与Linux登录过程的主要区别在于说明终端配置的方式。

在System V的init文件格式之后,有些Linux发行版的init程序使用了管理文件方式。在这些系统中,/etc/inittab包含配置信息,指定了init应当为之启动getty进程的各终端设备。

其他Linux发行版本,如最近的Ubuntu发行版,配有称为“Upstart”的init程序。使用存放在/etc/init目录的*.conf命名的配置文件。例如,运行/dev/tty1上的getty需要的说明可能放在/etc/init/tty1.conf文件中。

根据所使用的getty版本的不同,终端的特征要么在命令行中说明(如agetty),要么在/etc/gettydefs文件中说明(如mgetty)。

4.Solaris终端登录

Solaris支持两种形式的终端登录:(a)getty方式,这与前面对BSD终端登录的说明一样;(b)ttymon登录,这是SVR4引入的一种新特性。通常,getty用于控制台,ttymon则用于其他终端的登录。

ttymon命令是服务访问设施(Service Access Facility,SAF)的一部分。SAF的目的是用一致的方式对提供系统访问的服务进行管理(关于SAF的详细信息可以参见Rago[1993]的第6章)。按照本书的宗旨,我们只简单说明从init到登录shell之间不同的工作步骤,最后结果与图9-3中所示相似。init是sac(service access controller,服务访问控制器)的父进程,sac调用fork,然后,当系统进入多用户状态时,其子进程执行ttymon程序。ttymon监控在配置文件中列出的所有终端端口,当用户键入登录名时,它调用一次 fork。在此之后 ttymon 的子进程执行login,它向用户发出提示,要求输入口令字。一旦完成这一处理,login执行登录用户的登录shell,于是到达了图9-3中所示的位置。一个区别是用户登录shell的父进程现在是ttymon,而在getty登录中,登录shell的父进程是init。

9.3 网络登录

通过串行终端登录至系统和经由网络登录至系统两者之间的主要(物理上的)区别是:网络登录时,在终端和计算机之间的连接不再是点到点的。在网络登录情况下,login仅仅是一种可用的服务,这与其他网络服务(如FTP或SMTP)的性质相同。

在上节所述的终端登录中,init知道哪些终端设备可用来进行登录,并为每个设备生成一个getty进程。但是,对网络登录情况则有所不同,所有登录都经由内核的网络接口驱动程序(如以太网驱动程序),而且事先并不知道将会有多少这样的登录。因此必须等待一个网络连接请求的到达,而不是使一个进程等待每一个可能的登录。

为使同一个软件既能处理终端登录,又能处理网络登录,系统使用了一种称为伪终端(pseudo terminal)的软件驱动程序,它仿真串行终端的运行行为,并将终端操作映射为网络操作,反之亦然。(在第19章,我们将详细说明伪终端。)

1.BSD网络登录

在BSD中,有一个inetd进程(有时称为因特网超级服务器),它等待大多数网络连接。本节将说明 BSD 网络登录中所涉及的进程序列。关于这些进程的网络程序设计方面的细节请参阅Stevens、Fenner和Rudoff [2004]。

作为系统启动的一部分,init调用一个shell,使其执行shell脚本/etc/rc。由此shell脚本启动一个守护进程inetd。一旦此shell脚本终止,inetd的父进程就变成init。inetd等待TCP/IP连接请求到达主机,而当一个连接请求到达时,它执行一次fork,然后生成的子进程exec适当的程序。

假定一个对于TELNET服务进程的TCP连接请求到达。TELNET是使用TCP协议的远程登录应用程序。在另一台主机(它通过某种形式的网络与服务进程主机相连接)上的用户,或在同一个主机上的一个用户启动TELNET客户进程,由此启动登录过程:

telnet hostname

该客户进程打开一个到hostname主机的TCP连接,在hostname主机上启动的程序被称为TELNET服务进程。然后,客户进程和服务进程之间使用TELNET应用协议通过TCP连接交换数据。启动客户进程的用户现在登录到了服务进程所在的主机(当然,假定用户在服务进程主机上有一个有效的账号)。图9-4显示了在执行TELNET服务进程(称为telnetd)中所涉及的进程序列。

图9-4 执行TELNET服务进程时调用的进程序列

然后,telnetd进程打开一个伪终端设备,并用fork分成两个进程。父进程处理通过网络连接的通信,子进程则执行login程序。父进程和子进程通过伪终端相连接。在调用exec之前,子进程使其文件描述符0、1、2与伪终端相连。如果登录正确,login就执行9.2节中所述的同样步骤—更改当前工作目录为起始目录、设置登录用户的组ID、用户ID以及初始环境。然后login调用exec将其自身替换为登录用户的登录shell。图9-5显示了到达这一点时的进程安排。

图9-5 网络登录完成各种设置后的进程安排

很明显,在伪终端设备驱动程序和实际终端用户之间进行了很多工作。第19章详细说明伪终端时,我们将介绍与这种安排相关的所有进程。

需要理解的重点是:当通过终端(见图9-3)或网络(见图9-5)登录时,我们得到一个登录shell,其标准输入、标准输出和标准错误要么连接到一个终端设备,要么连接到一个伪终端设备上。在后面几节中我们会了解到这一登录shell是一个POSIX.1会话的开始,而此终端或伪终端则是会话的控制终端。

2.Mac OS X网络登录

Mac OS X是部分地基于FreeBSD的,所以其网络登录与BSD网络登录基本相同。但Mac OS X上telnet守护进程是从launchd运行的。

telnet守护进程在Mac OS X中默认是禁用的(虽然可以通过launchctl(1)命令启用)。Mac OS X上执行网络登录的更好办法是用使ssh(安全shell命令)。

3.Linux网络登录

除了有些版本使用扩展的因特网服务守护进程xinetd代替inetd进程外,Linux网络登录的其他方面与BSD网络登录相同。xinetd进程对它所启动的各种服务的控制比inetd提供的控制更加精细。

4.Solaris网络登录

Solaris中网络登录的工作过程与BSD和Linux中的步骤几乎一样。同样使用了类似于BSD版的inetd服务进程,但是在Solaris中,inetd服务进程在服务管理设施(Service Management Facility,SMF)下作为restarter运行。这个restarter是守护进程,它负责启动和监视其他守护进程,如果其他守护进程失败的话,restarter重启这些失效进程。虽然inetd 服务程序由SMF中的主restarter启动,但实际上主restarter是由init程序启动的,最后得到的结果与图9-5中一样。

Solaris服务管理设施是管理和监视系统服务的框架,提供了一种从影响系统服务的故障中恢复的途径。关于服务管理设施的更多内容,可参阅Adams[2005]以及Solaris系统手册smf(5)和inetd(1M)。

9.4 进程组

每个进程除了有一进程ID之外,还属于一个进程组,第10章讨论信号时还会涉及进程组。

进程组是一个或多个进程的集合。通常,它们是在同一作业中结合起来的(9.8 节将详细讨论作业控制),同一进程组中的各进程接收来自同一终端的各种信号。每个进程组有一个唯一的进程组ID。进程组ID类似于进程ID——它是一个正整数,并可存放在pid_t数据类型中。函数getpgrp返回调用进程的进程组ID。

#include <unistd.h>

pid_t getpgrp(void);

返回值:调用进程的进程组ID

在早期 BSD 派生的系统中,该函数的参数是 pid,返回该进程的进程组 ID。Single UNIX Specification定义了getpgid函数模仿此种运行行为。

#include <unistd.h>

pid_t getpgid(pid_t pid);

返回值:若成功,返回进程组ID;若出错,返回−1

若pid是0,返回调用进程的进程组ID,于是,

getpgid(0);

等价于

getpgrp();

每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID。

进程组组长可以创建一个进程组、创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间区间称为进程组的生命期。某个进程组中的最后一个进程可以终止,也可以转移到另一个进程组。

进程调用 setpgid 可以加入一个现有的进程组或者创建一个新进程组(下一节中将说明用setsid也可以创建一个新的进程组)。

#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);

返回值:若成功,返回0;若出错,返回−1

setpgid函数将pid进程的进程组ID设置为pgid。如果这两个参数相等,则由pid指定的进程变成进程组组长。如果pid是0,则使用调用者的进程ID。另外,如果pgid是0,则由pid指定的进程ID用作进程组ID。

一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用了exec后,它就不再更改该子进程的进程组ID。

在大多数作业控制shell中,在fork之后调用此函数,使父进程设置其子进程的进程组ID,并且也使子进程设置其自己的进程组ID。这两个调用中有一个是冗余的,但让父进程和子进程都这样做可以保证,在父进程和子进程认为子进程已进入了该进程组之前,这确实已经发生了。如果不这样做,在fork之后,由于父进程和子进程运行的先后次序不确定,会因为子进程的组员身份取决于哪个进程首先执行而产生竞争条件。

在讨论信号时,将说明如何将一个信号发送给一个进程(由其进程 ID 标识)或发送给一个进程组(由进程组ID标识)。类似地,8.6节的waitpid函数可被用来等待一个进程或者指定进程组中的一个进程终止。

9.5 会话

会话(session)是一个或多个进程组的集合。例如,可以具有图 9-6 中所示的安排。其中,在一个会话中有3个进程组。

图9-6 进程组和会话中的进程安排

通常是由shell的管道将几个进程编成一组的。例如,图9-6中的安排可能是由下列形式的shell命令形成的:

procl | proc2 &

proc3 | proc4 | proc5

进程调用setsid函数建立一个新会话。

#include <unistd.h>

pid_t setsid(void);

返回值:若成功,返回进程组ID;若出错,返回-1

如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生以下3件事。

(1)该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话中的唯一进程。

(2)该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID。

(3)该进程没有控制终端(下一节讨论控制终端)。如果在调用 setsid 之前该进程有一个控制终端,那么这种联系也被切断。

如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,而其进程ID则是新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长。

Single UNIX Specification只说明了会话首进程,而没有类似于进程ID和进程组ID的会话ID。显然,会话首进程是具有唯一进程ID的单个进程,所以可以将会话首进程的进程ID视为会话ID。会话ID这一概念是由SVR4引入的。历史上,基于BSD的系统并不支持这个概念,但后来改弦易辙也支持了会话ID。getsid函数返回会话首进程的进程组ID。

一些实现(如Solaris)与Single UNIX Specification保持一致,在实践中避免使用“会话ID”这一短语,而是将此称为“会话首进程的进程组 ID”。会话首进程总是一个进程组的组长进程,所以两者是等价的。

#include <unistd.h>

pid_t getsid(pid_t pid);

返回值:若成功,返回会话首进程的进程组ID;若出错,返回-1

如若pid是0,getsid返回调用进程的会话首进程的进程组ID。出于安全方面的考虑,一些实现有如下限制:如若pid并不属于调用者所在的会话,那么调用进程就不能得到该会话首进程的进程组ID。

9.6 控制终端

会话和进程组还有一些其他特性。

•一个会话可以有一个控制终端(controlling terminal)。这通常是终端设备(在终端登录情况下)或伪终端设备(在网络登录情况下)。

•建立与控制终端连接的会话首进程被称为控制进程(controlling process)。

•一个会话中的几个进程组可被分成一个前台进程组(foreground process group)以及一个或多个后台进程组(background process group)。

•如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组。

•无论何时键入终端的中断键(常常是Delete或Ctrl+C),都会将中断信号发送至前台进程组的所有进程。

• 无论何时键入终端的退出键(常常是Ctrl+\),都会将退出信号发送至前台进程组的所有进程。

• 如果终端接口检测到调制解调器(或网络)已经断开连接,则将挂断信号发送至控制进程(会话首进程)。

这些特性示于图9-7中。

图9-7 进程组、会话和控制终端

通常,我们不必担心控制终端,登录时,将自动建立控制终端。

POSIX.1将如何分配一个控制终端的机制交给具体实现来选择。19.4节中将说明实际步骤。

当会话首进程打开第一个尚未与一个会话相关联的终端设备时,只要在调用 open 时没有指定O_NOCTTY标志(见3.3节),System V派生的系统将此作为控制终端分配给此会话。

当会话首进程用TIOCSCTTY作为request参数(第三个参数是空指针)调用ioctl时,基于BSD的系统为会话分配控制终端。为使此调用成功执行,此会话不能已经有一个控制终端(通常ioctl调用紧跟在setsid调用之后,setsid保证此进程是一个没有控制终端的会话首进程)。除了以兼容模式支持其他系统以外,基于BSD的系统不使用POSIX.1中对open函数所说明的O_NOCTTY标志。

图9-8总结了本书讨论的4个平台分配控制终端的方式。注意,虽然Mac OS X 10.6.8是从BSD派生出来的,但其分配控制终端的方式如同System V。

图9-8 不同的实现分配控制终端的方式

有时不管标准输入、标准输出是否重定向,程序都要与控制终端交互作用。保证程序能与控制终端对话的方法是 open 文件/dev/tty。在内核中,此特殊文件是控制终端的同义语。自然地,如果程序没有控制终端,则对于此设备的open将失败。

典型的例子是用于读口令的 getpass(3)函数(终端回显被关闭)。这一函数由 crypt(1)程序调用,并可用于管道中。例如:

crypt < salaries | lpr

将文件 salaries 解密,然后经由管道将输出送至打印缓冲服务程序。因为 crypt 从其标准输入读输入文件,所以标准输入不能用于输入口令。而且,crypt经过了设计,因此每次运行此程序时都应输入加密口令,这样也就阻止了用户将口令存放在文件中(这会造成安全性漏洞)。

已经知道有一些方法可以破译 crypt 程序使用的密码。关于加密文件的详细情况请参见Garfinkel等[2003]。

9.7 函数tcgetpgrp、tcsetpgrp和tcgetsid

需要有一种方法来通知内核哪一个进程组是前台进程组,这样,终端设备驱动程序就能知道将终端输入和终端产生的信号发送到何处(见图9-7)。

#include <unistd.h>

pid_t tcgetpgrp(int fd);

int tcsetpgrp(int fd, pid_t pgrpid);

返回值:若成功,返回前台进程组ID;若出错,返回−1

返回值:若成功,返回0;若出错,返回−1

函数tcgetpgrp返回前台进程组ID,它与在fd上打开的终端相关联。

如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpid。pgrpid值应当是在同一会话中的一个进程组的ID。fd必须引用该会话的控制终端。

大多数应用程序并不直接调用这两个函数。它们通常由作业控制shell调用。

给出控制TTY的文件描述符,通过tcgetsid函数,应用程序就能获得会话首进程的进程组ID。

#include <termios.h>

pid_t tcgetsid(int fd);

返回值:若成功,返回会话首进程的进程组ID;若出错,返回−1

需要管理控制终端的应用程序可以调用 tcgetsid 函数识别出控制终端的会话首进程的会话ID(它等价于会话首进程的进程组ID)。

9.8 作业控制

作业控制是BSD在1980年左右增加的一个特性。它允许在一个终端上启动多个作业(进程组),它控制哪一个作业可以访问该终端以及哪些作业在后台运行。作业控制要求以下3种形式的支持。

(1)支持作业控制的shell。

(2)内核中的终端驱动程序必须支持作业控制。

(3)内核必须提供对某些作业控制信号的支持。

SVR3提供了一种不同的作业控制,称为shell层(shell layer)。但是 POSIX.1选择了BSD形式的作业控制,这也是我们在这里所说明的。POSIX.1 的早期版本中,对作业控制的支持是可选择的,现在则要求所有平台都支持它。

从shell使用作业控制功能的角度观察,用户可以在前台或后台启动一个作业。一个作业只是几个进程的集合,通常是一个进程管道。例如:

vi main.c

在前台启动了只有一个进程组成的作业。下面的命令:

pr *.c | lpr &

make all &

在后台启动了两个作业。这两个后台作业调用的所有进程都在后台运行。

如前所述,我们需要一个支持作业控制的shell以使用由作业控制提供的功能。对于早期的系统,shell是否支持作业控制比较易于说明。C shell支持作业控制,Bourne shell不支持,而Korn shell能否支持作业控制取决于主机是否支持作业控制。但是现在C shell已被移植到并不支持作业控制的系统上(如System V的早期版本),而当用名字jsh而不是用sh调用SVR4中的Bourne shell时,它支持作业控制。如果主机支持作业控制,则Korn shell继续支持作业控制。Bourne-again shell也支持作业控制。各种shell之间的差别无关紧要时,我们将只是一般地说明支持作业控制的shell和不支持作业控制的shell。

当启动一个后台作业时,shell赋予它一个作业标识符,并打印一个或多个进程ID。下面的脚本显示了Korn shell是如何处理这一点的。

$ make all > Make.out &

[1]  1475

[2]  1490

$ pr *.c | lpr &

$          键入回车

[2] + Done    pr *.c | lpr &

[1] + Done    make all > Make.out &

make是作业编号1,所启动的进程ID是1475。下一个管道是作业编号2,其第一个进程的进程ID是1490。当作业完成而且键入回车时,shell通知作业已经完成。键入回车是为了让shell打印其提示符。shell并不在任意时刻打印后台作业的状态改变——它只在打印其提示符让用户输入新的命令行之前才这样做。如果不这样处理,则当我们正输入一行时,它也可能输出,于是,就会引起混乱。

我们可以键入一个影响前台作业的特殊字符——挂起键(通常采用 Ctrl+Z),与终端驱动程序进行交互作用。键入此字符使终端驱动程序将信号SIGTSTP发送至前台进程组中的所有进程,后台进程组作业则不受影响。实际上有3个特殊字符可使终端驱动程序产生信号,并将它们发送至前台进程组,它们是:

•中断字符(一般采用Delete或Ctrl+C)产生SIGINT;

•退出字符(一般采用Ctrl+\)产生SIGQUIT;

•挂起字符(一般采用Ctrl+Z)产生SIGTSTP。

第 18 章中将说明可将这 3 个字符更改为用户选择的任意其他字符,以及如何使终端驱动程序不处理这些特殊字符。

终端驱动程序必须处理与作业控制有关的另一种情况。我们可以有一个前台作业,若干个后台作业,这些作业中哪一个接收我们在终端上键入的字符呢?只有前台作业接收终端输入。如果后台作业试图读终端,这并不是一个错误,但是终端驱动程序将检测这种情况,并且向后台作业发送一个特定信号SIGTTIN。该信号通常会停止此后台作业,而shell则向有关用户发出这种情况的通知,然后用户就可用shell命令将此作业转为前台作业运行,于是它就可读终端。下列操作过程显示了这一点:

$ cat > temp.foo &       在后台启动,但将从标准输入读

[1]  1681

$                键入回车

[1] + Stopped (SIGTTIN)     cat > temp.foo &

$ fg %1             使1号作业成为前台作业

cat > temp.foo         shell告诉我们现在哪一个作业在前台

hello, world          输入一行

^D               键入文件结束符

$ cat temp.foo         检查该行已送入文件

hello, world

注意,这个例子在Mac OS X 10.6.8上不起作用。在试图把cat命令放到前台时,read返回失败,并将errno设为EINTR。Mac OS X是基于FreeBSD的,在FreeBSD下本例运行良好,因此这应该是Mac OS X的一个bug。

shell在后台启动cat进程,但是当cat试图读其标准输入(控制终端)时,终端驱动程序知道它是个后台作业,于是将SIGTTIN信号送至该后台作业。shell检测到其子进程的状态改变(回忆8.6 节中对wait 和 waitpid 函数的讨论),并通知我们该作业已被停止。然后,我们用shell的fg命令将此停止的作业送入前台运行(关于作业控制命令,如fg和bg的详细情况,以及标识不同作业的各种方法请参阅有关shell的手册页)。这样做使shell将此作业转为前台进程组(tcsetpgrp),并将继续信号(SIGCONT)送给该进程组。因为该作业现在前台进程组中,所以它可以读控制终端。

如果后台作业输出到控制终端又将发生什么呢?这是一个我们可以允许或禁止的选项。通常,可以用stty(1)命令改变这一选项(第18章将说明在程序中如何改变这一选项)。下面显示了这种操作过程:

$ cat temp.foo &      在后台执行

[1]  1719

$ hello, world       提示符后出现后台作业的输出键入回车

[1] + Done    cat temp.foo &

$ stty tostop       禁止后台作业输出至控制终端

$ cat temp.foo &      在后台再试一次

[1]  1721

$              键入回车,发现作业已停止

[1] + Stopped(SIGTTOU)     cat temp.foo &

$ fg %1           在前台恢复停止的作业

cat temp.foo        shell告诉我们现在哪一个作业在前台

hello, world        这是该作业的输出

在用户禁止后台作业向控制终端写时,该作业的cat命令试图写其标准输出,此时,终端驱动程序识别出该写操作来自于后台进程,于是向该作业发送SIGTTOU信号,cat进程阻塞。与上面的例子一样,当用户使用shell的fg命令将该作业转为前台时,该作业继续执行直至完成。

图 9-9 总结了前面已说明的作业控制的某些功能。穿过终端驱动程序框的实线表明终端 I/O 301和终端产生的信号总是从前台进程组连接到实际终端。对应于 SIGTTOU 信号的虚线表明后台进程组进程的输出是否出现在终端是可选择的。

图9-9 对于前台、后台作业以及终端驱动程序的作业控制功能总结

是否需要作业控制是一个有争议的问题。作业控制是在窗口终端广泛得到应用之前设计和实现的。很多人认为设计得好的窗口系统已经免除了对作业控制的需要。某些人抱怨作业控制的实现要求得到内核、终端驱动程序、shell以及某些应用程序的支持,是吃力不讨好的事情。某些人在窗口系统中使用作业控制,他们认为两者都需要。不管你的意见如何,作业控制都是POSIX.1要求的部分。

9.9 shell执行程序

让我们检验一下shell是如何执行程序的,以及这与进程组、控制终端和会话等概念的关系。为此,再次使用ps命令。

首先使用不支持作业控制的、在Solaris上运行的经典Bourne shell。如果执行:

ps -o pid,ppid,pgid,sid,comm

则其输出可能是:

PID PPID PGID SID COMMAND

949 947 949 949 sh

1774 949 949 949 ps

ps的父进程是shell,这正是我们所期望的。shell和ps命令两者位于同一会话和前台进程组(949)中。因为我们是用一个不支持作业控制的shell执行命令时得到该值的,所以称其为前台进程组。

某些平台支持一个选项,它使 ps(1)命令打印与会话控制终端相关联的进程组 ID。该值在TPGID列中显示。遗憾的是,ps(1)命令的输出在各个UNIX版本中都有所不同。例如,Solaris 10不支持该选项。在FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8中,命令

ps -o pid, ppid, pgid, sid, tpgid, comm

准确地打印我们想要的信息。

注意,将进程与终端进程组ID(TPGID列)关联起来有点用词不当。进程并没有终端进程控制组。进程属于一个进程组,而进程组属于一个会话。会话可能有也可能没有控制终端。如果它确实有一个控制终端,则此终端设备知道其前台进程的进程组ID。这一值可以用tcsetpgrp函数在终端驱动程序中设置(见图9-9)。前台进程组ID是终端的一个属性,而不是进程的属性。取自终端设备驱动程序的该值是ps在TPGID列中打印的值。如果ps发现此会话没有控制终端,则它在该列打印0或者−1,具体值因不同平台而异。

如果在后台执行命令:

ps -o pid,ppid,pgid,sid,comm &

则唯一改变的值是命令的进程ID:

PID PPID PGID SID COMMAND

949 947 949 949 sh

1812 949 949 949 ps

因为这种shell不知道作业控制,所以没有将后台作业放入自己的进程组,也没有从后台作业处取走控制终端。

现在看一看Bourne shell如何处理管道。执行下列命令:

ps -o pid,ppid,pgid,sid,comm | cat1

其输出是:

PID PPID PGID SID COMMAND

949 947 949 949 sh

1823 949 949 949 cat1

1824 1823 949 949 ps

(程序cat1是标准cat程序的一个副本,只是名字不同。本节还将使用cat的另一个名为cat2的副本。在一个管道中使用两个cat副本时,不同的名字可使我们将它们区分开来。)注意,管道中的最后一个进程是 shell 的子进程,该管道中的第一个进程则是最后一个进程的子进程。从中可以看出,shell fork一个它自身的副本,然后此副本再为管道中的每条命令各fork一个进程。

如果在后台执行此管道:

ps -o pid,ppid,pgid,sid,comm | cat1 &

则只改变进程ID。因为shell并不处理作业控制,后台进程的进程组ID仍是949,如同会话的进程组ID一样。

如果一个后台进程试图读其控制终端,则会发生什么呢?例如,若执行:

cat > temp.foo &

在有作业控制时,后台作业被放在后台进程组,如果后台作业试图读控制终端,则会产生信号SIGTTIN。在没有作业控制时,其处理方法是:如果该进程自己没有重定向标准输入,则 shell自动将后台进程的标准输入重定向到/dev/null。读/dev/null则产生一个文件结束。这就意味着后台cat进程立即读到文件尾,并正常终止。

前面说明了对后台进程通过其标准输入访问控制终端的适当的处理方法,但是,如果一个后台进程打开/dev/tty并且读该控制终端,又将怎样呢?对此问题的回答是“看情况”。但是这很可能不是我们所期望的。例如:

crypt < salaries | lpr &

就是这样的一条管道。我们在后台运行它,但是crypt 程序打开/dev/tty,更改终端的特性(禁止回显),然后从该设备读,最后重置该终端特性。当执行这条后台管道时,crypt在终端上打印提示符“Password: ”,但是shell读取了我们所输入的加密口令,并试图执行以加密口令为名称的命令。我们输送给shell的下一行则被crypt进程取为口令行,于是salaries也就不能正确地被译码,结果将一堆无用的信息送到了打印机。在这里,我们有了两个进程,它们试图同时读同一设备,其结果则依赖于系统。前面说明的作业控制以较好的方式处理一个终端在多个进程间的转接。

返回到Bourne shell实例,在一条管道中执行3个进程,我们可以检验Bourne shell使用的进程控制方式:

ps -o pid,ppid,pgid,sid,comm | cat1 | cat2

其输出为:

PID PPID PGID SID COMMAND

949 947 949 949 sh

1988 949 949 949 cat2

1989 1988 949 949 ps

1990 1988 949 949 cat1

如果在你的系统上,输出的命令名不正确,那也不必为此感到惊慌。有时可能会得到类似如下的输出:

PID PPID PGID SID COMMAND

949 947 949 949 sh

1831 949 949 949 sh

1832 1831 949 949 ps

1833 1831 949 949 sh

造成此种结果的原因是,ps进程与shell产生竞争条件,shell创建一个子进程并由它执行cat命令。在这种情况下,当ps已经获得进程列表并打印时,shell尚未完成exec调用。

再重申一遍,该管道中的最后一个进程是shell的子进程,而执行管道中其他命令的进程则是该最后进程的子进程。图9-10 显示了所发生的情况。因为该管道线中的最后一个进程是登录shell的子进程,当该进程(cat2)终止时,shell得到通知。

图9-10 Bourne shell执行管道ps | cat1 | cat2时的进程

现在让我们用一个运行在 Linux 上的作业控制 shell 来检验同一个例子。这将显示这些 shell处理后台作业的方法。在本例中将使用Bourne-again shell,用其他作业控制shell得到的结果几乎是一样的。

ps -o pid,ppid,pgid,sid,tpgid,comm

其输出为:

PID PPID PGID SID TPGID COMMAND

2837 2818 2837 2837 5796 bash

5796 2837 5796 2837 5796 ps

(从本例开始,以粗体显示前台进程组。)我们立即看到了与Bourne shell例子的区别。Bourne-again shell将前台作业(ps)放入了它自己的进程组(5796)。ps命令是进程组组长进程,也是该进程组的唯一进程。进一步而言,此进程组具有控制终端,所以它是前台进程组。我们的登录 shell在执行ps命令时是后台进程组。但需要注意的是,这两个进程组2837和5796都是同一会话的成员。事实上,在本节的各实例中,会话决不会改变。

在后台执行此进程:

ps -o pid,ppid,pgid,sid,tpgid,comm &

其输出为:

PID PPID PGID SID TPGID COMMAND

2837 2818 2837 2837 2837 bash

5797 2837 5797 2837 2837 ps

再一次,ps命令被放入它自己的进程组,但是此时进程组(5797)不再是前台进程组,而是一个后台进程组。TPGID 2837指示前台进程组是登录shell。

按下列方式在一个管道中执行两个进程:

ps -o pid,ppid,pgid,sid,tpgid,comm | cat1

其输出为:

PID PPID PGID SID TPGID COMMAND

2837 2818 2837 2837 5799 bash

5799 2837 5799 2837 5799 ps

5800 2837 5799 2837 5799 cat1

两个进程ps和cat1都在一个新进程组(5799)中,这是一个前台进程组。在本例和类似的Bourne shell 实例之间能看到另一个区别。Bourne shell 首先创建将执行管道中最后一条命令的进程,而此进程是第一个进程的父进程。在这里,Bourne-again shell是两个进程的父进程。但是,如果在后台执行此管道:

ps -o pid,ppid,pgid,sid,tpgid,comm | cat1 &

其结果是类似的,但是ps和cat1现在都处于同一后台进程组。

PID PPID PGID SID TPGID COMMAND

2837 2818 2837 2837 2837 bash

5801 2837 5801 2837 2837 ps

5802 2837 5801 2837 2837 cat1

注意,使用的shell不同,创建各个进程的顺序也可能不同。

9.10 孤儿进程组

我们曾提及,一个其父进程已终止的进程称为孤儿进程(orphan process),这种进程由init进程“收养”。现在我们要说明整个进程组也可成为“孤儿”,以及POSIX.1如何处理它。

实例

考虑一个进程,它fork了一个子进程然后终止。这在系统中是经常发生的,并无异常之处,但是在父进程终止时,如果该子进程停止(用作业控制)又将如何呢?子进程如何继续,以及子进程是否知道它已经是孤儿进程?图9-11显示了这种情形:父进程已经fork了子进程,该子进程停止,父进程则将退出。

构成此种情形的程序示于图9-12中。下面要说明该程序的某些新特性。这里,假定使用了一个作业控制 shell。回忆前面所述,shell 将前台进程放在它(指前台进程)自已的进程组中(本例中是6099),shell则留在自己的进程组内(2837)。子进程继承其父进程(6099)的进程组。在fork之后:

图9-11 将要成为孤儿的进程组实例

•父进程睡眠5秒,这是一种让子进程在父进程终止之前运行的一种权宜之计。

•子进程为挂断信号(SIGHUP)建立信号处理程序。这样就能观察到SIGHUP信号是否已发送给子进程。(第10章将讨论信号处理程序。)

•子进程用kill函数向其自身发送停止信号(SIGTSTP)。这将停止子进程,类似于用终端挂起字符(Ctrl+Z)停止一个前台作业。

•当父进程终止时,该子进程成为孤儿进程,所以其父进程ID成为1,也就是init进程ID。

•现在,子进程成为一个孤儿进程组的成员。POSIX.1将孤儿进程组(orphaned process group)定义为:该组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员。对孤儿进程组的另一种描述可以是:一个进程组不是孤儿进程组的条件是——该组中有一个进程,其父进程在属于同一会话的另一个组中。如果进程组不是孤儿进程组,那么在属于同一会话的另一个组中的父进程就有机会重新启动该组中停止的进程。在这里,进程组中每一个进程的父进程(例如,进程6100的父进程是进程1)都属于另一个会话。所以此进程组是孤儿进程组。

•因为在父进程终止后,进程组包含一个停止的进程,进程组成为孤儿进程组,POSIX.1要求向新孤儿进程组中处于停止状态的每一个进程发送挂断信号(SIGHUP),接着又向其发送继续信号(SIGCONT)。

•在处理了挂断信号后,子进程继续。对挂断信号的系统默认动作是终止该进程,为此必须提供一个信号处理程序以捕捉该信号。因此,我们期望sig_hup函数中的printf会在pr_ids函数中的printf之前执行。

图9-12 创建一个孤儿进程组

下面是图9-12中的程序的输出:

$ ./a.out

parent: pid = 6099, ppid = 2837, pgrp = 6099, tpgrp = 6099

child: pid = 6100, ppid = 6099, pgrp = 6099, tpgrp = 6099

$ SIGHUP received, pid = 6100

child: pid = 6100, ppid = 1, pgrp = 6099, tpgrp = 2837

read error 5 on controlling TTY

注意,因为两个进程,登录shell和子进程都写向终端,所以shell提示符和子进程的输出一起出现。正如我们所期望的那样,子进程的父进程ID变成1。

在子进程中调用pr_ids后,程序企图读标准输入。如前所述,当后台进程组试图读控制终端时,对该后台进程组产生SIGTTIN。但在这里,这是一个孤儿进程组,如果内核用此信号停止它,则此进程组中的进程就再也不会继续。POSIX.1规定,read返回出错,其errno设置为EIO (在本书所用的系统中其值是5)。

最后,要注意的是父进程终止时,子进程变成后台进程组,因为父进程是由shell作为前台作业执行的。

在19.5节的pty程序中将会看到孤儿进程组的另一个例子。

9.11 FreeBSD实现

前面说明了进程、进程组、会话和控制终端的各种属性,值得观察一下所有这些是如何实现的。下面简要说明FreeBSD中的实现。SVR4实现的某些详细情况则请参阅Williams[1989]。图9-13显示了FreeBSD使用的各种有关数据结构。

下面从session结构开始说明图中标出的各个字段。每个会话都分配一个session结构(例如,每次调用setsid时)。

•s_count是该会话中的进程组数。当此计数器减至0时,则可释放此结构。

•s_leader是指向会话首进程proc结构的指针。

•s_ttyvp是指向控制终端vnode结构的指针。

•s_ttyp是指向控制终端tty结构的指针。

•s_sid是会话ID。请记住会话ID这一概念并非Single UNIX Specification的组成部分。

在调用setsid时,在内核中分配一个新的session结构。s_count设置为1,s_leader设置为调用进程 proc 结构的指针,s_sid 设置为进程 ID,因为新会话没有控制终端,所以s_ttyvp和s_ttyp设置为空指针。

接着说明 tty 结构。每个终端设备和每个伪终端设备均在内核中分配这样一种结构(第 19章将对伪终端做更多说明)。

•t_session指向将此终端作为控制终端的session结构(注意,tty结构指向session结构,session结构也指向tty结构)。终端在失去载波信号时使用此指针将挂起信号发送给会话首进程(见图9-7)。

图9-13 会话和进程组的FreeBSD实现

• t_pgrp指向前台进程组的pgrp结构。终端驱动程序用此字段将信号发送给前台进程组。由输入特殊字符(中断、退出和挂起)而产生的3个信号被发送至前台进程组。

• t_termios是包含所有这些特殊字符和与该终端有关信息(如波特率、回显打开或关闭等)的结构。第18章将再说明此结构。

• t_winsize是包含终端窗口当前大小的winsize型结构。当终端窗口大小改变时,信号SIGWINCH被发送至前台进程组。18.12节将说明如何设置和获取终端当前窗口大小。

为了找到特定会话的前台进程组,内核从session结构开始,然后用s_ttyp得到控制终端的tty结构,再用t_pgrp得到前台进程组的pgrp结构。

pgrp结构包含一个特定进程组的信息。其中各相关字段具体如下。

•pg_id是进程组ID。

•pg_session指向此进程组所属会话的session结构。

• pg_members 是指向此进程组proc 结构表的指针,该 proc 结构代表进程组的成员。proc结构中p_pglist结构是双向链表,指向该组中的下一个进程和上一个进程。直到遇到进程组中的最后一个进程,它的proc结构中p_pglist结构为空指针。

proc结构包含一个进程的所有信息。

•p_pid包含进程ID。

•p_pptr是指向父进程proc结构的指针。

•p_pgrp指向本进程所属的进程组的pgrp结构的指针。

•p_pglist是一个结构,其中包含两个指针,分别指向进程组中上一个和下一个进程。

最后还有一个vnode结构。如前所述,在打开控制终端设备时分配此结构。进程对/dev/tty的所有访问都通过vnode结构。

9.12 小结

本章说明了进程组之间的关系——会话,它由若干个进程组组成。作业控制是当今很多UNIX系统所支持的功能,本章说明了它是如何由支持作业控制的shell实现的。在这些进程关系中也涉及了进程的控制终端/dev/tty。

所有这些进程的关系都使用了很多信号方面的功能。下一章将详细讨论UNIX中的信号机制。

习题

9.1 考虑6.8节中说明的utmp和wtmp文件,为什么logout记录是由init进程写的?对于网络登录的处理与此相同吗?

9.2 编写一段程序调用fork并使子进程建立一个新的会话。验证子进程变成了进程组组长且不再有控制终端。