所有现今的UNIX系统都支持解释器文件(interpreter file)。这种文件是文本文件,其起始行的形式是:
#! pathname [ optional-argument ]
在感叹号和pathname之间的空格是可选的。最常见的解释器文件以下列行开始:
#! /bin/sh
pathname通常是绝对路径名,对它不进行什么特殊的处理(不使用PATH进行路径搜索)。对这种文件的识别是由内核作为 exec系统调用处理的一部分来完成的。内核使调用 exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。一定要将解释器文件(文本文件,它以#!开头)和解释器(由该解释器文件第一行中的pathname指定)区分开来。
很多系统对解释器文件第一行有长度限制。这包括#!、pathname、可选参数、终止换行符以及空格数。
在FreeBSD 8.0中,该限制是4 097字节。Linux 3.2.0中,该限制为128字节。Mac OS X 10.6.8中,该限制为513字节,而Solaris 10的限制是1 024字节。
实例
让我们观察一个实例,从中可了解当被执行的文件是个解释器文件时,内核如何处理 exec
函数的参数及该解释器文件第一行的可选参数。图8-20中的程序调用exec执行一个解释器文件。
图8-20 执行一个解释器文件的程序
下面先显示要被执行的该解释器文件的内容(只有一行),接着是运行图8-20中的程序得到的结果。
$ cat /home/sar/bin/testinterp
#!/home/sar/bin/echoarg foo
$ ./a.out
argv[0]: /home/sar/bin/echoarg
argv[1]: foo
argv[2]: /home/sar/bin/testinterp
argv[3]: myarg1
argv[4]: MY ARG2
程序echoarg(解释器)回显每一个命令行参数(它就是图7-4中的程序)。注意,当内核exec解释器(/home/sar/bin/echoarg)时,argv[0]是该解释器的pathname,argv[1]是解释器文件中的可选参数,其余参数是pathname(/home/sar/bin/testinterp)以及图8-20所示的程序中调用execl的第2个和第3个参数(myarg1和MY ARG2)。调用 execl时的argv[1]和 argv[2]已右移了两个位置。注意,内核取 execl 调用中的 pathname 而非第一个参数(testinterp),因为一般而言,pathname包含了比第一个参数更多的信息。
实例
在解释器pathname后可跟随可选参数。如果一个解释器程序支持-f选项,那么在pathname后经常使用的就是-f。例如,可以以下列方式执行awk(1)程序:
awk -f myfile
它告诉awk从文件myfile中读awk程序。
在UNIX System V派生的很多系统中,常包含有awk语言的两个版本。awk常常被称为“老awk”,它是与V7一起分发的原始版本。nawk(新awk)包含了很多增强功能,对应于在Aho、Kernighan和Weinberger[1988]中说明的语言。此新版本提供了对命令行参数的访问,这是下面的例子所需的。Solaris 10 提供了两个版本。
POSIX 1003.2标准现在是Single UNIX Specification中基本POSIX.1规范的一部分。在该标准中,awk程序是其中的一个实用程序。该实用程序的基础也是Aho、Kernighan和Weinberger[1988]中所描述的语言。
Mac OS X 10.6.8中的awk版本基于贝尔实验室版本,并已将其放在公共域(public domain)中。FreeBSD 8.0和Linux的某些发行版提供GNU awk(gawk),它链接至名字awk。gawk版本遵循POSIX标准,但也包括了一些扩展。因为gawk和贝尔实验室的awk版本比较新,所以较之nawk 或老版本的 awk 更受人欢迎。(贝尔实验室的 awk 版本可从 http://cm.bell-labs.com/cm/cs/awkbook/index.html获取。)
在解释器文件中使用-f选项,可以写成:
#!/bin/awk –f
(在此解释器文件中后跟随awk程序)
例如,图8-21展示了在/usr/local/bin/awkexample中的一个解释器文件程序。
图8-21 作为解释器文件的awk程序
如果路径前缀之一是/usr/local/bin,则可以用下列方式执行图 8-21 中的程序(假定我们已打开了该文件的执行位):
$ awkexample filel FILENAME2 f3
ARGV[0] = awk
ARGV[1] = file1
ARGV[2] = FILENAME2
ARGV[3] = f3
执行/bin/awk时,其命令行参数是:
/bin/awk -f /usr/local/bin/awkexample file1 FILENAME2 f3
解释器文件的路径名(/usr/local/bin/awkexample)被传送给解释器。因为不能期望解释器(在本例中是/bin/awk)会使用 PATH 变量定位该解释器文件,所以只传送其路径名中的文件名是不够的,要将解释器文件完整的路径名传送给解释器。当awk读解释器文件时,因为#是awk的注释字符,所以它忽略第一行。
可以用下列命令验证上述命令行参数。
$ /bin/su 成为超级用户
Password: 输入超级用户口令
# mv /usr/bin/awk /usr/bin/awk.save 保存原先的程序
# cp /home/sar/bin/echoarg /usr/bin/awk 暂时替换它
# suspend 用作业控制挂起超级用户shell
[1] + Stopped /bin/su
$ awkexample file1 FILENAME2 f3
argv[0]: /bin/awk
argv[1]: -f
argv[2]: /usr/local/bin/awkexample
argv[3]: file1
argv[4]: FILENAME2
argv[5]: f3
$ fg 用作业控制恢复超级用户shell
/bin/su
# mv /usr/bin/awk.save /usr/bin/awk 恢复原先的程序
# exit 终止超级用户shell
在此例子中,解释器的-f选项是必需的。正如前述,它告诉awk在什么地方找到awk程序。如果在解释器文件中删除-f选项,则在试图运行该解释器文件时,通常输出一条出错消息。该出错消息的精确文本可能有所不同,这取决于解释器文件存放在何处以及其余参数是否表示现有的文件等。因为在这种情况下命令行参数是:
/bin/awk /usr/local/bin/awkexample file1 FILENAME2 f3
于是awk企图将字符串/usr/local/bin/awkexample解释为一个awk程序。如果不能向解释器传递至少一个可选参数(在本例中是-f),那么这些解释器文件只有对 shell才是有用的。
是否一定需要解释器文件呢?那也不完全如此。但是它们确实使用户得到效率方面的好处,其代价是内核的额外开销(因为识别解释器文件的是内核)。由于下述理由,解释器文件是有用的。
(1)有些程序是用某种语言写的脚本,解释器文件可将这一事实隐藏起来。例如,为了执行图8-21程序,只需使用下列命令行:
awkexample optional-arguments
而并不需要知道该程序实际上是一个awk脚本,否则就要以下列方式执行该程序:
awk -f awkexample optional-arguments
(2)解释器脚本在效率方面也提供了好处。再考虑一下前面的例子。仍旧隐藏该程序是一个awk脚本的事实,但是将其放在一个shell脚本中:
awk ’BEGIN {
for (i = 0; i < ARGC; i++)
printf "ARGV[%d] = %s\n", i, ARGV[i]
exit
}’ $*
这种解决方法的问题是要求做更多的工作。首先,shell读此命令,然后试图execlp此文件名。因为shell脚本是一个可执行文件,但却不是机器可执行的,于是返回一个错误,execlp就认为该文件是一个 shell 脚本(它实际上就是这种文件)。然后执行/bin/sh,并以该 shell 脚本的路径名作为其参数。shell正确地执行我们的shell脚本,但是为了运行awk程序,它调用fork、exec和wait。于是,用一个shell脚本代替解释器脚本需要更多的开销。
(3)解释器脚本使我们可以使用除/bin/sh以外的其他shell来编写shell脚本。当execlp找到一个非机器可执行的可执行文件时,它总是调用/bin/sh来解释执行该文件。但是,用解释器脚本则可简单地写成:
#!/bin/csh
(在解释器文件中后跟随C shell脚本)
再一次,我们也可将此放在一个/bin/sh脚本中(然后由其调用C shell),但是要有更多的开销。如果3个shell和awk没有用#作为注释符,则上面所说的都无效。
在程序中执行一个命令字符串很方便。例如,假定要将时间和日期放到某一个文件中,则可使用6.10节中的函数实现这一点。调用time得到当前日历时间,接着调用localtime将日历时间变换为年、月、日、时、分、秒、周日的分解形式,然后调用strftime对上面的结果进行格式化处理,最后将结果写到文件中。但是用下面的system函数则更容易做到这一点:
system("date > file");
ISO C定义了system函数,但是其操作对系统的依赖性很强。POSIX.1包括了system接口,它扩展了ISO C定义,描述了system在POSIX.1环境中的运行行为。
#include <stdlib.h>
int system(const char *cmdstring);
返回值:(见下)
如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。在UNIX中,system总是可用的。
因为system在其实现中调用了fork、exec和waitpid,因此有3种返回值。
(1)fork失败或者waitpid返回除EINTR之外的出错,则system返回−1,并且设置errno以指示错误类型。
(2)如果 exec失败(表示不能执行 shell),则其返回值如同 shell执行了 exit(127)一样。
(3)否则所有3个函数(fork、exec和waitpid)都成功,那么system的返回值是shell的终止状态,其格式已在waitpid中说明。
如果 waitpid 被一个捕捉到的信号中断,则某些早期的 system 实现都返回错误类型值EINTR。但是,因为没有可用的策略能让应用程序从这种错误类型中恢复(子进程的进程ID对调用者来说是未知的)。POSIX后来增加了下列要求:在这种情况下system不返回一个错误。(10.5节中将讨论被中断的系统调用。)
图8-22中的程序是system函数的一种实现。它对信号没有进行处理。10.18节中将修改此函数使其进行信号处理。
图8-22 system函数(没有对信号进行处理)
shell的-c选项告诉shell程序取下一个命令行参数(在这里是cmdstring)作为命令输入(而不是从标准输入或从一个给定的文件中读命令)。shell对以null字节终止的命令字符串进行语法分析,将它们分成命令行参数。传递给shell的实际命令字符串可以包含任一有效的shell命令。例如,可以用<和>对输入和输出重定向。
如果不使用shell执行此命令,而是试图由我们自己去执行它,那将相当困难。首先,我们必须用execlp而不是execl,像shell那样使用PATH变量。我们必须将null字节终止的命令字符串分成各个命令行参数,以便调用execlp。最后,我们也不能使用任何一个shell元字符。
注意,我们调用_exit而不是exit。这是为了防止任一标准I/O缓冲(这些缓冲会在fork中由父进程复制到子进程)在子进程中被冲洗。
用图8-23中的程序对这种实现的system函数进行测试(pr_exit函数定义在图8-5程序中)。
图8-23 调用system函数
运行图8-23程序得到:
$ ./a.out
Sat Feb 25 19:36:59 EST 2012
normal termination, exit status = 0 对于date
sh: nosuchcommand: command not found
normal termination, exit status = 127 对于无此种命令
sar console Jan 1 14:59
sar ttys000 Feb 7 19:08
sar ttys001 Jan 15 15:28
sar ttys002 Jan 15 21:50
sar ttys003 Jan 21 16:02
normal termination, exit status = 44 对于 exit
使用system而不是直接使用fork和exec的优点是:system进行了所需的各种出错处理以及各种信号处理(在10.18节中的下一个版本system函数中)。
在UNIX的早期系统中,包括SVR3.2和4.3BSD,都没有waitpid函数,于是父进程用下列形式的语句等待子进程:
while ((lastpid = wait(&status)) != pid && lastpid != -1)
;
如果调用 system 的进程在调用它之前已经生成子进程,那么将引起问题。因为上面的while语句一直循环执行,直到由system产生的子进程终止才停止,如果不是用pid标识的任一子进程在pid子进程之前终止,则它们的进程ID和终止状态都被while语句丢弃。实际上,由于wait 不能等待一个指定的进程以及其他一些原因,POSIX.1 Rationale 才定义了waitpid函数。如果不提供waitpid函数,popen和pclose函数也会发生同样的问题(见15.3节)。
设置用户ID程序
如果在一个设置用户 ID 程序中调用 system,那会发生什么呢?这是一个安全性方面的漏洞,决不应当这样做。图8-24程序是一个简单程序,它只是对其命令行参数调用system函数。
图8-24 用system执行命令行参数
将此程序编译成可执行目标文件tsys。
图8-25所示的是另一个简单程序,它打印实际用户ID和有效用户ID。
图8-25 打印实际用户ID和有效用户ID
将此程序编译成可执行目标文件printuids。运行这两个程序,得到如下结果:
$ tsys printuids 正常执行,无特权real uid = 205, effective uid = 205
normal termination, exit status = 0
$ su 成为超级用户
Password: 输入超级用户口令
# chown root tsys 更改所有者
# chmod u+s tsys 增加设置用户ID
# ls -l tsys 检验文件权限和所有者
-rwsrwxr-x 1 root 7888 Feb 25 22:13 tsys
# exit 退出超级用户shell
$ tsys printuids
real uid = 205, effective uid = 0 哎呀! 这是一个安全性漏洞
normal termination, exit status = 0
我们给予tsys程序的超级用户权限在system中执行了fork和exec之后仍被保持下来。
有些实现通过更改/bin/sh,当有效用户ID与实际用户ID不匹配时,将有效用户ID设置为实际用户ID,这样可以关闭上述安全漏洞。在这些系统中,上述示例的结果就不会发生。不管调用system的程序设置用户ID位状态如何,都会打印出相同的有效用户ID。
如果一个进程正以特殊的权限(设置用户ID或设置组ID)运行,它又想生成另一个进程执行另一个程序,则它应当直接使用fork和exec,而且在fork之后、exec之前要更改回普通权限。设置用户ID或设置组ID程序决不应调用system函数。
这种警告的一个理由是:system调用shell对命令字符串进行语法分析,而shell使用IFS变量作为其输入字段分隔符。早期的shell版本在被调用时不将此变量重置为普通字符集。这就允许一个恶意的用户在调用system之前设置IFS,造成system执行一个不同的程序。
大多数UNIX系统提供了一个选项以进行进程会计(process accounting)处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。本节将较详细地说明这种会计记录,这样也使我们得到了一个再次观察进程的机会,以及使用5.9节中所介绍的fread函数的机会。
任一标准都没有对进程会计进行过说明。于是,所有实现都有令人厌烦的差别。例如,关于I/O的数量,Solaris 10使用的单位是字节,FreeBSD 8.0和Mac OS X 10.6.8使用的单位是块,但又不考虑不同的块长,这使得该计数值并无实际效用。Linux 3.2.0则完全没有保持I/O统计数。
每种实现也都有自己的一套管理命令去处理这种原始的会计数据。例如,Solaris 提供了runacct(1m)和acctcom(1),FreeBSD则提供sa(8)命令处理并总结原始会计数据。
一个至今没有说明的函数(acct)启用和禁用进程会计。唯一使用这一函数的是accton(8)命令(这是在几种平台上都类似的少数几条命令中的一条)。超级用户执行一个带路径名参数的accton命令启用会计处理。会计记录写到指定的文件中,在FreeBSD和Mac OS X中,该文件通常是/var/account/acct;在Linux中,该文件是/var/account/pacct;在Solaris中,该文件是/var/adm/pacct。执行不带任何参数的accton命令则停止会计处理。
会计记录结构定义在头文件<sys/acct.h>中,虽然每种系统的实现各不相同,但会计记录样式基本如下:
typedef u_short comp_t; /* 3-bit base 8 exponent; 13-bit fraction*/
struct acct
{
char ac_flag; /* flag (see Figure 8.26)*/
char ac_stat; /* termination status(signal & core flag only)*/
/* (Solaris only)*/
uid_t ac_uid; /* real user ID*/
gid_t ac_gid; /* real group ID*/
dev_t ac_tty; /* controlling terminal*/
time_t ac_btime; /* starting calendar time*/
comp_t ac_utime; /* user CPU time*/
comp_t ac_stime; /* system CPU time*/
comp_t ac_etime; /* elapsed time*/
comp_t ac_mem; /* average memory usage*/
comp_t ac_io; /* bytes transferred (by read and write)*/
comp_t ac_rw; /* blocks read or written*/
char ac_comm[8]; /* command name: [8] for Solaris,*/
/* "blocks" on BSD systems*/
/* (not present on BSD systems)*/
/* [10] for Mac OS X, [16] for FreeBSD, and*/
/* [17] for Linux*/
};
在大多数的平台上,时间是以时钟滴答数记录的,但FreeBSD以微秒进行记录的。ac_flag成员记录了进程执行期间的某些事件。这些事件见图8-26。
图8-26 会计记录中的ac_flag值
会计记录所需的各个数据(各CPU时间、传输的字符数等)都由内核保存在进程表中,并在一个新进程被创建时初始化(如fork之后在子进程中)。进程终止时写一个会计记录。这产生两个后果。
第一,我们不能获取永远不终止的进程的会计记录。像init这样的进程在系统生命周期中一直在运行,并不产生会计记录。这也同样适合于内核守护进程,它们通常不会终止。
第二,在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。为了确定启动顺序,需要读全部会计文件,并按启动日历时间进行排序。这不是一种很完善的方法,因为日历时间的单位是秒(见 1.10 节),在一个给定的秒中可能启动了多个进程。而墙上时钟时间的单位是时钟滴答(通常,每秒滴答数在60~128)。但是我们并不知道进程的终止时间,所知道的只是启动时间和终止顺序。这就意味着,即使墙上时钟时间比启动时间要精确得多,仍不能按照会计文件中的数据重构各进程的精确启动顺序。
会计记录对应于进程而不是程序。在fork之后,内核为子进程初始化一个记录,而不是在一个新程序被执行时初始化。虽然exec并不创建一个新的会计记录,但相应记录中的命令名改变了,AFORK标志则被清除。这意味着,如果一个进程顺序执行了3个程序(A exec B、B exec C,最后是C exit),只会写一个会计记录。在该记录中的命令名对应于程序C,但CPU时间是程序A、B和C之和。
实例
为了得到某些会计数据以便查看,我们按图8-27编写了测试程序。
测试程序的源代码如图8-28所示。该程序调用4次fork。每个子进程做不同的事情,然后终止。
图8-27 会计处理实例的进程结构
图8-28 产生会计数据的程序
在Solaris上运行该测试程序,然后用图8-29中的程序从会计记录中选择一些字段并打印出来。
图8-29 打印从系统会计文件中选出的字段
BSD 派生的平台不支持 ac_stat 成员,所以我们在支持该成员的平台上定义了HAS_AC_STAT 常量。基于特性而非平台定义的符号常量使代码更易读,也使我们更容易修改程序。修改的方法是对编译命令增加新的定义。替代方法可以是使用:
#if !defined(BSD) && !defined(MACOS)
但是,当将应用移植到其他平台上时,这种方法会带来很大的不便。
我们定义了类似的常量以判断该平台是否支持ACORE和AXSIG会计标志。我们不能直接使用这两个标志符号,其原因是,在Linux中,它们被定义为enum类型值,而在#ifdef表达式中不能使用此种类型值。
为了进行测试,执行下列操作步骤。
(1)成为超级用户,用accton命令启用会计处理。注意,当此命令结束时,会计处理已经启用,因此在会计文件中的第一个记录应来自这一命令。
(2)终止超级用户shell,运行图 8-28程序。这会追加6个记录到会计文件中(超级用户shell一个、父进程一个、4个子进程各一个)。
在第二个子进程中,execl并不创建一个新进程,所以对第二个进程只有一个会计记录。(3)成为超级用户,停止会计处理。因为在accton命令终止时已经停止会计处理,所以不会在会计文件中增加一个记录。
(4)运行图8-29程序,从会计文件中选出字段并打印。
第4步的输出如下面所示。在每一行中都对进程追加了说明,以便后面讨论。
accton e = 1, chars = 336, stat = 0: S
sh e = 1550, chars = 20168, stat = 0: S
dd e = 2, chars = 1585, stat = 0: 第二个子进程
a.out e = 202, chars = 0, stat = 0: 父进程
a.out e = 420, chars = 0, stat = 134: F 第一个子进程
a.out e = 600, chars = 0, stat = 9: F 第四个子进程
a.out e = 801, chars = 0, stat = 0: F 第三个子进程
墙上时钟时间值的单位是每秒滴答数。从图2-15中可见,本系统的每秒滴答数是100。例如,在父进程中的 sleep(2)对应于墙上时钟时间 202 个时钟滴答。对于第一个子进程,sleep(4)变成420时钟滴答。注意,一个进程休眠的时间总量并不精确。(第10章将返回到sleep函数。)调用fork和exit也需要一些时间。
注意,ac_stat成员并不是进程的真正终止状态。它只是8.6节中讨论的终止状态的一部分。如果进程异常终止,则此字节包含的信息只是core标志位(一般是最高位)以及信号编号数(一般是低7位)。如果进程正常终止,则从会计文件不能得到进程的退出(exit)状态。对于第一个子进程,此值是128+6。128是core标志位,6是此系统信号SIGABRT的值(它是由调用abort产生的)。第四个子进程的值是9,它对应于SIGKILL的值。从会计文件的数据中不能分辨出,父进程在退出时所用的参数值是2,第三个子进程退出时所用的参数值是0。
dd进程将文件/etc/passwd复制到第二个子进程中,该文件的长度是777字节。而I/O字符数是此值的2倍,其原因是读了777字节,然后又写了777字节。即使输出到空设备,但仍对I/O 字符数进行计算。dd 命令还有 31 个附加字节,用于报告读写字节数的摘要信息,该摘要信息也会在stdout上打印输出。
ac_flag值与我们所预料的相同。除调用execl的第二个子进程以外,其他子进程都设置了F 标志。父进程没有设置F 标志,其原因是执行父进程的交互式 shell调用 fork,然后执行a.out文件。第一个子进程调用abort,abort产生信号SIGABRT,产生了core转储。该进程的X标志和D标志都没有打开,因为Solaris不支持它们;相关信息可从ac_stat字段导出。第四个子进程也因信号而终止,但是SIGKILL信号并不产生core转储,它只是终止该进程。
最后要说明的是:第一个子进程的 I/O 字符数为 0,但是该进程产生了一个 core 文件。其原因是写core文件所需的I/O并不由该进程负责。
任一进程都可以得到其实际用户ID和有效用户ID及组ID。但是,我们有时希望找到运行该程序用户的登录名。我们可以调用getpwuid(getuid()),但是如果一个用户有多个登录名,这些登录名又对应着同一个用户ID,又将如何呢?(一个人在口令文件中可以有多个登录项,它们的用户 ID 相同,但登录 shell 不同。)系统通常记录用户登录时使用的名字(见 6.8 节),用getlogin函数可以获取此登录名。
#include <unistd.h>
char *getlogin(void);
返回值:若成功,返回指向登录名字符串的指针;若出错,返回NULL
如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon),第 13章将对这种进程专门进行讨论。
给出了登录名,就可用getpwnam在口令文件中查找用户的相应记录,从而确定其登录shell等。
为了找到登录名,UNIX系统在历史上一直是调用ttyname函数(见18.9节),然后在utmp文件(见6.8节)中找匹配项。FreeBSD和Mac OS X将登录名存放在与进程表项相关联的会话结构中,并提供系统调用获取该登录名。
System V提供cuserid函数返回登录名。此函数先调用getlogin函数,如果失败则再调用getpwuid(getuid())。IEEE标准1003.1-1988说明了cuserid,但是它以有效用户ID而不是实际用户ID来调用。POSIX.1的1990版本删除了cuserid函数。
环境变量LOGNAME通常由login(1)以用户的登录名对其赋初值,并由登录shell继承。但是,用户可以修改环境变量,所以不能使用LOGNAME来验证用户,而应当使用getlogin函数。
UNIX 系统历史上对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级是由内核确定的。进程可以通过调整nice值选择以更低优先级运行(通过调整nice值降低它对CPU的占有,因此该进程是“友好的”)。只有特权进程允许提高调度权限。
POSIX实时扩展增加了在多个调度类别中选择的接口以进一步细调行为。我们这里只讨论用于调整nice值的接口,这些包括在POSIX.1的XSI扩展选项中。关于实时调度扩展更多的信息,可参考Gallmeister[1995]。
Single UNIX Specification 中 nice 值的范围在 0~(2*NZERO)-1 之间,有些实现支持 0~2*NZERO。nice值越小,优先级越高。虽然这看起来有点倒退,但实际上是有道理的:你越友好,你的调度优先级就越低。NZERO是系统默认的nice值。
注意,定义 NZERO 的头文件因系统而异。除了头文件以外,Linux 3.2.0 可以通过非标准的sysconf参数(_SC_NZERO)来访问NZERO的值。
进程可以通过nice函数获取或更改它的nice值。使用这个函数,进程只能影响自己的nice值,不能影响任何其他进程的nice值。
#include <unistd.h>
int nice(int incr);
返回值:若成功,返回新的nice值NZERO;若出错,返回−1
incr参数被增加到调用进程的nice值上。如果incr太大,系统直接把它降到最大合法值,不给出提示。类似地,如果incr太小,系统也会无声息地把它提高到最小合法值。由于−1是合法的成功返回值,在调用nice函数之前需要清楚errno,在nice函数返回−1时,需要检查它的值。如果nice调用成功,并且返回值为−1,那么errno仍然为0。如果errno不为0,说明nice调用失败。
getpriority函数可以像nice函数那样用于获取进程的nice值,但是getpriority还可以获取一组相关进程的nice值。
#include <sys/resource.h>
int getpriority(int which, id_t who);
返回值:若成功,返回-NZERO~NZERO-1之间的nice值;若出错,返回−1
which参数可以取以下三个值之一:PRIO_PROCESS 表示进程,PRIO_PGRP 表示进程组, PRIO_USER表示用户ID。which参数控制who参数是如何解释的,who参数选择感兴趣的一个或多个进程。如果who参数为0,表示调用进程、进程组或者用户(取决于which参数的值)。当which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用于多个进程,则返回所有作用进程中优先级最高的(最小的nice值)。
setpriority函数可用于为进程、进程组和属于特定用户ID的所有进程设置优先级。
#include <sys/resource.h>
int setpriority(int which, id_t who, int value);
返回值:若成功,返回0;若出错,返回−1
参数which和who与getpriority函数中相同。value增加到NZERO上,然后变为新的nice值。
nice 系统调用起源于早期 Research UNIX 系统的 PDP-11 版本。getpriority 和setpriority函数源于4.2BSD。
Single UNIX Specification没有对在fork之后子进程是否继承nice值制定规则,而是留给具体实现自行决定。但是遵循XSI的系统要求进程调用exec后保留nice值。
在FreeBSD 8.0、Linux 3.2.0、MacOS X 10.6.8以及Solaris 10中,子进程从父进程中继承nice值。
实例
图8-30的程序度量了调整进程nice值的效果。两个进程并行运行,各自增加自己的计数器。父进程使用了默认的nice值,子进程以可选命令参数指定的调整后的nice值运行。运行10 s后,两个进程都打印各自的计数值并终止。通过比较不同nice值的进程的计数值的差异,我们可以了解nice值时如何影响进程调度的。
图8-30 更改nice值的效果
执行该程序两次:一次用默认的nice值,另一次用最高有效nice值(最低调度优先级)。程序运行在单处理器Linux系统上,以显示调度程序如何在不同nice值的进程间进行CPU的共享。否则,对于有空闲资源的系统,如多处理器系统(或多核CPU),两个进程可能无需共享CPU(运行在不同的处理器上),就无法看出具有不同nice值的两个进程的差异。
$ ./a.out
NZERO = 20
current nice value in parent is 20
current nice value in child is 20, adjusting by 0
now child nice value is 20
child count = 1859362
parent count = 1845338
$ ./a.out 20
NZERO = 20
current nice value in parent is 20
current nice value in child is 20, adjusting by 20
now child nice value is 39
parent count = 3595709
child count = 52111
当两个进程的nice值相同时,父进程占用50.2%的CPU,子进程占用49.8%的CPU。可以看到,两个进程被有效地进行了平等对待。百分比并不完全相同,是因为进程调度并不精确,而且子进程和父进程在计算结束时间和处理循环开始时间之间执行了不同数量的处理。
相比之下,当子进程有最高可能nice值(最低优先级)时,我们看到父进程占用98.5%的CPU,而子进程只占用1.5%的 CPU。这些值取决于进程调度程序如何使用 nice 值,因此不同的 UNIX系统会产生不同的CPU占用比。
在1.10节中说明了我们可以度量的3个时间:墙上时钟时间、用户CPU时间和系统CPU时间。任一进程都可调用times函数获得它自己以及已终止子进程的上述值。
#include <sys/times.h>
clock_t times(struct tms *buf));
返回值:若成功,返回流逝的墙上时钟时间(以时钟滴答数为单位);若出错,返回-1
此函数填写由buf指向的tms结构,该结构定义如下:
struct tms {
clock_t tms_utime; /* user CPU time */
clock_t tms_stime; /* system CPU time */
clock_t tms_cutime; /* user CPU time,terminated children */
clock_t tms_cstime; /* system CPU time,terminated children */
};
注意,此结构没有包含墙上时钟时间。times函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用其相对值。例如,调用times,保存其返回值。在以后某个时间再次调用times,从新返回的值中减去以前返回的值,此差值就是墙上时钟时间。(一个长期运行的进程可能其墙上时钟时间会溢出,当然这种可能性极小,见习题1.5)。
该结构中两个针对子进程的字段包含了此进程用本章开始部分的wait函数族已等待到的各子进程的值。
所有由此函数返回的clock_t值都用_SC_CLK_TCK(由sysconf 函数返回的每秒时钟滴答数,见2.5.4节)转换成秒数。
大多数实现提供了getrusage(2)函数,该函数返回CPU时间以及指示资源使用情况的另外14个值。它起源于BSD系统,所以BSD派生的实现与其他实现比较,支持的字段要多一些。
实例
图8-31中的程序将每个命令行参数作为shell命令串执行,对每个命令计时,并打印从tms结构取得的值。
图8-31 计时并执行所有命令行参数
运行此程序可以得到:
$ ./a.out "sleep 5" "date" "man bash >/dev/null"
command: sleep 5
real: 5.01
user: 0.00
sys: 0.00
child user: 0.00
child sys: 0.00
normal termination, exit status = 0
command: date
Sun Feb 26 18:39:23 EST 2012
real: 0.00
user: 0.00
sys: 0.00
child user: 0.00
child sys: 0.00
normal termination, exit status = 0
command: man bash >/dev/null
real: 1.46
user: 0.00
sys: 0.00
child user: 1.32
child sys: 0.07
normal termination, exit status = 0
在前两个命令中,命令执行时间足够快避免了以可报告的精度记录CPU时间。但在第3个命令中,运行了一个处理时间足够长的命令来表明所有的CPU时间都出现在子进程中,而shell和命令正是在子进程中执行的。
对在UNIX环境中的高级编程而言,完整地了解UNIX的进程控制是非常重要的。其中必须熟练掌握的只有几个函数—fork、exec系列、_exit、wait和waitpid。很多应用程序都使用这些简单的函数。fork函数也给了我们一个了解竞争条件的机会。
本章说明了system函数和进程会计,这也使我们能进一步了解所有这些进程控制函数。本章还说明了exec函数的另一种变体:解释器文件及它们的工作方式。对各种不同的用户ID和组ID(实际、有效和保存的)的理解,对编写安全的设置用户ID程序是至关重要的。
在了解进程和子进程的基础上,下一章将进一步说明进程和其他进程的关系——会话和作业控制。第10章将说明信号机制并以此结束对进程的讨论。
8.1 在图8-3程序中,如果用exit调用代替_exit调用,那么可能会使标准输出关闭,使printf返回−1。修改该程序以验证在你所使用的系统上是否会产生此种结果。如果并非如此,你怎样处理才能得到类似结果呢?
8.2 回忆图7-6中典型的存储空间布局。由于对应于每个函数调用的栈帧通常存储在栈中,并且由于调用vfork后,子进程运行在父进程的地址空间中,如果不是在main函数中而是在另一个函数中调用vfork,此后子进程又从该函数返回,将会发生什么?请编写一段测试程序对此进行验证,并且画图说明发生了什么。
8.3 重写图8-6中的程序,把wait换成waitid。不调用pr_exit,而从siginfo结构中确定等价的信息。
8.4 当用$./a.out 执行图 8-13 中的程序一次时,其输出是正确的。但是若将该程序按下列方式执行多次,则其输出不正确。
$ ./a.out ; a.out ;./a.out
output from parent
ooutput from parent
ouotuptut from child
put from parent
output from child
utput from child
原因是什么?怎样才能更正此类错误?如果使子进程首先输出,还会发生此问题吗?
8.5 在图 8-20 所示的程序中,调用 execl,指定pathname为解释器文件。如果将其改为调用execlp,指定testinterp的filename,并且如果目录/home/sar/bin是路径前缀,则运行该程序时,argv[2]的打印输出是什么?
8.6 编写一段程序创建一个僵死进程,然后调用system执行ps(1)命令以验证该进程是僵死进程。8.7 8.10节中提及POSIX.1要求在exec时关闭打开目录流。按下列方法对此进行验证:对根目录调用opendir,查看在你系统上实现的DIR结构,然后打印执行时关闭标志。接着打开同一目录读并打印执行时关闭标志。
在上一章我们已了解到进程之间具有关系。首先,每个进程有一个父进程(初始的内核级进程通常是自己的父进程)。当子进程终止时,父进程得到通知并能取得子进程的退出状态。在8.6节说明waitpid函数时,我们也提到了进程组,以及如何等待进程组中的任意一个进程终止。
本章将更详细地说明进程组以及POSIX.1引入的会话的概念。还将介绍登录shell(登录时所调用的)和所有从登录shell启动的进程之间的关系。
在说明这些关系时不可能不谈及信号,而讨论信号时又需要很多本章介绍的概念。如果你不熟悉UNIX系统信号机制,则可能先要浏览一下第10章。
先说明当我们登录到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。
通过串行终端登录至系统和经由网络登录至系统两者之间的主要(物理上的)区别是:网络登录时,在终端和计算机之间的连接不再是点到点的。在网络登录情况下,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)。
每个进程除了有一进程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函数可被用来等待一个进程或者指定进程组中的一个进程终止。
会话(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。
会话和进程组还有一些其他特性。
•一个会话可以有一个控制终端(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)。
作业控制是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要求的部分。
让我们检验一下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不同,创建各个进程的顺序也可能不同。
我们曾提及,一个其父进程已终止的进程称为孤儿进程(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程序中将会看到孤儿进程组的另一个例子。
前面说明了进程、进程组、会话和控制终端的各种属性,值得观察一下所有这些是如何实现的。下面简要说明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结构。
本章说明了进程组之间的关系——会话,它由若干个进程组组成。作业控制是当今很多UNIX系统所支持的功能,本章说明了它是如何由支持作业控制的shell实现的。在这些进程关系中也涉及了进程的控制终端/dev/tty。
所有这些进程的关系都使用了很多信号方面的功能。下一章将详细讨论UNIX中的信号机制。
9.1 考虑6.8节中说明的utmp和wtmp文件,为什么logout记录是由init进程写的?对于网络登录的处理与此相同吗?
9.2 编写一段程序调用fork并使子进程建立一个新的会话。验证子进程变成了进程组组长且不再有控制终端。
信号是软件中断。很多比较重要的应用程序都需处理信号。信号提供了一种处理异步事件的方法,例如,终端用户键入中断键,会通过信号机制停止一个程序,或及早终止管道中的下一个程序。
UNIX系统的早期版本就已经提供信号机制,但是这些系统(如V7)所提供的信号模型并不可靠。信号可能丢失,而且在执行临界区代码时,进程很难关闭所选择的信号。4.3BSD 和 SVR3对信号模型都做了更改,增加了可靠信号机制。但是Berkeley和AT&T所做的更改之间并不兼容。幸运的是,POSIX.1对可靠信号例程进行了标准化,这正是本章所要说明的。
本章先对信号机制进行综述,并说明每种信号的一般用法。然后分析早期实现的问题。在分析存在的问题之后再说明解决这些问题的方法,这种安排有助于加深对改进机制的理解。本章也包含了很多并非完全正确的实例,这样做的目的是为了对其不足之处进行讨论。
首先,每个信号都有一个名字。这些名字都以3个字符SIG开头。例如,SIGABRT是夭折信号,当进程调用abort函数时产生这种信号。SIGALRM是闹钟信号,由alarm函数设置的定时器超时后将产生此信号。V7 有 15 种不同的信号,SVR4 和 4.4BSD 均有 31 种不同的信号。FreeBSD 8.0支持32种信号,Mac OS X 10.6.8以及Linux 3.2.0都支持31种信号,而Solaris 10支持40种信号。但是,FreeBSD、Linux和Solaris作为实时扩展都支持另外的应用程序定义的信号。虽然本书不包括POSIX实时扩展(有关信息请参阅Gallmeister[1995]),但是SUSv4已经把实时信号接口移至基础规范说明中。
在头文件<signal.h>中,信号名都被定义为正整数常量(信号编号)。
实际上,实现将各信号定义在另一个头文件中,但是该头文件又包括在<signal.h>中。内核包括对用户级应用程序有意义的头文件,这被认为是一种不好的形式,所以如若应用程序和内核两者都需使用同一定义,那么就将有关信息放置在内核头文件中,然后用户级头文件再包括该内核头文件。于是,FreeBSD 8.0和Mac OS X 10.6.8将信号定义在<sys/signal.h>中,Linux 3.2.0将信号定义在<bits/signum.h>中,Solaris 10将信号定义在<sys/iso/signal_iso.h>中。
不存在编号为 0 的信号。在 10.9 节中将会看到,kill 函数对信号编号 0 有特殊的应用。POSIX.1将此种信号编号值称为空信号。
很多条件可以产生信号。
•当用户按某些终端键时,引发终端产生的信号。在终端上按 Delete 键(或者很多系统中的Ctrl+C键)通常产生中断信号(SIGINT)。这是停止一个已失去控制程序的方法。(第18章将说明此信号可被映射为终端上的任一字符。)
•硬件异常产生信号:除数为0、无效的内存引用等。这些条件通常由硬件检测到,并通知内核。然后内核为该条件发生时正在运行的进程产生适当的信号。例如,对执行一个无效内存引用的进程产生SIGSEGV信号。
•进程调用kill(2)函数可将任意信号发送给另一个进程或进程组。自然,对此有所限制:接收信号进程和发送信号进程的所有者必须相同,或发送信号进程的所有者必须是超级用户。
•用户可用kill(1)命令将信号发送给其他进程。此命令只是kill函数的接口。常用此命令终止一个失控的后台进程。
•当检测到某种软件条件已经发生,并应将其通知有关进程时也产生信号。这里指的不是硬件产生条件(如除以 0),而是软件条件。例如 SIGURG(在网络连接上传来带外的数据)、SIGPIPE(在管道的读进程已终止后,一个进程写此管道)以及 SIGALRM(进程所设置的定时器已经超时)。
信号是异步事件的经典实例。产生信号的事件对进程而言是随机出现的。进程不能简单地测试一个变量(如errno)来判断是否发生了一个信号,而是必须告诉内核“在此信号发生时,请执行下列操作”。
在某个信号出现时,可以告诉内核按下列3种方式之一进行处理,我们称之为信号的处理或与信号相关的动作。
(1)忽略此信号。大多数信号都可使用这种方式进行处理,但有两种信号却决不能被忽略。它们是SIGKILL和SIGSTOP。这两种信号不能被忽略的原因是:它们向内核和超级用户提供了使进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(如非法内存引用或除以0),则进程的运行行为是未定义的。
(2)捕捉信号。为了做到这一点,要通知内核在某种信号发生时,调用一个用户函数。在用户函数中,可执行用户希望对这种事件进行的处理。例如,若正在编写一个命令解释器,它将用户的输入解释为命令并执行之,当用户用键盘产生中断信号时,很可能希望该命令解释器返回到主循环,终止正在为该用户执行的命令。如果捕捉到 SIGCHLD 信号,则表示一个子进程已经终止,所以此信号的捕捉函数可以调用waitpid以取得该子进程的进程ID以及它的终止状态。又例如,如果进程创建了临时文件,那么可能要为 SIGTERM 信号编写一个信号捕捉函数以清除临时文件(SIGTERM 是终止信号,kill 命令传送的系统默认信号是终止信号)。注意,不能捕捉SIGKILL和SIGSTOP信号。
(3)执行系统默认动作。图10-1给出了对每一种信号的系统默认动作。注意,对大多数信号的系统默认动作是终止该进程。
图10-1列出了所有信号的名字,说明了哪些系统支持此信号以及对于这些信号的系统默认动作。在SUS 列中,“•”表示此种信号定义为基本POSIX.1 规范部分,“XSI”表示该信号定义在XSI扩展部分。
在系统默认动作列,“终止+core”表示在进程当前工作目录的core文件中复制了该进程的内存映像(该文件名为core,由此可以看出这种功能很久之前就是UNIX的一部分)。大多数UNIX系统调试程序都使用core文件检查进程终止时的状态。
图10-1 UNIX系统信号
产生core文件是大多数UNIX系统的实现功能。虽然该功能不是POSIX.1的组成部分,但在Single UNIX Specification XSI的扩展部分中,这一功能作为一个潜在的特定实现的动作被提及。
在不同的实现中,core 文件的名字可能不同。例如,在 FreeBSD 8.0 中,core 文件名为cmdname.core,其中cmdname是接收到信号的进程所执行的命令名。在Mac OS X 10.6.8中,core文件名是core.pid,其中,pid是接收到信号的进程的ID。(这些系统允许经sysctl参数配置core文件名。在Linux 3.2.0中,core文件名通过/proc/sys/kernel/core_pattern进行配置。)
大多数实现在相应进程的工作目录中包含core文件项;但Mac OS X将所有core文件都放置在/cores目录中。
在下列条件下不产生core文件:(a)进程是设置用户ID的,而且当前用户并非程序文件的所有者;(b)进程是设置组ID的,而且当前用户并非该程序文件的组所有者;(c)用户没有写当前工作目录的权限;(d)文件已存在,而且用户对该文件设有写权限;(e)文件太大(回忆7.11节中的RLIMIT_CORE限制)。core文件的权限(假定该文件在此之前并不存在)通常是用户读/写,但Mac OS X只设置为用户读。
在图10-1说明中的“硬件故障”对应于实现定义的硬件故障。这些名字中有很多取自UNIX系统早先在PDP-11上的实现。请查看你所使用系统的手册,以确切地弄清楚这些信号对应于哪些错误类型。
下面较详细地逐一说明这些信号。
SIGABRT 调用abort函数时(见10.17节)产生此信号。进程异常终止。
SIGALRM 当用alarm函数设置的定时器超时时,产生此信号。详细情况见10.10节。若由setitimer(2)函数设置的间隔时间已经超时时,也产生此信号。
SIGBUS 指示一个实现定义的硬件故障。当出现某些类型的内存故障时(如 14.8 节中说明的),实现常常产生此种信号。
SIGCANCEL 这是Solaris线程库内部使用的信号。它不适用于一般应用。
SIGCHLD 在一个进程终止或停止时,SIGCHLD信号被送给其父进程。按系统默认,将忽略此信号。如果父进程希望被告知其子进程的这种状态改变,则应捕捉此信号。信号捕捉函数中通常要调用一种wait函数以取得子进程ID和其终止状态。System V的早期版本有一个名为SIGCLD(无H)的类似信号。这一信号具有与其他信号不同的语义,SVR2的手册页警告在新的程序中尽量不要使用这种信号。(令人奇怪的是,在SVR3和SVR4版的手册页中,该警告消失了。)应用程序应当使用标准的SIGCHLD信号,但应了解,为了向后兼容,很多系统定义了与SIGCHLD等同的SIGCLD。如果有使用SIGCLD的软件,需要查阅系统手册,了解它具体的语义。10.7节将讨论这两个信号。
SIGCONT 此作业控制信号发送给需要继续运行,但当前处于停止状态的进程。如果接收到此信号的进程处于停止状态,则系统默认动作是使该进程继续运行;否则默认动作是忽略此信号。例如,全屏编辑程序在捕捉到此信号后,使用信号处理程序发出重新绘制终端屏幕的通知。关于进一步的情况见10.21节。
SIGEMT 指示一个实现定义的硬件故障。
EMT这一名字来自PDP-11的仿真器陷入(emulator trap)指令。并非所有平台都支持此信号。例如,Linux只对SPARC、MIPS和PA_RISC等系统结构支持SIGEMT。
SIGFPE 此信号表示一个算术运算异常,如除以0、浮点溢出等。
SIGFREEZE 此信号仅由Solaris定义。它用于通知进程在冻结系统状态之前需要采取特定动作,例如当系统进入休眠或挂起状态时可能需要做这种处理。
SIGHUP 如果终端接口检测到一个连接断开,则将此信号送给与该终端相关的控制进程(会话首进程)。见图9-13,此信号被送给session结构中s_leader字段所指向的进程。仅当终端的CLOCAL标志没有设置时,在上述条件下才产生此信号。(如果所连接的终端是本地的,则设置该终端的CLOCAL标志。它告诉终端驱动程序忽略所有调制解调器的状态行。第18章将说明如何设置此标志。)
SIGILL 此信号表示进程已执行一条非法硬件指令。
SIGINFO 这是一种BSD信号,当用户按状态键(一般采用Ctrl+T)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程(见图 9-9)。此信号通常造成在终端上显示前台进程组中各进程的状态信息。
注意,接到此信号的会话首进程可能在后台,作为一个例子,请参见图9-7。这区别于由终端正常产生的几个信号(中断、退出和挂起),这些信号总是传递给前台进程组。
如果会话首进程终止,也产生此信号。在这种情况,此信号送给前台进程组中的每一个进程。
通常用此信号通知守护进程(见第13章)再次读取它们的配置文件。选用SIGHUP的理由是,守护进程不会有控制终端,通常决不会接收到这种信号。
4.3BSD的abort函数产生此信号。现在该函数产生SIGABRT信号。
虽然Alpha平台将SIGINFO定义为与SIGPWR具有相同值,但是Linux并不支持SIGINFO信号。这更多是因为需要对OSF/1开发的软件提供某种程度的兼容。
SIGINT 当用户按中断键(一般采用 Delete 或 Ctrl+C)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程(见图9-9)。当一个进程在运行时失控,特别是它正在屏幕上产生大量不需要的输出时,常用此信号终止它。
SIGIO 此信号指示一个异步I/O事件。在14.5.2节中将对此进行讨论。
在图10-1中,对SIGIO的系统默认动作是终止或忽略。遗憾的是,这依赖于系统。在System V中,SIGIO与SIGPOLL相同,其默认动作是终止此进程。在BSD中,其默认动作是忽略此信号。
Linux 3.2.0和Solaris 10将SIGIO定义为与SIGPOLL具有相同值,所以默认行为是终止该进程。在FreeBSD 8.0和Mac OS X 10.6.8中,默认行为是忽略该信号。
SIGIOT 这指示一个实现定义的硬件故障。
IOT这个名字来自于PDP-11,它是PDP-11计算机“输入/输出TRAP”(input/output TRAP)指令的缩写。System V的早期版本,由abort函数产生此信号。该函数现在产生SIGABRT信号。
FreeBSD 8.0、Linux 3.2.0、Mac OS X 10.6.8和Solaris 10将SIGIOT定义为与SIGABRT具相同值。
SIGJVM1 Solaris上为Java虚拟机预留的一个信号。
SIGJVM2 Solaris上为Java虚拟机预留的另一个信号。
SIGKILL 这是两个不能被捕捉或忽略信号中的一个。它向系统管理员提供了一种可以杀死任一进程的可靠方法。
SIGLOST 运行在Solaris NFSv4客户端系统中的进程,恢复阶段不能重新获得锁,此时将由这个信号通知该进程。
SIGLWP 此信号由Solaris线程库内部使用,并不做一般使用。在FreeBSD中,SIGLWP是SIGTHR的别名。
SIGPIPE 如果在管道的读进程已终止时写管道,则产生此信号。15.2 节将说明管道。当类型为 SOCK_STREAM 的套接字已不再连接时,进程写该套接字也产生此信号。我们将在第16章说明套接字。
SIGPOLL 这个信号在SUSv4中已被标记为弃用,将来的标准可能会将此信号移除。当在一个可轮询设备上发生一个特定事件时产生此信号。14.4.2节将说明poll函数和此信号,它起源于SVR3,与BSD的SIGIO和SIGURG信号接近。在Linux和Solaris中,SIGPOLL定义为与SIGIO具有相同值。
SIGPROF 这个信号在SUSv4中已被标记为弃用,将来的标准可能会将此信号移除。当setitimer(2)函数设置的梗概统计间隔定时器(profiling interval timer)已经超时时产生此信号。
SIGPWR 这是一种依赖于系统的信号。它主要用于具有不间断电源(UPS)的系统。如果电源失效,则UPS起作用,而且通常软件会接到通知。在这种情况下,系统依靠蓄电池电源继续运行,所以无须做任何处理。但是如果蓄电池也将不能支持工作,则软件通常会再次接到通知,此时,系统必项使其各部分都停止运行。这时应当发送 SIGPWR 信号。在大多数系统中,接到蓄电池电压过低信息的进程将信号SIGPWR发送给init进程,然后由init处理停机操作。
Solaris 10和有些Linux版本在inittab文件中有两个记录项用于此种目的:powerfail以及powerwait(或powerokwait)。
在图10-1中,我们将SIGPWR的默认动作标记为“终止或忽略”。遗憾的是,这种默认动作依赖于系统。Linux对此的默认动作是终止相关进程,而Solaris的默认动作是忽略该信号。
SIGQUIT 当用户在终端上按退出键(一般采用Ctrl+\)时,中断驱动程序产生此信号,并发送给前台进程组中的所有进程(见图9-9)。此信号不仅终止前台进程组(如SIGINT所做的那样),同时产生一个core文件。
SIGSEGV 指示进程进行了一次无效的内存引用(通常说明程序有错,比如访问了一个未经初始化的指针)。
名字SEGV代表“段违例”(segmentation violation)。
SIGSTKFLT 此信号仅由Linux定义。它出现在Linux的早期版本,企图用于数学协处理器的栈故障。该信号并非由内核产生,但仍保留以向后兼容。
SIGSTOP 这是一个作业控制信号,它停止一个进程。它类似于交互停止信号(SIGTSTP),但是SIGSTOP不能被捕捉或忽略。
SIGSYS 该信号指示一个无效的系统调用。由于某种未知原因,进程执行了一条机器指令,内核认为这是一条系统调用,但该指令指示系统调用类型的参数却是无效的。这种情况是可能发生的,例如,若用户编写了一道使用新系统调用的程序,然后运行该程序的二进制可执行代码,而所用的操作系统却是不支持该系统调用的较早版本,于是就出现上述情况。
SIGTERM 这是由kill(1)命令发送的系统默认终止信号。由于该信号是由应用程序捕获的,使用SIGTERM也让程序有机会在退出之前做好清理工作,从而优雅地终止(相对于SIGKILL而言。SIGKILL不能被捕捉或者忽略)。
SIGTHAW 此信号仅由Solaris定义。在被挂起的系统恢复时,该信号用于通知相关进程,它们需要采取特定的动作。
SIGTHR FreeBSD线程库预留的信号,它的值定义或与SIGLWP相同。
SIGTRAP 指示一个实现定义的硬件故障。
此信号名来自于PDP-11的TRAP指令。当执行断点指令时,实现常用此信号将控制转移至调试程序。
SIGTSTP 交互停止信号,当用户在终端上按挂起键(一般采用 Ctrl+Z)时,终端驱动程序产生此信号。该信号发送至前台进程组中的所有进程(参见图9-9)。
遗憾的是,停止具有不同的含义。当讨论作业控制和信号时,我们谈及停止和继续作业。但是,终端驱动程序一直使用术语“停止”表示用Ctrl+S字符终止终端输出,为了继续启动该终端输出,则用Ctrl+Q字符。为此,终端驱动程序称产生交互停止信号的字符为挂起字符,而非停止字符。
SIGTTIN当一个后台进程组进程试图读其控制终端时,终端驱动程序产生此信号(见9.8节中对此问题的讨论)。在下列例外情形下不产生此信号:(a)读进程忽略或阻塞此信号;(b)读进程所属的进程组是孤儿进程组,此时读操作返回出错,errno设置为EIO。
SIGTTOU 当一个后台进程组进程试图写其控制终端时,终端驱动程序产生此信号(见9.8节对此问题的讨论)。与上面所述的SIGTTIN信号不同,一个进程可以选择允许后台进程写控制终端。第18章将讨论如何更改此选项。
如果不允许后台进程写,则与SIGTTIN相似,也有两种特殊情况:(a)写进程忽略或阻塞此信号;(b)写进程所属进程组是孤儿进程组。在第2种情况下不产生此信号,写操作返回出错,errno设置为EIO。
不论是否允许后台进程写,一些除写以外的下列终端操作也能产生SIGTTOU信号,如tcsetattr、tcsendbreak、tcdrain、tcflush、tcflow以及tcsetpgrp。第18章将说明这些终端操作。
SIGURG 此信号通知进程已经发生一个紧急情况。在网络连接上接到带外的数据时,可选择地产生此信号。
SIGUSR1 这是一个用户定义的信号,可用于应用程序。
SIGUSR2 这是另一个用户定义的信号,与SIGUSR1相似,可用于应用程序。
SIGVTALRM 当一个由setitimer(2)函数设置的虚拟间隔时间已经超时时,产生此信号。
SIGWAITING 此信号由Solaris线程库内部使用,不做他用。
SIGWINCH 内核维持与每个终端或伪终端相关联窗口的大小。进程可以用ioctl函数(见18.12 节)得到或设置窗口的大小。如果进程用 ioctl 的设置窗口大小命令更改了窗口大小,则内核将SIGWINCH信号发送至前台进程组。
SIGXCPU Single UNIX Specification的XSI扩展支持资源限制的概念(见7.11节)。如果进程超过了其软CPU时间限制,则产生此信号。
在图10-1中,对于SIGXCPU的默认动作说明为“终止或终止+core”。该默认动作依赖于操作系统。Linux 3.2.0和Solaris 10支持的默认动作是终止并创建core文件;FreeBSD 8.0和Mac OS X 10.6.8支持的默认动作是终止且不产生core文件。Single UNIX Specification要求该默认动作是,异常终止该进程,是否创建core文件则留给实现决定。
SIGXFSZ 如果进程超过了其软文件长度限制(见7.11节),则产生此信号。
如同SIGXCPU一样,针对SIGXFSZ的默认动作依赖于操作系统。Linux 3.2.0和Solaris 10对此信号的默认动作是终止并创建core文件。FreeBSD 8.0和Mac OS X 10.6.8支持的默认动作是终止且不产生core文件。Single UNIX Specification要求该默认动作是异常终止该进程,是否创建core文件则留给实现决定。
SIGXRES 此信号仅由Solaris定义。可选择地使用此信号以通知进程超过了预配置的资源值。Solaris资源限制机制是一种通用设施,用于控制在独立应用集之间共享资源的使用。
UNIX系统信号机制最简单的接口是signal函数。
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
返回值:若成功,返回以前的信号处理配置;若出错,返回SIG_ERR
signal函数由ISO C定义。因为ISO C不涉及多进程、进程组以及终端I/O等,所以它对信号的定义非常含糊,以致于对UNIX系统而言几乎毫无用处。
从UNIX System V派生的实现支持signal函数,但该函数提供旧的不可靠信号语义(10.4节将说明这些旧的语义)。提供此函数主要是为了向后兼容要求此旧语义的应用程序,新应用程序不应使用这些不可靠信号。
4.4BSD 也提供 signal 函数,但它是按照 sigaction 函数定义的(10.14 节将说明sigaction 函数),所以在 4.4BSD 之下使用它提供新的可靠信号语义。目前大多数系统遵循这种策略,但Solaris 10沿用System V signal函数的语义。
因为signal的语义与实现有关,所以最好使用sigaction函数代替signal函数。在10.14节讨论sigaction函数时,提供了使用该函数的signal的一个实现。本书中的所有实例均使用图10-18中给出的signal函数,这样不管使用何种平台都可以有一致的语义。
signo参数是图10-1中的信号名。func的值是常量SIG_IGN、常量SIG_DFL或当接到此信号后要调用的函数的地址。如果指定SIG_IGN,则向内核表示忽略此信号(记住有两个信号SIGKILL和SIGSTOP不能忽略)。如果指定SIG_DFL,则表示接到此信号后的动作是系统默认动作(见图10-1中的最后一列)。当指定函数地址时,则在信号发生时,调用该函数,我们称这种处理为捕捉该信号,称此函数为信号处理程序(signal handler)或信号捕捉函数(signal-catching function)。
signal 函数原型说明此函数要求两个参数,返回一个函数指针,而该指针所指向的函数无返回值(void)。第一个参数signo 是一个整型数,第二个参数是函数指针,它所指向的函数需要一个整型参数,无返回值。signal 的返回值是一个函数地址,该函数有一个整型参数(即最后的(int))。用自然语言来描述也就是要向信号处理程序传送一个整型参数,而它却无返回值。当调用signal设置信号处理程序时,第二个参数是指向该函数(也就是信号处理程序)的指针。signal的返回值则是指向在此之前的信号处理程序的指针。
很多系统用附加的依赖于实现的参数来调用信号处理程序。10.14节将对此做进一步说明。
本节开头所示的signal函数原型太复杂了,如果使用下面的typedef[Plauger 1992],则可使其简单一些。
typedef void Sigfunc(int);
然后,可将signal函数原型写成:
Sigfunc *signal(int, Sigfunc *);
我们已将此typedef包括在apue.h文件中(见附录B),并随本章中的函数一起使用。
如果查看系统的头文件<signal.h>,则很可能会找到下列形式的声明:
#define SIG_ERR (void (*)())-1
#define SIG_DFL (void (*)())0
#define SIG_IGN (void (*)())1
这些常量可用于表示“指向函数的指针,该函数要求一个整型参数,而且无返回值”。signal的第二个参数及其返回值就可用它们表示。这些常量所使用的3 个值不一定是−1、0 和1,但它们必须是3个值而决不能是任一函数的地址。大多数UNIX系统使用上面所示的值。
实例
图10-2给出了一个简单的信号处理程序,它捕捉两个用户定义的信号并打印信号编号。10.10节将说明pause函数,它使调用进程在接到一信号前挂起。
图10-2 捕捉SIGUSR1和SIGUSR2的简单程序
我们使该程序在后台运行,并且用kill(1)命令将信号发送给它。注意,在UNIX系统中,杀死(kill)这个术语是不恰当的。kill(1)命令和 kill(2)函数只是将一个信号发送给一个进程或进程组。该信号是否终止进程则取决于该信号的类型,以及进程是否安排了捕捉该信号。
$ ./a.out & 在后台启动进程
[1] 7216 作业控制shell打印作业编号和进程ID
$ kill -USR1 7216 向该进程发送SIGUSR1
received SIGUSR1
$ kill -USR2 7216 向该进程发送SIGUSR2
received SIGUSR2
$ kill 7216 向该进程发送SIGTERM
[1]+ Terminated ./a.out
因为执行图10-2程序的进程不捕捉SIGTERM信号,而对该信号的系统默认动作是终止,所以当向该进程发送SIGTERM信号后,该进程就终止。
1.程序启动
当执行一个程序时,所有信号的状态都是系统默认或忽略。通常所有信号都被设置为它们的默认动作,除非调用exec的进程忽略该信号。确切地讲,exec函数将原先设置为要捕捉的信号都更改为默认动作,其他信号的状态则不变(一个进程原先要捕捉的信号,当其执行一个新程序后,就不能再捕捉了,因为信号捕捉函数的地址很可能在所执行的新程序文件中已无意义)。
一个具体例子是一个交互 shell 如何处理针对后台进程的中断和退出信号。对于一个非作业控制shell,当在后台执行一个进程时,例如:
cc main.c &
shell自动将后台进程对中断和退出信号的处理方式设置为忽略。于是,当按下中断字符时就不会影响到后台进程。如果没有做这样的处理,那么当按下中断字符时,它不但终止前台进程,也终止所有后台进程。
很多捕捉这两个信号的交互程序具有下列形式的代码:
void sig_int(int), sig_quit(int);
if (signal(SIGINT, SIG_IGN) != SIG_IGN)
signal(SIGINT, sig_int);
if (signal(SIGQUIT, SIG_IGN) != SIG_IGN)
signal(SIGQUIT, sig_quit);
这样处理后,仅当SIGINT和SIGQUIT当前未被忽略时,进程才会捕捉它们。
从signal的这两个调用中也可以看到这种函数的限制:不改变信号的处理方式就不能确定信号的当前处理方式。我们将在本章的稍后部分说明使用sigaction函数可以确定一个信号的处理方式,而无需改变它。
2.进程创建
当一个进程调用fork时,其子进程继承父进程的信号处理方式。因为子进程在开始时复制了父进程内存映像,所以信号捕捉函数的地址在子进程中是有意义的。
在早期的UNIX版本中(如V7),信号是不可靠的。不可靠在这里指的是,信号可能会丢失:一个信号发生了,但进程却可能一直不知道这一点。同时,进程对信号的控制能力也很差,它能捕捉信号或忽略它。有时用户希望通知内核阻塞某个信号:不要忽略该信号,在其发生时记住它,然后在进程做好了准备时再通知它。这种阻塞信号的能力当时并不具备。
4.2BSD对信号机制进行了更改,提供了被称为可靠信号的机制。然后,SVR3也修改了信号机制,提供了System V可靠信号机制。POSIX.1选择了BSD模型作为其标准化的基础。
早期版本中的一个问题是在进程每次接到信号对其进行处理时,随即将该信号动作重置为默认值(在前面运行图10-2程序时,每种信号只捕捉一次,从而回避了这一点)。在描述这些早期系统的编程书籍中,有一个经典实例,它与如何处理中断信号相关,其代码与下面所示的相似:
┇
int sig_int(); /* my signal handling function */
┇
signal(SIGINT, sig_int); /* establish handler */
sig_int()
{
signal(SIGINT, sig_int); /* reestablish handler for next time */
┇ /* process the signal ... */
}
这些早期版本的另一个问题是:在进程不希望某种信号发生时,它不能关闭该信号。进程能做的一切就是忽略该信号。有时希望通知系统“阻止下列信号发生,如果它们确实产生了,请记住它们。”能够显现这种缺陷的的一个经典实例是下列程序段,它捕捉一个信号,然后设置一个表示该信号已发生的标志:
int sig_int(); /* my signal handling function */
int sig_int_flag; /* set nonzero when signal occurs */
main()
{
}
signal(SIGINT, sig_int); /* establish handler */
┇
while (sig_int_flag == 0)
┇
pause(); /* go to sleep, waiting for signal */
sig_int()
(由于早期的C语言版本不支持ISO C的void数据类型,所以将信号处理程序声明为int类型。)这段代码的一个问题是:在信号发生之后到信号处理程序调用signal函数之间有一个时间窗口。在此段时间中,可能发生另一次中断信号。第二个信号会造成执行默认动作,而对中断信号的默认动作是终止该进程。这种类型的程序段在大多数情况下会正常工作,使得我们认为它们是正确无误的,而实际上却并非如此。
{
signal(SIGINT, sig_int); /* reestablish handler for next time */
sig_int_flag = 1; /* set flag for main loop to examine */
}
其中,进程调用 pause 函数使自己休眠,直到捕捉到一个信号。当捕捉到信号时,信号处理程序将标志 sig_int_flag 设置为非 0 值。从信号处理程序返回后,内核自动将该进程唤醒,它检测到该标志为非0,然后执行它所需做的。但是这里有一个时间窗口,在此窗口中操作可能失误。如果在测试sig_int_flag之后、调用pause之前发生信号,则此进程在调用pause时可能将永久休眠(假定此信号不会再次产生)。于是,这次发生的信号也就丢失了。这是另一个例子,某段代码并不正确,但是大多数时间却能正常工作。要查找并排除这种类型的问题很困难。
早期UNIX系统的一个特性是:如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就被中断不再继续执行。该系统调用返回出错,其errno设置为EINTR。这样处理是因为一个信号发生了,进程捕捉到它,这意味着已经发生了某种事情,所以是个好机会应当唤醒阻塞的系统调用。
在这里,我们必须区分系统调用和函数。当捕捉到某个信号时,被中断的是内核中执行的系统调用。
为了支持这种特性,将系统调用分成两类:低速系统调用和其他系统调用。低速系统调用是可能会使进程永远阻塞的一类系统调用,包括:
•如果某些类型文件(如读管道、终端设备和网络设备)的数据不存在,则读操作可能会使调用者永远阻塞;
•如果这些数据不能被相同的类型文件立即接受,则写操作可能会使调用者永远阻塞;
•在某种条件发生之前打开某些类型文件,可能会发生阻塞(例如要打开一个终端设备,需要先等待与之连接的调制解调器应答);
•pause函数(按照定义,它使调用进程休眠直至捕捉到一个信号)和wait函数;
•某些ioctl操作;
•某些进程间通信函数(见第15章)。
在这些低速系统调用中,一个值得注意的例外是与磁盘I/O有关的系统调用。虽然读、写一个磁盘文件可能暂时阻塞调用者(在磁盘驱动程序将请求排入队列,然后在适当时间执行请求期间),但是除非发生硬件错误,I/O操作总会很快返回,并使调用者不再处于阻塞状态。
可以用中断系统调用这种方法来处理的一个例子是:一个进程启动了读终端操作,而使用该终端设备的用户却离开该终端很长时间。在这种情况下,进程可能处于阻塞状态几个小时甚至数天,除非系统停机,否则一直如此。
对于中断的read、write系统调用,POSIX.1的语义在该标准的2001版有所改变。对于如何处理已 read、write 部分数据量的相应系统调用,早期版本允许实现自行选择。如若 read系统调用已接收并传送数据至应用程序缓冲区,但尚未接收到应用程序请求的全部数据,此时被中断,操作系统可以认为该系统调用失败,并将 errno 设置为 EINTR;另一种处理方式是允许该系统调用成功返回,返回值是已接收到的数据量。与此类似,如若write巳传输了应用程序缓冲区中的部分数据,然后被中断,操作系统可以认为该系统调用失败,并将errno设置为EINTR;另一种处理方式是允许该系统调用成功返回,返回值是已写部分的数据量。历史上,从System V派生的实现将这种系统调用视为失败,而 BSD 派生的实现则处理为部分成功返回。2001 版 POSIX.1标准采用BSD风格的语义。
与被中断的系统调用相关的问题是必须显式地处理出错返回。典型的代码序列(假定进行一个读操作,它被中断,我们希望重新启动它)如下:
again:
if ((n = read(fd, buf, BUFFSIZE)) < 0) {
if (errno == EINTR)
goto again; /* just an interrupted system call */
/* handle other errors */
}
为了帮助应用程序使其不必处理被中断的系统调用,4.2BSD引进了某些被中断系统调用的自动重启动。自动重启动的系统调用包括:ioctl、read、readv、write、writev、wait 和waitpid。如前所述,其中前5个函数只有对低速设备进行操作时才会被信号中断。而wait和waitpid 在捕捉到信号时总是被中断。因为这种自动重启动的处理方式也会带来问题,某些应用程序并不希望这些函数被中断后重启动。为此4.3BSD允许进程基于每个信号禁用此功能。
POSIX.1 要求只有中断信号的SA_RESTART标志有效时,实现才重启动系统调用。在10.14节将看到,sigaction函数使用这个标志允许应用程序请求重启动被中断的系统调用。
历史上,使用signal函数建立信号处理程序时,对于如何处理被中断的系统调用,各种实现的做法各不相同。System V的默认工作方式是从不重启动系统调用。而BSD则重启动被信号中断的系统调用。FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8中,当信号处理程序是用signal函数时,被中断的系统调用会重启动。但 Solaris 10 的默认方式是出错返回,将 errno 设置为EINTR。使用用户自己实现的signal函数(见图10-18)可以避免必须处理这些差异的麻烦。
4.2BSD引入自动重启动功能的一个理由是:有时用户并不知道所使用的输入、输出设备是否是低速设备。如果我们编写的程序可以用交互方式运行,则它可能读、写终端低速设备。如果在程序中捕捉信号,而且系统并不提供重启动功能,则对每次读、写系统调用就要进行是否出错返回的测试,如果是被中断的,则再调用读、写系统调用。
图10-3列出了几种实现所提供的与信号有关的函数及它们的语义。
图10-3 几种信号实现所提供的功能
应当了解,其他厂商提供的UNIX系统可能不同于图10-3中所示的情况。例如,SunOS 4.1.2中的sigaction默认方式是重启动被中断的系统调用,这与列在图10-3中的各平台不同。
在图10-18中,提供了我们自己的signal函数版本,它自动地尝试重启动被中断的系统调用(除 SIGALRM信号外)。在图10-19中则提供了另一个函数signal_intr,它不进行重启动。
在14.4节说明select和poll函数时,还将更多涉及被中断的系统调用。
进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令。如果从信号处理程序返回(例如没有调用 exit 或longjmp),则继续执行在捕捉到信号时进程正在执行的正常指令序列(这类似于发生硬件中断时所做的)。但在信号处理程序中,不能判断捕捉到信号时进程执行到何处。如果进程正在执行malloc,在其堆中分配另外的存储空间,而此时由于捕捉到信号而插入执行该信号处理程序,其中又调用malloc,这时会发生什么?又例如,若进程正在执行getpwnam(见6.2节)这种将其结果存放在静态存储单元中的函数,其间插入执行信号处理程序,它又调用这样的函数,这时又会发生什么呢?在malloc例子中,可能会对进程造成破坏,因为malloc通常为它所分配的存储区维护一个链表,而插入执行信号处理程序时,进程可能正在更改此链表。在getpwnam的例子中,返回给正常调用者的信息可能会被返回给信号处理程序的信息覆盖。
图10-4 信号处理程序可以调用的可重入函数
Single UNIX Specification说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全的(async-signal safe)。除了可重入以外,在信号处理操作期间,它会阻塞任何会引起不一致的信号发送。图10-4列出了这些异步信号安全的函数。没有列入图10-4中的大多数函数是不可重入的,因为(a)已知它们使用静态数据结构;(b)它们调用 malloc 或free;(c)它们是标准I/O函数。标准I/O库的很多实现都以不可重入方式使用全局数据结构。注意,虽然在本书的某些实例中,信号处理程序也调用了printf函数,但这并不保证产生所期望的结果,信号处理程序可能中断主程序中的printf函数调用。
应当了解,即使信号处理程序调用的是图10-4中的函数,但是由于每个线程只有一个errno变量(回忆1.7节对errno和线程的讨论),所以信号处理程序可能会修改其原先值。考虑一个信号处理程序,它恰好在main刚设置errno之后被调用。如果该信号处理程序调用read这类函数,则它可能更改errno的值,从而取代了刚由main设置的值。因此,作为一个通用的规则,当在信号处理程序中调用图10-4中的函数时,应当在调用前保存errno,在调用后恢复errno。(应当了解,经常被捕捉到的信号是SIGCHLD,其信号处理程序通常要调用一种wait函数,而各种wait函数都能改变errno。)
注意,图10-4没有包括longjmp(7.10节)和siglongjmp(10.15节)。这是因为主例程以非可重入方式正在更新一个数据结构时可能产生信号。如果不是从信号处理程序返回而是调用siglongjmp,那么该数据结构可能是部分更新的。如果应用程序将要做更新全局数据结构这样的事情,而同时要捕捉某些信号,而这些信号的处理程序又会引起执行siglongjmp,则在更新这种数据结构时要阻塞此类信号。
实例
图10-5给出了一段程序,这段程序从信号处理程序my_alarm调用非可重入函数getpwnam,而my_alarm每秒钟被调用一次。10.10节中将说明alarm函数。在该程序中调用alarm函数使得每秒产生一次SIGALRM信号。
图10-5 在信号处理程序中调用不可再入函数
运行该程序时,其结果具有随机性。通常,在信号处理程序经多次迭代返回时,该程序将由SIGSEGV信号终止。检查core文件,从中可以看到main函数已调用getpwnam,但当getpwnam调用free时,信号处理程序中断了它的运行,并调用getpwnam,进而再次调用free。在信号处理程序调用free而主程序也在调用free时,malloc和free维护的数据结构就出现了损坏,偶然,此程序会运行若干秒,然后因产生 SIGSEGV 信号而终止。在捕捉到信号后,若main函数仍正确运行,其返回值却有时错误,有时正确。
从此实例中可以看出,如果在信号处理程序中调用一个非可重入函数,则其结果是不可预知的。
SIGCLD和SIGCHLD这两个信号很容易被混淆。SIGCLD(没有H)是System V的一个信号名,其语义与名为SIGCHLD的BSD信号不同。POSIX.1采用BSD的SIGCHLD信号。
BSD的SIGCHLD信号语义与其他信号的语义相类似。子进程状态改变后产生此信号,父进程需要调用一个wait函数以检测发生了什么。
System V处理SIGCLD信号的方式不同于其他信号。如果用signal或sigset(早期设置信号配置的,与SRV3兼容的函数)设置信号配置,则基于SVR4的系统继承了这一具有问题色彩的传统(即兼容性限制)。对于SIGCLD的早期处理方式是:
(1)如果进程明确地将该信号的配置设置为SIG_IGN,则调用进程的子进程将不产生僵死进程。注意,这与其默认动作(SIG_DFL)“忽略”(见图10-1)不同。子进程在终止时,将其状态丢弃。如果调用进程随后调用一个wait函数,那么它将阻塞直到所有子进程都终止,然后该wait会返回−1,并将其errno设置为ECHILD。(此信号的默认配置是忽略,但这不会使上述语义起作用。必须将其配置明确指定为SIG_IGN才可以。)
POSIX.1 并未说明在 SIGCHLD 被忽略时应产生的后果,所以这种行为是允许的。Single UNIX Specification的XSI扩展选项要求对于SIGCHLD支持这种行为。
如果SIGCHLD被忽略,4.4BSD总是产生僵死进程。如果要避免僵死进程,则必须等待子进程。在SVR4中,如果调用signal或sigset将SIGCHLD的配置设置为忽略,则决不会产生僵死进程。本书讨论的4种平台在此方面都追随SVR4的行为。
使用sigaction可设置SA_NOCLDWAIT标志(见图10-6)以避免进程僵死。本书讨论的4种平台都支持这一点。
(2)如果将SIGCLD的配置设置为捕捉,则内核立即检查是否有子进程准备好被等待,如果是这样,则调用SIGCLD处理程序。
第2种方式改变了为此信号编写处理程序的方法,这一点可在下面的实例中看到。
实例
10.4节曾提到,进入信号处理程序后,首先要调用signal函数以重新设置此信号处理程序(在信号被重置为其默认值时,它可能会丢失,立即重新设置可以减少此窗口时间)。图10-6展示了这一点。但此程序不能在某些传统的 System V 平台上正常工作。程序一行行地不断重复输出“SIGCLD received”,最后进程用完其栈空间并异常终止。
图10-6 不能正常工作的System V SIGCLD处理程序
因为基于BSD的系统通常并不支持早期System V的SIGCLD语义,所以FreeBSD 8.0和Mac OS X 10.6.8 并没有出现此问题。Linux 3.2.0 也没有出现此问题,其原因是,虽然 SIGCLD 和SIGCHLD 定义为相同的值,但当一个进程安排捕捉 SIGCHLD,并且已经有进程准备好由其父进
程等待时,该系统并不调用SIGCHLD信号的处理程序。Solaris 10在此种情况时确实调用该信号处理程序,但在内核中增加了避免此问题的代码。
虽然本书说明的所有4种平台都解决了这一问题,但是应当意识到没有解决这一问题的平台(如AIX)依然存在。
此程序的问题是:在信号处理程序的开始处调用signal,按照上述第2种方式,内核检查是否有需要等待的子进程(因为我们正在处理一个SIGCLD信号,所以确实有这种子进程),所以它产生另一个对信号处理程序的调用。信号处理程序调用 signal,整个过程再次重复。
为了解决这一问题,应当在调用wait取到子进程的终止状态后再调用signal。此时仅当其他子进程终止,内核才会再次产生此种信号。
如果为SIGCHLD建立了一个信号处理程序,又存在一个已终止但父进程尚未等待它的进程,则是否会产生信号?POSIX.1 对此没有做说明。这就允许前面所述的工作方式。但是,POSIX.1在信号发生时并没有将信号处理重置为其默认值(假定正调用POSIX.1的sigaction函数设置其配置),于是在SIGCHLD处理程序中也就不必再为该信号指定一个信号处理程序。
务必了解你所用的系统实现中 SIGCHLD 信号的语义。也应了解在某些系统中#define SIGCHLD为SIGCLD或反之。更改这种信号的名字使你可以编译为另一个系统编写的程序,但是如果这一程序使用该信号的另一种语义,程序有可能不会正常工作。
在本书说明的4种平台上,只有Linux 3.2.0和Solaris 10定义了SIGCLD,SIGCLD等同于SIGCHLD。
我们需要先定义一些在讨论信号时会用到的术语。首先,当造成信号的事件发生时,为进程产生一个信号(或向一个进程发送一个信号)。事件可以是硬件异常(如除以 0)、软件条件(如alarm 定时器超时)、终端产生的信号或调用kill 函数。当一个信号产生时,内核通常在进程表中以某种形式设置一个标志。
当对信号采取了这种动作时,我们说向进程递送了一个信号。在信号产生(generation)和递送(delivery)之间的时间间隔内,称信号是未决的(pending)。
进程可以选用“阻塞信号递送”。如果为进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作或捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略。内核在递送一个原来被阻塞的信号给进程时(而不是在产生该信号时),才决定对它的处理方式。于是进程在信号递送给它之前仍可改变对该信号的动作。进程调用sigpending函数(见10.13节)来判定哪些信号是设置为阻塞并处于未决状态的。
如果在进程解除对某个信号的阻塞之前,这种信号发生了多次,那么将如何呢?POSIX.1允许系统递送该信号一次或多次。如果递送该信号多次,则称这些信号进行了排队。但是除非支持POSIX.1实时扩展,否则大多数UNIX并不对信号排队,而是只递送这种信号一次。
SUSv4 中,实时信号功能已经移至基础规范的实时扩展部分。随着时间的推移,更多的系统即使不支持实时扩展,也会支持信号排队。我们将在10.20节中进一步讨论排队信号。
SVR2 的手册页称,在进程执行 SIGCLD 信号处理程序期间,该信号是用排队方式处理的,虽然在概念层次这可能是真的,但实际并非如此。内核是按照 10.7 节中所述方式产生此信号。SVR3的手册页对此做了修改,它指明在进程执行SIGCLD信号处理程序期间,忽略SIGCLD信号。SVR4手册页删除了有关部分。
AT&T[1990e]中的SVR4 sigaction(2)手册页称SA_SIGINFO标志(见图10-16)使信号可靠地排队,这是不正确的。表面上内核部分地实现了此功能,但在 SVR4 中并不起作用。令人不可思议的是,SVID(System V接口定义)对这种可靠队列并未做同样的声明。
如果有多个信号要递送给一个进程,那将如何呢?POSIX.1并没有规定这些信号的递送顺序。但是POSIX.1基础部分建议:在其他信号之前递送与进程当前状态有关的信号,如SIGSEGV。
每个进程都有一个信号屏蔽字(signal mask),它规定了当前要阻塞递送到该进程的信号集。对于每种可能的信号,该屏蔽字中都有一位与之对应。对于某种信号,若其对应位已设置,则它当前是被阻塞的。进程可以调用sigprocmask(在10.12节中说明)来检测和更改其当前信号屏蔽字。
信号编号可能会超过一个整型所包含的二进制位数,因此 POSIX.1 定义了一个新数据类型sigset_t,它可以容纳一个信号集。例如,信号屏蔽字就存放在其中一个信号集中。10.11节将说明对信号集进行操作的5个函数。
kill函数将信号发送给进程或进程组。raise函数则允许进程向自身发送信号。
raise最初是由ISO C定义的。后来,为了与ISO C标准保持一致,POSIX.1也包括了该函数。但是POSIX.1扩展了raise的规范,使其可处理线程(12.8中讨论线程如何与信号交互)。
因为ISO C并不涉及多进程,所以它不能定义以进程ID作为其参数(如kill函数)的函数。
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
两个函数返回值:若成功,返回0;若出错,返回−1
调用
raise(signo);
等价于调用
kill(getpid(), signo);
kill的pid参数有以下4种不同的情况。
pid > 0 将该信号发送给进程ID为pid的进程。
pid == 0 将该信号发送给与发送进程属于同一进程组的所有进程(这些进程的进程组 ID等于发送进程的进程组 ID),而且发送进程具有权限向这些进程发送信号。这里用的术语“所有进程”不包括实现定义的系统进程集。对于大多数UNIX系统,系统进程集包括内核进程和init(pid为1)。
pid < 0 将该信号发送给其进程组ID等于pid绝对值,而且发送进程具有权限向其发送信号的所有进程。如前所述,所有进程并不包括系统进程集中的进程。
pid == −1 将该信号发送给发送进程有权限向它们发送信号的所有进程。如前所述,所有进程不包括系统进程集中的进程。
如前所述,进程将信号发送给其他进程需要权限。超级用户可将信号发送给任一进程。对于非超级用户,其基本规则是发送者的实际用户 ID 或有效用户 ID 必须等于接收者的实际用户 ID或有效用户ID。如果实现支持_POSIX_SAVED_IDS(如POSIX.1现在要求的那样),则检查接收者的保存设置用户ID(而不是有效用户ID)。在对权限进行测试时也有一个特例:如果被发送的信号是SIGCONT,则进程可将它发送给属于同一会话的任一其他进程。
POSIX.1将信号编号0定义为空信号。如果signo参数是0,则kill仍执行正常的错误检查,但不发送信号。这常被用来确定一个特定进程是否仍然存在。如果向一个并不存在的进程发送空信号,则kill返回−1,errno被设置为ESRCH。但是,应当注意,UNIX系统在经过一定时间后会重新使用进程ID,所以一个现有的具有所给定进程ID的进程并不一定就是你所想要的进程。
还应理解的是,测试进程是否存在的操作不是原子操作。在kill向调用者返回测试结果时,原来已存在的被测试进程此时可能已经终止,所以这种测试并无多大价值。
如果调用kill为调用进程产生信号,而且此信号是不被阻塞的,那么在kill返回之前, signo或者某个其他未决的、非阻塞信号被传送至该进程。(对于线程而言,还有一些附加条件;详细情况见12.8节。)
使用alarm函数可以设置一个定时器(闹钟时间),在将来的某个时刻该定时器会超时。当定时器超时时,产生 SIGALRM 信号。如果忽略或不捕捉此信号,则其默认动作是终止调用该alarm函数的进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
返回值:0或以前设置的闹钟时间的余留秒数
参数seconds的值是产生信号SIGALRM需要经过的时钟秒数。当这一时刻到达时,信号由内核产生,由于进程调度的延迟,所以进程得到控制从而能够处理该信号还需要一个时间间隔。
早期的UNIX系统实现曾提出警告,这种信号可能比预定值提前1 s发送。POSIX.1则不允许这样做。
每个进程只能有一个闹钟时间。如果在调用alarm时,之前已为该进程注册的闹钟时间还没有超时,则该闹钟时间的余留值作为本次alarm函数调用的值返回。以前注册的闹钟时间则被新值代替。
如果有以前注册的尚未超过的闹钟时间,而且本次调用的seconds值是0,则取消以前的闹钟时间,其余留值仍作为alarm函数的返回值。
虽然 SIGALRM 的默认动作是终止进程,但是大多数使用闹钟的进程捕捉此信号。如果此时进程要终止,则在终止之前它可以执行所需的清理操作。如果我们想捕捉 SIGALRM 信号,则必须在调用 alarm 之前安装该信号的处理程序。如果我们先调用 alarm,然后在我们能够安装SIGALRM处理程序之前已接到该信号,那么进程将终止。
pause函数使调用进程挂起直至捕捉到一个信号。
#include <unistd.h>
int pause(void);
返回值:−1,errno设置为EINTR
只有执行了一个信号处理程序并从其返回时,pause才返回。在这种情况下,pause返回−1, errno设置为EINTR。
实例
使用alarm和pause,进程可使自己休眠一段指定的时间。图10-7中的sleep1函数看似提供了这种功能(其实这里面存在问题,我们很快就会看到)。
图10-7 sleep简化而不完整的实现
程序中的sleep1函数看起来与将在10.19节中说明的sleep函数类似,但这种简单实现有以下3个问题。
(1)如果在调用sleep1之前,调用者已设置了闹钟,则它被sleep1函数中的第一次alarm调用擦除。可用下列方法更正这一点:检查第一次调用 alarm 的返回值,如其值小于本次调用alarm的参数值,则只应等到已有的闹钟超时。如果之前设置的闹钟超时时间晚于本次设置值,则在sleep1函数返回之前,重置此闹钟,使其在之前闹钟的设定时间再次发生超时。
(2)该程序中修改了对 SIGALRM 的配置。如果编写了一个函数供其他函数调用,则在该函数被调用时先要保存原配置,在该函数返回前再恢复原配置。更正这一点的方法是:保存signal函数的返回值,在返回前重置原配置。
(3)在第一次调用alarm和pause之间有一个竞争条件。在一个繁忙的系统中,可能alarm在调用pause之前超时,并调用了信号处理程序。如果发生了这种情况,则在调用pause后,如果没有捕捉到其他信号,调用者将永远被挂起。
sleep的早期实现与图10-7程序类似,但更正了第1个和第2个问题。有两种方法可以更正第3个问题。第一种方法是使用setjmp,下一个实例将说明这种方法。另一种方法是使用sigprocmask和sigsuspend,10.19节将说明这种方法。
实例
SVR2中的sleep实现使用了setjmp和longjmp(见7.10节),以避免前一个实例的第3个问题中说明的竞争条件。此函数的一个简化版本称为sleep2,示于图10-8中(为了缩短实例程序的长度,程序中没有处理上面所说的第1个和第2个问题)。
图10-8 sleep的另一个不完善的实现
在此函数中,已避免了图10-7中具有的竞争条件。即使pause 从未执行,在发生SIGALRM时,sleep2函数也返回。
但是,sleep2函数中却有另一个难以察觉的问题,它涉及与其他信号的交互。如果SIGALRM中断了某个其他信号处理程序,则调用longjmp会提早终止该信号处理程序。图10-9显示了这种情况。SIGINT 处理程序中包含了for 循环语句,它在作者所用系统上的执行时间超过5s,也就是大于sleep2的参数值,这正是我们想要的。整型变量k说明为volatile,这样就阻止了优化编译程序去除循环语句。
图10-9 在一个捕捉其他信号的程序中调用sleep2
执行图10-9中的程序,可以通过键入中断字符来中断休眠,运行结果如下:
$ ./a.out
^C 键入中断字符
sig_int starting
sleep2 returned: 0
从中可见sleep2函数所引起的longjmp使另一个信号处理程序sig_int提早终止,即使它未完成也会如此。如果将SVR2的sleep函数与其他信号处理程序一起使用,就可能碰到这种情况。见习题10.3。
sleep1 和sleep2 函数的这两个实例是告诉我们在涉及信号时需要有精细而周到的考虑。下面几节将说明解决这些问题的方法,使我们能够可靠地、在不影响其他代码段的情况下处理信号。
实例
除了用来实现sleep函数外,alarm还常用于对可能阻塞的操作设置时间上限值。例如,程序中有一个读低速设备的可能阻塞的操作(见 10.5 节),我们希望超过一定时间量后就停止执行该操作。图10-10实现了这一点,它从标准输入读一行,然后将其写到标准输出上。
图10-10 带时间限制调用read
这种代码序列在很多UNIX应用程序中都能见到,但是这种程序有两个问题:
(1)图10-10中的程序具有与图10-7 中的程序相同的问题:在第一次alarm 调用和read调用之间有一个竞争条件。如果内核在这两个函数调用之间使进程阻塞,不能占用处理机运行,而其时间长度又超过闹钟时间,则read可能永远阻塞。大多数这种类型的操作使用较长的闹钟时间,例如1分钟或更长一点,使这种问题不会发生,但无论如何这是一个竞争条件。
(2)如果系统调用是自动重启动的,则当从SIGALRM信号处理程序返回时,read并不被中断。在这种情形下,设置时间限制不起作用。
在这里我们确实需要中断慢速系统调用。我们将在10.14节对此进行详细讨论。
实例
让我们用 longjmp 再实现前面的实例。使用这种方法无需担心一个慢速的系统调用是否被中断,见图10-11。
图10-11 使用longjmp,带时间限制调用read
不管系统是否重新启动被中断的系统调用,该程序都会如所预期的那样工作。但是要知道,该程序仍旧有和图10-8中的程序相同的与其他信号处理程序交互的问题。
如果要对I/O操作设置时间限制,则如上所示可以使用longjmp,当然也要清楚它可能有与其他信号处理程序交互的问题。另一种选择是使用select或poll函数,14.4.1节和14.4.2节将对它们进行说明。
我们需要有一个能表示多个信号——信号集(signal set)的数据类型。我们将在sigprocmask (下一节中说明)类函数中使用这种数据类型,以便告诉内核不允许发生该信号集中的信号。如前所述,不同的信号的编号可能超过一个整型量所包含的位数,所以一般而言,不能用整型量中的一位代表一种信号,也就是不能用一个整型量表示信号集。POSIX.1定义数据类型sigset_t以包含一个信号集,并且定义了下列5个处理信号集的函数。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
4个函数返回值:若成功,返回0;若出错,返回−1
int sigismember(const sigset_t *set, int signo);
返回值:若真,返回1;若假,返回0
函数sigemptyset初始化由set指向的信号集,清除其中所有信号。函数sigfillset初始化由set指向的信号集,使其包括所有信号。所有应用程序在使用信号集前,要对该信号集调用sigemptyset或sigfillset一次。这是因为C编译程序将不赋初值的外部变量和静态变量都初始化为0,而这是否与给定系统上信号集的实现相对应却并不清楚。
一旦已经初始化了一个信号集,以后就可在该信号集中增、删特定的信号。函数 sigaddset将一个信号添加到已有的信号集中,sigdelset 则从信号集中删除一个信号。对所有以信号集作为参数的函数,总是以信号集地址作为向其传送的参数。
实现
如果实现的信号数目少于一个整型量所包含的位数,则可用一位代表一个信号的方法实现信号集。例如,本书的后续部分都假定一种实现有31种信号和32位整型。sigemptyset函数将整型设置为0, sigfillset函数则将整型中的各位都设置为1。这两个函数可以在<signal.h>头文件中实现为宏:
#define sigemptyset(ptr) (*(ptr) = 0)
#define sigfillset(ptr) (*(ptr) = ~(sigset_t)0, 0)
注意,除了设置信号集中各位为1外,sigfillset必须返回0,所以使用C语言的逗号算符,它将逗号算符后的值作为表达式的值返回。
使用这种实现,sigaddset 开启一位(将该位设置为 1),sigdelset 则关闭一位(将该位设置为0);sigismember测试一个指定的位。因为没有信号编号为0,所以从信号编号中减1以得到要处理位的位编号数。图10-12给出了这些函数的实现。
图10-12 sigaddset、sigdelset和sigismember的实现
也可将这3个函数在<signal.h>中实现为各一行的宏,但是POSIX.1要求检查信号编号参数的有效性,如果无效则设置errno。在宏中实现这一点比函数要难。
10.8节曾提及一个进程的信号屏蔽字规定了当前阻塞而不能递送给该进程的信号集。调用函数sigprocmask可以检测或更改,或同时进行检测和更改进程的信号屏蔽字。
#include <signal.h>
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
返回值:若成功,返回0;若出错,返回−1
首先,若oset是非空指针,那么进程的当前信号屏蔽字通过oset返回。
其次,若set是一个非空指针,则参数how指示如何修改当前信号屏蔽字。图10-13说明了how可选的值。SIG_BLOCK是或操作,而SIG_SETMASK则是赋值操作。注意,不能阻塞SIGKILL和SIGSTOP信号。
图10-13 用sigprocmask更改当前信号屏蔽字的方法
如果set是个空指针,则不改变该进程的信号屏蔽字,how的值也无意义。
在调用sigprocmask后如果有任何未决的、不再阻塞的信号,则在sigprocmask返回前,至少将其中之一递送给该进程。
sigprocmask 是仅为单线程进程定义的。处理多线程进程中信号的屏蔽使用另一个函数。我们将在12.8节中对此进行讨论。
实例
图10-14程序是一个函数,它打印调用进程信号屏蔽字中的信号名。图10-20中的程序和图10-22中的程序将调用此函数。
图10-14 为进程打印信号屏蔽字
为了节省空间,没有对图10-1中列出的每一种信号测试该屏蔽字(见习题10.9)。
sigpending函数返回一信号集,对于调用进程而言,其中的各信号是阻塞不能递送的,因而也一定是当前未决的。该信号集通过set参数返回。
#include <signal.h>
int sigpending(sigset_t *set);
返回值:若成功,返回0;若出错,返回−1
实例
图10-15展示了很多前面说明过的信号功能。
图10-15 信号设置和sigprocmask实例
进程阻塞SIGQUIT信号,保存了当前信号屏蔽字(以便以后恢复),然后休眠5秒。在此期间所产生的退出信号SIGQUIT都被阻塞,不递送至该进程,直到该信号不再被阻塞。在5秒休眠结束后,检查该信号是否是未决的,然后将SIGQUIT设置为不再阻塞。
注意,在设置 SIGQUIT 为阻塞时,我们保存了老的屏蔽字。为了解除对该信号的阻塞,用老的屏蔽字重新设置了进程信号屏蔽字(SIG_SETMASK)。另一种方法是用SIG_UNBLOCK使阻塞的信号不再阻塞。但是,应当了解如果编写一个可能由其他人使用的函数,而且需要在函数中阻塞一个信号,则不能用SIG_UNBLOCK简单地解除对此信号的阻塞,这是因为此函数的调用者在调用本函数之前可能也阻塞了此信号。在这种情况下必须使用SIG_SETMASK将信号屏蔽字恢复为先前的值,这样也就能继续阻塞该信号。10.18节的system函数部分有这样的一个例子。
在休眠期间如果产生了退出信号,那么此时该信号是未决的,但是不再受阻塞,所以在sigprocmask 返回之前,它被递送到调用进程。从程序的输出中可以看到这一点:SIGQUIT 处理程序(sig_quit)中的printf语句先执行,然后再执行sigprocmask之后的printf语句。
然后该进程再休眠5秒。如果在此期间再产生退出信号,那么因为在上次捕捉到该信号时,已将其处理方式设置为默认动作,所以这一次它就会使该进程终止。在下列输出中,当我们在终端键入退出字符Ctrl+\时,终端打印^\(终端退出字符):
$ ./a.out
^\ 产生信号一次(在5s之内)
SIGQUIT pending 从sleep返回后
caught SIGQUIT 在信号处理程序中
SIGQUIT unblocked 从sigprocmask返回后
^\Quit(coredump) 再次产生信号
$ ./a.out
^\^\^\^\^\^\^\^\^\^\ 产生信号10次(在5 s之内)
SIGQUIT pending
caught SIGQUIT 只产生信号一次
SIGQUIT unblocked
^\Quit(coredump) 再产生信号
shell发现其子进程异常终止时输出QUIT(coredump)信息。注意,第二次运行该程序时,在进程休眠期间使SIGQUIT信号产生了10次,但是解除了对该信号的阻塞后,只向进程传送一次SIGQUIT。从中可以看出在此系统上没有将信号进行排队。
sigaction函数的功能是检查或修改(或检查并修改)与指定信号相关联的处理动作。此函数取代了UNIX早期版本使用的signal函数。在本节末尾用sigaction函数实现了signal。
#include <signal.h>
int sigaction(int signo, const struct sigaction *restrict act,
struct sigaction *restrict oact);
返回值:若成功,返回0;若出错,返回−1
其中,参数signo是要检测或修改其具体动作的信号编号。若act指针非空,则要修改其动作。如果oact指针非空,则系统经由oact指针返回该信号的上一个动作。此函数使用下列结构:
struct sigaction {
void (*sa_handler)(int); /* addr of signal handler, */
/* or SIG_IGN, or SIG_DFL */
sigset_t sa_mask; /* additional signals to block */
int sa_flags; /* signal options, Figure 10.16 */
/* alternate handler */
void (*sa_sigaction)(int, siginfo_t *, void *);
};
当更改信号动作时,如果 sa_handler 字段包含一个信号捕捉函数的地址(不是常量SIG_IGN或SIG_DFL),则sa_mask字段说明了一个信号集,在调用该信号捕捉函数之前,这一信号集要加到进程的信号屏蔽字中。仅当从信号捕捉函数返回时再将进程的信号屏蔽字恢复为原先值。这样,在调用信号处理程序时就能阻塞某些信号。在信号处理程序被调用时,操作系统建立的新信号屏蔽字包括正被递送的信号。因此保证了在处理一个给定的信号时,如果这种信号再次发生,那么它会被阻塞到对前一个信号的处理结束为止。回忆10.8节,若同一种信号多次发生,通常并不将它们加入队列,所以如果在某种信号被阻塞时,它发生了5次,那么对这种信号解除阻塞后,其信号处理函数通常只会被调用一次(上一个例子已经说明了这种特性)。
一旦对给定的信号设置了一个动作,那么在调用sigaction显式地改变它之前,该设置就一直有效。这种处理方式与早期的不可靠信号机制不同,符合POSIX.1在这方面的要求。
act结构的sa_flags字段指定对信号进行处理的各个选项。图10-16详细列出了这些选项的意义。若该标志已定义在基本 POSIX.1 标准中,那么 SUS 列包含“•”;若该标志定义在基本POSIX.1标准的XSI扩展中,那么该列包含“XSI”。
图10-16 处理每个信号的可选标志(sa_flags)
sa_sigaction字段是一个替代的信号处理程序,在sigaction结构中使用了SA_SIGINFO标志时,使用该信号处理程序。对于sa_sigaction字段和sa_handler字段两者,实现可能使用同一存储区,所以应用只能一次使用这两个字段中的一个。
通常,按下列方式调用信号处理程序:
void handler(int signo);
但是,如果设置了SA_SIGINFO标志,那么按下列方式调用信号处理程序:
void handler(int signo, siginfo_t *info, void *context);
siginfo结构包含了信号产生原因的有关信息。该结构的大致样式如下所示。符合POSIX.1的所有实现必须至少包括si_signo和si_code成员。另外,符合XSI的实现至少应包含下列字段:
struct siginfo {
int si_signo; /* signal number */
int si_errno; /* if nonzero, errno value from <errno.h> */
int si_code; /* additional info (depends on signal) */
pid_t si_pid; /* sending process ID */
uid_t si_uid; /* sending process real user ID */
void *si_addr; /* address that caused the fault */
int si_status; /* exit value or signal number */
union sigval si_value; /* application-specific value */
/* possibly other fields also */
};
sigval联合包含下列字段:
int sival_int;
void *sival_ptr;
应用程序在递送信号时,在si_value.sival_int中传递一个整型数或者在si_value.sival_ptr中传递一个指针值。
图10-17示出了对于各种信号的si_code值,这些信号是由Single UNIX Specification定义的。注意,实现可定义附加的代码值。
若信号是SIGCHLD,则将设置si_pid、si_status和si_uid字段。若信号是SIGBUS、SIGILL、SIGFPE或SIGSEGV,则si_addr包含造成故障的根源地址,该地址可能并不准确。si_errno字段包含错误编号,它对应于造成信号产生的条件,并由实现定义。
信号处理程序的context参数是无类型指针,它可被强制类型转换为ucontext_t结构类型,该结构标识信号传递时进程的上下文。该结构至少包含下列字段:
ucontext_t *uc_link; /* pointer to context resumed when */
sigset_t uc_sigmask; /* signals blocked when this context */
stack_t uc_stack; /* stack used by this context */
/* this context returns */
/* is active */
mcontext_t uc_mcontext; /* machine-specific representation of */
/* saved context */
uc_stack字段描述了当前上下文使用的栈,至少包括下列成员:
void *ss_sp; /* stack base or pointer */
size_t ss_size; /* stack size */
int ss_flags; /* flags */
当实现支持实时信号扩展时,用SA_SIGINFO标志建立的信号处理程序将造成信号可靠地排队。一些保留信号可由实时应用使用。如果信号由sigqueue函数产生,那么siginfo结构能包含应用特有的数据(参见10.20节)。
实例:signal函数
现在用sigaction实现signal函数。很多平台都是这样做的(POSIX.1的基础阐述部分也说明这是POSIX所希望的)。另一方面,有些系统支持老的不可靠信号语义signal函数,其目的是实现二进制向后兼容。除非特殊地要求老的不可靠语义(为了向后兼容),否则应当使用下面的 signal 实现,或者直接调用 sigaction(可以在调用 sigaction 时指定SA_RESETHAND和SA_NODEFER选项以实现老语义的signal函数)。本书中所有调用signal的实例均调用图10-18中实现的函数。
图10-17 siginfo_t代码值
图10-18 用sigaction实现的signal函数
注意,必须用sigemptyset函数初始化act结构的sa_mask成员。不能保证act.sa_mask=0会做同样的事情。
对除SIGALRM以外的所有信号,我们都有意尝试设置SA_RESTART标志,于是被这些信号中断的系统调用都能自动重启动。不希望重启动由 SIGALRM 信号中断的系统调用的原因是:我们希望对I/O操作可以设置时间限制(请回忆有关图10-10的讨论)。
某些早期系统(如SunOS)定义了SA_INTERRUPT标志。这些系统的默认方式是重新启动被中断的系统调用,而指定此标志则使系统调用被中断后不再重启动。Linux定义SA_INTERRUPT标志,以便与使用该标志的应用程序兼容。但是,如若信号处理程序是用sigaction设置的,那么其默认方式是不重新启动系统调用。Single UNIX Specification的XSI扩展规定,除非说明了SA_RESTART标志,否则sigaction函数不再重启动被中断的系统调用。
实例:signal_intr函数
图10-19给出的是signal函数的另一种版本,它力图阻止被中断的系统调用重启动。
图10-19 signal_intr函数
如果系统定义了SA_INTERRUPT标志,那么为了提高可移植性,我们在sa_flags中增加该标志,这样也就阻止了被中断的系统调用的重启动。
7.10 节说明了用于非局部转移的 setjmp 和 longjmp 函数。在信号处理程序中经常调用longjmp函数以返回到程序的主循环中,而不是从该处理程序返回。图10-8和图10-11中已经出现了这种情况。
但是,调用longjmp有一个问题。当捕捉到一个信号时,进入信号捕捉函数,此时当前信号被自动地加到进程的信号屏蔽字中。这阻止了后来产生的这种信号中断该信号处理程序。如果用longjmp跳出信号处理程序,那么,对此进程的信号屏蔽字会发生什么呢?
在FreeBSD 8.0和Mac OS X 10.6.8中,setjmp和longjmp保存和恢复信号屏蔽字。但是, Linux 3.2.0和Solaris 10并不执行这种操作,虽然Linux支持提供BSD行为的选项。FreeBSD 8.0和Mac OS X 10.6.8提供函数_setjmp和_longjmp,它们也不保存和恢复信号屏蔽字。
为了允许两种形式并存,POSIX.1并没有指定setjmp和longjmp对信号屏蔽字的作用,而是定义了两个新函数sigsetjmp和siglongjmp。在信号处理程序中进行非局部转移时应当使用这两个函数。
#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savemask);
返回值:若直接调用,返回0;若从siglongjmp调用返回,则返回非0
void siglongjmp(sigjmp_buf env, int val);
这两个函数和 setjmp、longjmp 之间的唯一区别是 sigsetjmp 增加了一个参数。如果savemask非0,则sigsetjmp在env中保存进程的当前信号屏蔽字。调用siglongjmp时,如果带非0 savemask的sigsetjmp调用已经保存了env,则siglongjmp从其中恢复保存的信号屏蔽字。
实例
图10-20中的程序演示了在信号处理程序被调用时,系统所设置的信号屏蔽字如何自动地包括刚被捕捉到的信号。此程序也示例说明了如何使用sigsetjmp和siglongjmp函数。
图10-20 信号屏蔽、sigsetjmp和siglongjmp实例
此程序演示了另一种技术,只要在信号处理程序中调用 siglongjmp 就应使用这种技术。仅在调用sigsetjmp之后才将变量canjump设置为非0值。在信号处理程序中检测此变量,仅当它为非0值时才调用siglongjmp。这提供了一种保护机制,使得在jmpbuf(跳转缓冲)尚未由sigsetjmp 初始化时,防止调用信号处理程序。(在本程序中,siglongjmp 之后程序很快就结束,但是在较大的程序中,在 siglongjmp 之后的较长一段时间内,信号处理程序可能仍旧被设置)。在一般的C代码中(不是信号处理程序),对于longjmp并不需要这种保护措施。但是,因为信号可能在任何时候发生,所以在信号处理程序中,需要这种保护措施。
在程序中使用了数据类型sig_atomic_t,这是由ISO C标准定义的变量类型,在写这种类型变量时不会被中断。这意味着在具有虚拟存储器的系统上,这种变量不会跨越页边界,可以用一条机器指令对其进行访问。这种类型的变量总是包括ISO类型修饰符volatile,其原因是:该变量将由两个不同的控制线程——main 函数和异步执行的信号处理程序访问。图10-21显示了此程序的执行时间顺序。可将图10-21分成三部分:左面部分(对应于main),中间部分(sig_usr1)和右面部分(sig_alrm)。在进程执行左面部分时,信号屏蔽字是 0(没有信号是阻塞的)。而执行中间部分时,其信号屏蔽字是SIGUSR1。执行右面部分时,信号屏蔽字是SIGUSR1|SIGALRM。
图10-21 处理两个信号的实例程序的时间顺序
执行图10-20程序,得到下面的输出:
$ ./a.out & 在后台启动进程
starting main:
[1] 531 作业控制shell打印其进程ID
$ kill -USR1 531 向该进程发送SIGUSR1
starting sig_usr1: SIGUSR1
$ in sig_alrm: SIGUSR1 SIGALRM
finishing sig_usr1: SIGUSR1
ending main:
键入回车
[1] + Done ./a.out &
该输出与我们所期望的相同:当调用一个信号处理程序时,被捕捉到的信号加到进程的当前信号屏蔽字中。当从信号处理程序返回时,恢复原来的屏蔽字。另外,siglongjmp 恢复了由sigsetjmp所保存的信号屏蔽字。
如果在Linux中将图10-20程序中的sigsetjmp和siglongjmp分别替换成setjmp和longjmp(在FreeBSD中,则替换成_setjmp和_longjmp),则最后一行输出变成:
ending main: SIGUSR1
这意味着在调用 setjmp之后执行 main 函数时,其 SIGUSR1 是阻塞的。这多半不是我们所希望的。
上面已经说明,更改进程的信号屏蔽字可以阻塞所选择的信号,或解除对它们的阻塞。使用这种技术可以保护不希望由信号中断的代码临界区。如果希望对一个信号解除阻塞,然后pause以等待以前被阻塞的信号发生,则又将如何呢?假定信号是SIGINT,实现这一点的一种不正确的方法是:
sigset_t newmask, oldmask;
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
/* block SIGINT and save current signal mask */
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0)
err_sys("SIG_BLOCK error");
/* critical region of code */
/* restore signal mask, which unblocks SIGINT */
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");
/* window is open */
pause(); /* wait for signal to occur */
/* continue processing */
如果在信号阻塞时,产生了信号,那么该信号的传递就被推迟直到对它解除了阻塞。对应用程序而言,该信号好像发生在解除对SIGINT的阻塞和pause之间(取决于内核如何实现信号)。如果发生了这种情况,或者如果在解除阻塞时刻和 pause 之间确实发生了信号,那么就会产生问题。因为可能不会再见到该信号,所以从这种意义上讲,在此时间窗口中发生的信号丢失了,这样就使得pause永远阻塞。这是早期的不可靠信号机制的另一个问题。
为了纠正此问题,需要在一个原子操作中先恢复信号屏蔽字,然后使进程休眠。这种功能是由sigsuspend函数所提供的。
#include <signal.h>
int sigsuspend(const sigset_t *sigmask);
返回值:−1,并将errno设置为EINTR
进程的信号屏蔽字设置为由sigmask指向的值。在捕捉到一个信号或发生了一个会终止该进程的信号之前,该进程被挂起。如果捕捉到一个信号而且从该信号处理程序返回,则sigsuspend返回,并且该进程的信号屏蔽字设置为调用sigsuspend之前的值。
注意,此函数没有成功返回值。如果它返回到调用者,则总是返回−1,并将 errno 设置为EINTR(表示一个被中断的系统调用)。
实例
图10-22显示了保护代码临界区,使其不被特定信号中断的正确方法。
图10-22 保护临界区不被信号中断
注意,当sigsuspend返回时,它将信号屏蔽字设置为调用它之前的值。在本例中,SIGINT信号将被阻塞。因此将信号屏蔽恢复为之前保存的值(oldmask)。
运行图10-22中的程序得到下面的输出:
$ ./a.out
program start:
in critical region: SIGINT
^C 键入中断字符
in sig_int: SIGINT SIGUSR1
after return from sigsuspend: SIGINT
program exit:
在调用sigsuspend时,将SIGUSRI信号加到了进程信号屏蔽字中,所以当运行该信号处理程序时,我们得知信号屏蔽字已经改变了。从中可见,在 sigsuspend 返回时,它将信号屏蔽字恢复为调用它之前的值。
实例
sigsuspend的另一种应用是等待一个信号处理程序设置一个全局变量。图10-23中的程序用于捕捉中断信号和退出信号,但是希望仅当捕捉到退出信号时,才唤醒主例程。
图10-23 用sigsuspend等待一个全局变量被设置
此程序的样本输出是:
$ ./a.out
^C 键入中断字符
interrupt
^C 再次键入中断字符
interrupt
^C 再一次
interrupt
^\ $ 用退出符终止
考虑到支持ISO C的非POSIX系统与POSIX系统两者之间的可移植性,在一个信号处理程序中唯一应当做的是为sig_atomic_t类型的变量赋一个值。POSIX.1规定得更多一些,它详细说明了在一个信号处理程序中可以安全地调用的函数列表(见图10-4),但是如果这样来编写代码,则它们可能不会正确地在非POSIX系统上运行。
实例
可以用信号实现父、子进程之间的同步,这是信号应用的另一个实例。图 10-24 给出了 8.9节中提到的5个例程的实现,它们是TELLWAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT和WAIT_CHILD。
图10-24 父子进程可用来实现同步的例程
其中使用了两个用户定义的信号:SIGUSR1由父进程发送给子进程,SIGUSR2由子进程发送给父进程。图15-7显示了使用管道的这5个函数的另一种实现。
如果在等待信号发生时希望去休眠,则使用 sigsuspend 函数是非常适当的(正如在前面两个例子中所示),但是如果在等待信号期间希望调用其他系统函数,那么将会怎样呢?遗憾的是,在单线程环境下对此问题没有妥善的解决方法。如果可以使用多线程,则可专门安排一个线程处理信号(见12.8节中的讨论)。
如果不使用线程,那么我们能尽力做到最好的是,当信号发生时,在信号捕捉程序中对一个全局变量置1。例如,若我们捕捉SIGINT和SIGALRM这两种信号,并用signal_intr函数设置这两个信号的处理程序,使得它们中断任一被阻塞的慢速系统调用。当进程阻塞在调用read函数等待慢速设备输入时,很可能发生这两种信号(如果设置闹钟以阻止永远等待输入,那么对于SIGALRM信号,这种情况尤其会发生)。处理这种问题的代码类似于下面所示:
if (intr_flag) /* flag set by our SIGINT handler */
handle_intr();
if (alrm_flag) /* flag set by our SIGALRM handler */
handle_alrm();
/* signals occurring in here are lost */
while (read( ... ) < 0) {
if (errno == EINTR) {
if (alrm_flag)
handle_alrm();
else if (intr_flag)
handle_intr();
} else {
/* some other error */
}
} else if (n == 0) {
/* end of file */
} else {
/* process input */
}
在调用read之前测试各全局标志,如果read返回一个中断的系统调用错误,则再次进行测试。如果在前两个if语句和后随的read 调用之间捕捉到两个信号中的任意一个,则问题就发生了。正如代码中的注释所指出的,在此处发生的信号丢失了。调用信号处理程序,它们设置了相应的全局变量,但是read决不会返回(除非某些数据已准备好可读)。
我们希望实现下列操作步骤。
(1)阻塞SIGINT和SIGALRM。
(2)测试两个全局变量以判别是否发生了一个信号,如果已发生则对此进行处理。
(3)调用 read(或任何其他系统函数)并解除对这两个信号的阻塞,这两个操作应当是一个原子操作。
仅当第(3)步是pause操作时,sigsuspend函数才能帮助我们。