进程关系
0号进程由系统创建。
在数据结构上,父进程PCB中的指针p_cptr指向最近创建的一个子进程的PCB块,每个子进程的PCB中的p_pptr指针都指向父进程的PCB块,这一对指针构成了父子关系;出最老的子进程外,每个子进程的PCB块中的p_osptr指向父进程创建的上一个子进程PCB,反之除了最新的子进程外,每个子进程的PCB块中的p_ysptr指向父进程创建的后一个子进程的PCB,这样父进程创建的每个子进程按“年龄”构成了一个双向链表,父进程可以通过其p_cptr指针,从最新创建的子进程开始,依次访问到每个子进程。
系统启动时,自行创建0号进程,其运行代码的函数是init_task(),该进程的作用是作为一切其他的父进程,0号进程不能自动生成,必须手工将其设置到进程表中,才能启动进程管理机制。
系统管理员创建一个通常名为/etc/ttys的文件,其中,每个终端设备有一行,每一行说明设备名和传到getty程序的参数,这些参数说明了终端的波特率。当系统bootstrap时内核创建进程ID 1,也就是init进程。init进程使系统进入多用户状态。init读文件/etc/ttys,对每一个允许登录的终端设备,init调用一次fork,它所生成的子进程则执行程序getty。这种情况见下图:
图中每个进程的实际用户ID和有效用户ID都是0(即都有root权限)。init以空环境执行getty程序。
getty对终端设备调用open函数,以读、写方式将终端打开。如果设备是调制解调器,则open可能会在设备驱动程序中滞留,直到用户拨号调制解调器,并且线路被接通。一旦设备被打开,则文件描述符0、1、2就被设置到该设备。然后getty输出"login:"之类的信息,并等待用户键入用户名。如果终端支持多种速度,则getty可以测试特殊字符以便适当地更改终端速度(波特率)。
当用户键入用户名后,getty就完成了,然后它以类似于下列的方式调用login程序:
execle("/usr/bin/login", "login", "-p" username, (char *) 0, envp);
(在gettytab文件中可能会有一些选择使其调用其他程序,但系统默认是login程序)。init以一个空环境调用getty。getty以终端名(例如TERM=foo,其中终端foo的类型取自gettytab文件)和在gettytab中的环境字符串为login创建一个环境(envp参数)。-p标志通知login保留传给它的环境,也可以将其他环境字符串加到该环境中,但是不要替换它。下图显示了login刚被调用后这些进程的状态。
因为最初的init进程具有root权限,所以图中所有进程都有root权限。图中底部三个进程的进程ID相同,因为进程ID不会因执行exec而改变。并且除了最初的init进程,所有的进程均有一个父进程ID。
login能处理多项工作。因为它得到了用户名,所以能调用getpwnam取得相应用户的口令文件登陆项。然后调用getpass以显示提示"Password:"接着读用户键入的口令。它调用crypt将用户键入的口令加密,并与该用户口令文件中登陆项的pw_passwd字段相比较。如果用户几次键入的口令都无效,则login以参数1调用exit表示登录过程失败。父进程(init)了解到子进程的终止情况后,将再次调用fork,其后又跟着执行getty,对此终端重复上述过程。
如果用户正确登录,login就将当前工作目录更改为用户的home目录。它也调用chown改变该终端的所有权,使该用户成为所有者和组所有者。将对该终端设备的存取许可权改变成:用户读、写和组写。调用setgid及initgroup设置进程的组ID。然后调用login所得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户名(USER和LOGNAME),以及一个系统默认路径(PATH)。最后login进程改变为登录用户的用户ID(setuid)并调用该用户的登陆shell,其方式类似于:
execl("/bin/sh", "-sh", (char *) 0);
argv[0]的第一个字符是一个标志,表示该shell被调用为登录shell。shell可以查看此字符,并相应地修改其启动过程
login所做的比上面说的要多。
到此为止,登录用户的登录shell开始运行。其父进程ID是init进程ID(进程ID 1),所以当此进程终止时,init进程会收到通知(接收到SIGGHLD信号),它会对该终端重复全部上述过程。登陆shell的文件描述符0,1和2设置为终端设备。下图显示了这种安排。
现在登录shell读其启动文件。这些启动文件通常改变某些环境变量,加上一些环境变量。当执行完启动文件后,用户最后得到shell的提示符,并能键入命令。
网络登录
4.3+BSD网络登录
终端登录时,init知道哪些终端设备可用来登录,并为每一个设备生成一个getty进程。但是网络登录都经过内核的网络界面驱动程序,事先并不知道有多少个这样的登录。不是使一个进程等待每一个可能的登录,而是必须等待一个网络连接请求的到达。在4.3+BSD中,有一个称为inetd的进程,它等待大多数网络连接。
作为系统启动的一部分,init调用一个shell,使其执行shell脚本stc/rc。由此shell脚本启动一个精灵进程inetd。一旦此shell脚本终止,inetd的父进程就变成init。inetd等待TCP/IP连接请求到达主机,而当一个连接请求到达时,它执行一次fork,然后该子进程执行适当的程序。
我们假定到达了一个对于TELNET服务器的TCP连接请求。TELNET是使用TCP协议的远程登录应用程序。在另一个主机上的用户,或在同一个主机上的用户启动TELNET客户端进程启动登录过程:
telnet hostname
该客户端进程打开一个到名为hostname的主机的TCP连接,在hostname主机上启动的程序被称为TELNET服务器。然后客户端进程和服务器进程之间使用TELNET应用协议通过TCP连接交换数据。所发生的是启动客户端进程的用户现在登录到了服务器进程所在的主机。下图显示了在执行TELNET服务器进程(称为telnetd)中所涉及的进程序列。
然后telnetd进程打开一个伪终端设备,并用fork生成一个子进程。父进程处理通过网络连接的通信,子进程则执行login程序。父、子进程通过伪终端相连接。在调用exec之前,子进程使其文件描述符0,1,2与伪终端相连。如果登录正确,login就执行:更改当前工作目录为起始目录,设置登录用户的组ID和用户ID,以及登录用户的起始环境。然后login用exec将其自身替换为登录用户的登录shell。下图显示了到达这一点时的进程安排
当通过终端或网络登录时,我们得到一个登录shell,其标准输入、输出和标准出错连接到一个终端或者伪终端设备上。
进程组
每个进程除了有一进程ID之外,还属于一个进程组,进程组是一个或多个进程的集合,每个进程组有一个唯一的进程组ID。同一进程组的各个进程接受来自同一终端的各种信号。
#include <sys/types.h> #include <unistd.h> pid_t getpgrp(void); //返回值: 调用进程的进程组ID
每个进程组都有一个组长进程。组长进程的标识是:其进程组ID等于其进程ID。
进程组组长可以创建一个进程组,创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,该进程组就存在,与进程组长是否终止无关。从进程组创建到其中最后一个进程终离开(该进程可以终止也可以加入另一个进程组)的时间区间称为进程组的生命期。
进程调用setpgid可以参加一个现存的组或者创建一个新进程组
#include <sys/types.h> #include <unistd.h> int setpgid(pid_t pid, pid_t pgid); //返回值: 若成功则为0,出错为-1
这将pid进程的进程组ID设置为pgid。如果这两个参数相等,则由pid指定的进程变成进程组组长
一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用了exec后,它就不再改变该子进程的进程组ID
如果pid是0,则使用调用者的进程ID。如果pgid是0,则由pid指定的进程ID被用作为进程组ID。
如果系统不支持作业控制,那么就不定义_POSIX_JOB_CONTROL,在这种情况下,该函数返回错误,errno设置为ENOSYS。
在大多数作业控制shell中,在fork之后调用此函数,使父进程设置其子进程的进程组ID,然后使子进程设置其自己的进程组ID。这些调用中有一个是冗余的,但这样做可以保证父、子进程在进一步操作之前,子进程都进入了该进程组。如果不这样做的话,那么就产生一个竟态条件,因为它依赖于哪一个进程先执行。
会话
对话期(session)是一个或多个进程组的集合。例如,可以有下图中所示的安排。在一个对话期中有三个进程组。通常由shell的管道线将几个进程编成一组的。例如下图中的安排可能是由下列形式的shell命令形成的:
proc1 | proc2 &
proc3 | proc4 | proc5
进程调用setsid函数就可以建立一个新对话期。
#include <sys/types.h> #include <unistd.h> pid_t setsid(void); 返回值:若成功则为进程组ID,若出错则为-1
如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新对话期,结果为:
- 此进程变成该新对话期的对话期的首进程(session leader, 对话期首进程是创建该对话期的进程)。此进程是该新对话期中的唯一进程。
- 此进程成为一个新进程组的组长进程。新进程组ID是此调用进程的进程ID。
- 此进程没有控制终端。如果在调用setsid之前此进程有一个控制终端,那么这种联系也被解除。
如果此调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,所以其不可能是进程组组长。
前台任务:独占命令行窗口,只有运行完了或者手动中止,才能执行其他命令。
后台任务有两个特点:
- 继承当前 session (对话)的标准输出(stdout)和标准错误(stderr)。因此,后台任务的所有输出依然会同步地在命令行下显示。
- 不再继承当前 session 的标准输入(stdin)。你无法向这个任务输入指令了。如果它试图读取标准输入,就会暂停执行(halt)。
"后台任务"与"前台任务"的本质区别只有一个:是否继承标准输入。所以,执行后台任务的同时,用户还可以输入其他命令。
当用户退出session时:
- 用户准备退出 session
- 系统向该 session 发出
SIGHUP
信号 - session 将
SIGHUP
信号发给所有子进程 - 子进程收到
SIGHUP
信号后,自动退出
控制终端
- 一个对话期可以有一个单独的控制终端。通常是我们在其上登录的终端设备或伪终端设备。
- 建立与控制终端连接的对话期首进程,被称之为控制进程
- 一个对话期中的几个进程组可以被分成一个前台进程组以及一个或几个后台进程组
- 如果一个对话期有一个控制终端,则它有一个前台进程组,其他进程组则为后台进程组。
- 无论何时键入终端键(Ctrl-C)或退出键(Ctrl-\),就会造成中断信号或退出信号送至前台进程组的所有进程。
- 如果终端界面检测到调制解调器已经脱开连接,则将挂断信号送至控制进程。
这些特性见下图
登录时会自动建立控制终端
tcgetpgrp和tcsetpgrp
以下两个函数用来通知内核哪一个进程组是前台进程组,这样,终端设备驱动程序就能了解将终端输入和终端产生的信号送到何处。
#include <sys/types.h> #include <unistd.h> pid_t tcgetpgrp(int filedes); //返回值:成功返回前台进程组ID,出错-1 int tcsetpgrp(int filedes, pid_t pgrpid); //返回值:成功为0,出错为-1
函数tcgetpgrp返回前台进程组ID,它与在filedes上打开的终端相关。
如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpid。pgrpid值应当是在同一会话期中的一个进程组的ID。filedes必须引用该对话期的控制终端。
大多数程序并不直接调用这两个函数。它们通常由作业控制shell调用。只有定义了—_POSIX_JOB_CONTROL,这两个函数才被定义了。否则它们返回出错。
作业控制
作业控制允许在一个控制终端上启动多个作业(进程组),控制哪一个作业可以存取该终端,以及哪些作业在后台运行。作业控制要求三种形式的支持:
- 支持作业控制shell。
- 内核中的终端驱动程序必须支持作业控制
- 必须提供对某些作业控制信号的支持
一个作业只是几个进程的集合,通常是一个进程管道。例如:
vim abc.c
在前台启动了只有一个进程的一个作业。下面的命令:
pr *.c | lpr &
make all &
在后台启动了两个作业。这两个作业所调用的进程都在后台运行。 当启动一个后台作业时,shell赋予它一个作业标识。并打印一个或几个进程ID。下面的操作过程显示了KornShell是如何处理这一点的。
$ make all > Make.out & [1] 1475 $ pr *.c | lpr & [2] 1490 $ 键入回车 [2] + Done pr *.c | lpr & [1] + Done make all > Make.out &
make是作业号1,所启动的进程ID是1475.下一个管道线是作业号2,其中第一个进程的进程ID是1490。当作业已完成并且键入回车时,shell通知我们作业已完成。键入回车是为了让shell打印其提示符。shell并不在任意时间打印后台作业的状态改变,它只在打印其提示符之前这样做。
有三个特殊字符可使终端驱动程序产生信号,并将他们送至前台进程组,后台进程组作业不受影响。它们是:
- 终端字符(一般用DELETE或者Ctrl-C)产生SIGINT
- 退出字符(一般用Ctrl-\)产生SIGOUT
- 挂起字符(一般采用Ctrl-Z)产生SIGTSTP
如果后台作业试图读终端,终端驱动程序会检测这种情况,并且发送一个特定信号SIGTTIN给后台作业。这通常会停止此后台作业,而有关用户则会得到这种情况的通知,然后就可以将此作业转为前台作业运行,于是它就可以读终端。下面操作过程展示了这种情况:
$ cat > temp.foo & 在后台启动,但将从标准输入读 [1] 1681 $ 键入回车 [1] + Stopped (tty input) cat > temp.out & $ fg &1 使1号作业成为前台作业 cat > temp.foo shell告诉我们现在哪一个作业在前台 hello, world 输入1行 ^D 键入文件描述符 $ cat temp.foo 检查该行已送入文件 hello, world
shell在后台启动cat进程,但是当cat试图读其标准输入(控制终端)时,终端驱动程序知道它是后台作业,于是将SIGTTIN信号送至该后台作业。shell检测到其子进程的状态变化,并通知我们该作业已被停止。然后,用shell的fg命令将次停止的作业送入前台运行。这样做使shell将此作业转为前台进程组(tcsetpgrp),并将继续信号(SIGCONT)送给该进程组。因为该作业现在在前台进程组中,所以它可以读控制终端。
如果后台进程输出到控制终端会发生什么呢?这是一个可以允许或禁止的选择项。通常,可以用stty命令来改变这一选项
$ cat temp.foo & 在后台运行 [1] 1719 $ hello, world 在提示符出现后台作业的输出 键入回车 [1] + Done cat temp.foo & $ stty tostop 禁止后台作业向控制终端输出 $ cat temp.foo & 在后台再次运行 [1] 1721 $ 键入回车,发现作业已停止 [1] + Stopped(tty output) cat temp.foo & $ fg %1 将停止的作业恢复为前台作业 cat temp.foo shell告诉我们现在哪一个作业在前台 hello, world 该作业的输出
下图摘录了我们已说明的作业控制的某些功能。穿过终端驱动程序的实线表示:终端I/O和终端产生的信号总是从前台进程组连接到实际终端。对应于SIGTTOU信号的虚线表示后台进程组进程的输出是否出现在终端是可选择的。
shell执行程序
首先使用不支持作业控制的经典的Bourne shell。如果执行:
ps -xj
则其输出为: PPID PID PGID SID TPGID COMMAND 1 163 163 163 163 -sh 163 163 163 163 163 ps 结果略去了现在无关的列。shell和ps命令两者位于同一对话期和前台进程组(163)中。因为163是在TGPID列中显示的进程组,所以称其为前台进程组。
说进程与终端进程组ID(TPGID列)相关联并不当。进程并没有终端进程控制组。进程属于一个进程组,而进程组属于一个对话期。对话期可能有,也可能没有控制终端。如果它确有一个控制终端,则此终端设备知道其前台进程的进程组ID。这一值可以用tcsetpgrp函数在终端驱动程序中设置。前台进程组ID是终端的一个属性,而不是进程的属性。取自终端设备驱动程序的该值是ps在TPGID列中打印的值。如果ps发现此对话期没有控制终端,则它在该列打印1。
如果在后台执行命令:
ps -xj &
则唯一改变的值是命令的进程ID。
PPID PID PGID SID TPGID COMMAND 1 163 163 163 163 -sh 163 163 163 163 163 ps
因为这种shell不知道作业控制,所以后台作业没有构成另一个进程组,也没有从后台作业处取走控制终端。
看一下Bourne shell如何处理管道线。执行下列命令:
ps -xj | cat1
其输出是:
PPID PID PGID SID TPGID COMMAND 1 163 163 163 163 -sh 163 200 163 163 163 cat1 200 201 163 163 163 ps
(程序cat1只是标准cat程序的一个副本,但名字不同)管道中最后一个进程是shell的子进程,该管道中的第一个进程则是最后一个进程的子进程。从中可以看出,shell fork一个它的副本,然后此副本再为管道线中的每条命令各fork一个进程。
如果在后台执行此管道线:
ps -xj | cat1 &
则只有进程ID改变了。因为shell并不处理作业控制,后台进程的进程组ID仍是163,如果终端进程组ID一样。
在没有作业控制时如果后台作业试图读控制终端,其处理方法是:如果该进程自己不重新定向标准输入,则shell自动将后台进程的标准输入重新定向到/dev/null。读/dev/null则产生一个文件结束。这就意味着后台cat进程立即读到文件尾,并正常结束。
在一条管道中执行三个进程:
ps -xj | cat1 | cat2
该管道中的最后一个进程是shell的子进程,而执行管道中其他命令的进程则是该最后进程的子进程。下图展示了所发生的情况:
孤儿进程组
一个父进程已终止的子进程称为孤儿进程(orphan process),这种进程由init进程收养。整个进程组也可以成为孤儿。 考虑一个进程,它fork了一个子进程然后终止。这在系统中是进场发生的,但是在父进程终止时,如果该子进程停止(用作业控制)该如何?下面的程序就是这种情况的一个例子。下图显示了程序已经启动,父进程已经fork了子进程之后的情况。
#include <sys/types.h> #include <errno.h> #include <fcntl.h> #include <signal.h> #include "ourhdr.h" static void sig_hup(int); static void pr_ids(char *); int main(void) { char c; pid_t pid; pr_ids("parent"); if ( (pid = fork()) < 0) { fprint(stderr, "fork error\n"); exit(1); } else if (pid > 0) { sleep(5); // sleep 5等待子进程退出 exit(0); // 父进程退出 } else { // 子进程 pr_ids("child"); signal(SIGHUP, sig_hup); // kill(getpid(), SIGTSTP); pr_ids("child"); if (read(0, &c, 1) != 1) { printf("read error from control terminal,errno = %d\n", errno); } exit(0); } } static void sig_hup(int signo) { printf("SIGHUP received, pid = %d\n, getpid()"); return ; } static void pr_ids(char *name) { printf("%s: pid = %d, ppid = %d, pgrp = %d\n", name, getpid(), getppid(), getpgrp()); fflush(stdout); }
这里假定使用了一个作业控制shell。shell将前台进程放在一个进程组中(本例是512),shell则留在自己的组内(442)。子进程继承其父进程512进程组。在fork后:
- 父进程睡眠5秒,让子进程在父进程终止之前运行
- 子进程为挂断信号(SIGHUP)建立信号处理程序。
- 子进程用kill函数向其自身发送停止信号SIGTSTP。这停止了子进程,类似于用终端挂起字符(Ctrl-Z)停止一个前台作业。
- 当父进程终止时,该子进程成为孤儿进程,其父进程ID成为1,也就是init进程ID。
- 现在,子进程成为一个孤儿进程组的成员。POSIX.1将孤儿进程组定义为:该组中每一个成员的父进程或者是该组中的一个成员,或者不是该组所属对话期的成员。
- 因为在父进程终止后,进程组成为孤儿进程组,POSIX.1要求向新孤儿进程组中处于停止状态的每一个进程发送挂断信号(SIGHUP),接着又向其发送继续信号(SIGCONT)。
- 在处理了挂断信号后,子进程继续。对挂断信号的系统默认动作是终止该进程,为此必须提供一个信号处理函数来捕捉此信号。因此我们期望sig_hup函数中的printf会在pr_id函数中的printf之前执行。
下面是程序的输出:
因为两个进程,登录shell和子进程都写向终端,所以shell提示符和子进程的输出一起出现。
在子进程中调用pr_ids后程序企图读标准输入。正如前述,当后台进程组试图读控制终端时,则对该后台进程组产生SIGTTIN。但在这里这是一个孤儿进程组,如果内核用此信号终止它,则此进程组中的进程就再也不会继续。POSIX.1规定,read返回出错,其errno设置为EIO。
在父进程终止时,子进程变成后台进程组,因为父进程是由shell作为前台作业执行的。