一、控制终端
对话期和进程组有一些其他特性:
- 一个对话期可以有一个单独的控制终端。通常是我们在其上登录的终端设备或伪终端设备。
- 建立与控制终端连接的对话期首进程,被称之为控制进程
- 一个对话期中的几个进程组可以被分成一个前台进程组以及一个或几个后台进程组
- 如果一个对话期有一个控制终端,则它有一个前台进程组,其他进程组则为后台进程组。
- 无论何时键入终端键(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一个进程。
&nsbp;如果在后台执行此管道线:
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作为前台作业执行的。