前面已提及abort函数的功能是使程序异常终止。
#include <stdlib.h>
void abort(void);
此函数不返回值
此函数将SIGABRT信号发送给调用进程(进程不应忽略此信号)。ISO C规定,调用abort将向主机环境递送一个未成功终止的通知,其方法是调用raise(SIGABRT)函数。
ISO C要求若捕捉到此信号而且相应信号处理程序返回,abort仍不会返回到其调用者。如果捕捉到此信号,则信号处理程序不能返回的唯一方法是它调用exit、_exit、_Exit、longjmp或siglongjmp(10.15节讨论了longjmp和siglongjmp之间的区别)。POSIX.1也说明abort并不理会进程对此信号的阻塞和忽略。
让进程捕捉 SIGABRT 的意图是:在进程终止之前由其执行所需的清理操作。如果进程并不在信号处理程序中终止自己,POSIX.1声明当信号处理程序返回时,abort终止该进程。
ISO C针对此函数的规范将下列问题留由实现决定:是否要冲洗输出流以及是否要删除临时文件(见5.13节)。 POSIX.1的要求则更进一步,它要求如果abort调用终止进程,则它对所有打开标准I/O流的效果应当与进程终止前对每个流调用fclose相同。
System V的早期版本中,abort函数产生SIGIOT信号。更进一步,进程忽略此信号或者捕捉它并从信号处理程序返回,这都是可能的,在返回情况下,abort返回到它的调用者。
4.3BSD产生SIGILL信号。在此之前,该函数解除对此信号的阻塞,将其配置恢复为SIG_DFL (终止并创建core文件)。这阻止一个进程忽略或捕捉此信号。
历史上,abort的各种实现在如何处理标准I/O流方面是并不相同的。对于保护性的程序设计以及为提高可移植性,如果希望冲洗标准 I/O 流,则在调用 abort 之前要执行这种操作。在err_dump函数中实现了这一点(见附录B)。
因为大多数UNIX系统tmpfile(临时文件)的实现在创建该文件之后立即调用unlink,所以ISO C关于临时文件的警告通常与我们无关。
实例
图10-25中的abort函数是按POSIX.1说明实现的。
图10-25 abort的POSIX.1实现
首先查看是否将执行默认动作,若是则冲洗所有标准I/O流。这并不等价于对所有打开的流调用fclose(因为只冲洗,并不关闭它们),但是当进程终止时,系统会关闭所有打开的文件。如果进程捕捉此信号并返回,那么因为进程可能产生了更多的输出,所以再一次冲洗所有的流。不进行冲洗处理的唯一条件是如果进程捕捉此信号,然后调用_exit或_Exit。在这种情况下,任何未冲洗的内存中的标准I/O缓存都被丢弃。我们假定捕捉此信号,而且_exit或_Exit的调用者并不想要冲洗缓冲区。
回忆10.9节,如果调用kill使其为调用者产生信号,并且如果该信号是不被阻塞的(图10-25中的程序保证做到这一点),则在kill返回前该信号(或某个未决、未阻塞的信号)就被传送给了该进程。我们阻塞除SIGABRT外的所有信号,这样就可知如果对kill的调用返回了,则该进程一定已捕捉到该信号,并且也从该信号处理程序返回。
8.13节已经有了一个system函数的实现,但是该版本并不执行任何信号处理。POSIX.1要求system忽略SIGINT和SIGQUIT,阻塞SIGCHLD。在给出一个正确地处理这些信号的一个版本之前,先说明为什么要考虑信号处理。
实例
图10-26中的程序使用8.13节中的system版本,用其调用ed(1)编辑器。(ed编辑器很久以来就是UNIX的组成部分。在这里使用它的原因是:它是捕捉中断和退出信号的交互式程序。若从shell调用ed,并键入中断字符,则它捕捉中断信号并打印问号。ed程序对退出信号的处理方式设置为忽略。)
图10-26中的程序用于捕捉SIGINT和SIGCHLD信号。若调用它则可得:
$ ./a.out
a 将正文追加至编辑器缓冲区
Here is one line of text
. 行首的点停止追加方式
1,$p 打印缓冲区中的第一行至最后一行,以便观察其内容
Here is one line of text
w temp.foo 将缓冲区写至一文件
25 编辑器称写了25个字节
q 离开编辑器
caught SIGCHLD
当编辑器终止时,系统向父进程(a.out进程)发送SIGCHLD信号。父进程捕捉它,执行其处理程序sig_chid,然后从信号处理程序返回。但是若父进程正捕捉 SIGCHLD 信号(因为它创建了子进程,所以应当这样做以便了解它的子进程在何时终止),那么正在执行system函数时,应当阻塞对父进程递送SIGCHLD信号。实际上,这就是POSIX.1所说明的。否则,当system创建的子进程结束时,system 的调用者可能错误地认为,它自己的一个子进程结束了。于是,调用者将会调用一种wait函数以获得子进程的终止状态,这样就阻止了system函数获得子进程的终止状态,并将其作为它的返回值。
图10-26 用syetem调用ed编辑器
如果再次执行该程序,在这次运行时将一个中断信号传送给编辑器,则可得:
$ ./a.out
a 将正文追加至编辑器缓冲区
hello, world
. 行首的点停止追加方式
1,$p 打印缓冲区中的第一行至最后一行,以便观察其内容
hello, world
w temp.foo 将缓冲区写至一文件
13 编辑器称写了13个字节
^C 键入中断符
? 编辑器捕捉信号,打印问号
caught SIGINT 父进程执行同一操作
q 离开编辑器
caught SIGCHLD
回忆9.6节可知,键入中断字符可使中断信号传送给前台进程组中的所有进程。图10-27展示了编辑器正在运行时的各个进程的关系。
图10-27 图10-26程序运行时的前台和后台进程组
在这一实例中,SIGINT被送给3个前台进程(shell进程忽略此信号)。从输出中可见,a.out进程和ed进程捕捉该信号。但是,当用system运行另一个程序时,不应使父、子进程两者都捕捉终端产生的两个信号:中断和退出。这两个信号只应发送给正在运行的程序:子进程。因为由system执行的命令可能是交互式命令(如本例中的ed),以及因为system的调用者在程序执行时放弃了控制,等待该执行程序的结束,所以system的调用者就不应接收这两个终端产生的信号。这就是为什么POSIX.1规定system的调用者在等待命令完成时应当忽略这两个信号的原因。
实例
图10-28中的程序是system函数的另一个实现,它进行了所要求的信号处理。
图10-28 system函数的POSIX.1正确实现
如果将图10-26中的程序与system函数的这一实现相链接,那么所产生的二进制代码与上一个有缺陷的程序相比较,存在如下差别。
(1)当我们键入中断字符或退出字符时,不向调用进程发送信号。
(2)当 ed 命令终止时,不向调用进程发送 SIGCHLD 信号。作为替代,在程序末尾的sigprocmask 调用对 SIGCHLD 信号解除阻塞之前,SIGCHLD 信号一直被阻塞。而对sigprocmask函数的这一次调用是在system函数调用waitpid获取子进程的终止状态之后。
POSIX.1说明,在SIGCHLD未决期间,如若wait或waitpid返回了子进程的状态,那么SIGCHLD信号不应递送给该父进程,除非另一个子进程的状态也可用。FreeBSD 8.0、Mac OS X 10.6.8和Solaris 10都实现了这种语义,而Linux 3.2.0没有实现这种语义,在system函数调用了waitpid 后,SIGCHLD 保持为未决;当解除了对此信号的阻塞后,它被递送至调用者。如果我们在图10-26的sig_chld函数中调用wait,Linux系统将返回−1,并将errno设置为ECHILD,因为system函数已取到子进程的终止状态。
很多较早的书中使用下列程序段,它忽略中断和退出信号:
if ( (pid = fork()) < 0){
err_sys("fork error");
}else if (pid == 0) {
/* child */
execl(...);
_exit(127);
}
/* parent */
old_intr = signal(SIGINT, SIG_IGN);
old_quit = signal(SIGQUIT, SIG_IGN);
waitpid(pid, &status, 0)
signal(SIGINT, old_intr);
signal(SIGQUIT, old_quit);
这段代码的问题是:在fork之后不能保证父进程还是子进程先运行。如果子进程先运行,父进程在一段时间后再运行,那么在父进程将中断信号的处理更改为忽略之前,就可能产生这种信号。由于这种原因,图10-28中在fork之前就改变对该信号的配置。
注意,子进程在调用execl之前要先恢复这两个信号的处理。如同8.10节中所说明的一样,这就允许在调用者配置的基础上,execl可将它们的配置更改为默认值。
system的返回值
注意system的返回值,它是shell的终止状态,但shell的终止状态并不总是执行命令字符串进程的终止状态。图8-23中有一些例子,其结果正是我们所期望的。如果执行一条如date那样的简单命令,其终止状态是0。执行shell命令exit 44,则得终止状态44。在信号方面又如何呢?
运行图8-24程序,并向正在执行的命令发送一些信号:
$ tsys "sleep 30"
^Cnormal termination, exit status = 130 键入中断符
$ tsys "sleep 30"
^\sh: 946 Quit 键入退出符
normal termination, exit status = 131
当用中断信号终止sleep时,pr_exit函数(见图8-5)认为它正常终止。当用退出符杀死sleep进程时,会发生同样的事情。终止状态130、131又是怎样得到的呢?原来Bourne shell有一个在其文档中没有说清楚的特性,其终止状态是128加上一个信号编号,该信号终止了正在执行的命令。用交互方式使用shell可以看到这一点。
$ sh 确保运行Bourne shell
$ sh -c "sleep 30"
^C 键入中断符
$ echo $? 打印最后一条命令的终止状态
130
$ sh -c "sleep 30"
^\sh: 962 Quit - core dumped 键入退出符
$ echo $? 打印最后一条命令的终止状态
131
$ exit 离开Bourne shell
在所使用的系统中,SIGINT的值为2,SIGQUIT的值为3,于是给出shell终止状态130、131。
再试一个类似的例子,这一次将一个信号直接送给shell,然后观察system返回什么:
$ tsys "sleep 30" & 这一次在后台启动它
9257
$ ps -f 查看进程ID
UID PID PPID TTY TIME CMD
sar 9260 949 pts/5 0:00 ps -f
sar 9258 9257 pts/5 0:00 sh -c sleep 30
sar 949 947 pts/5 0:01 /bin/sh
sar 9257 949 pts/5 0:00 tsys sleep 30
sar 9259 9258 pts/5 0:00 sleep 30
$ kill -KILL 9258 杀死shell自身
abnormal termination, signal number = 9从中可见,仅当shell本身异常终止时,system的返回值才报告一个异常终止。
其他的shell在处理终端产生的信号(如SIGINT和SIGQUIT)时表现出来的行为各不相同。例如在bash和dash中,键入中断或退出符会导致带有对应信号编号的表示异常终止的退出状态。但是,如果发现正在执行sleep的进程并直接给它发送信号,这样信号只会到达单个进程而不是整个前台进程组。这些shell与Bourne shell类似,以正常终止状态128加上信号编号退出。
在编写使用system函数的程序时,一定要正确地解释返回值。如果直接调用fork、exec和wait,则终止状态与调用system是不同的。
10.19 函数sleep、nanosleep和clock_nanosleep
在本书的很多例子中都已使用了sheep函数,在图10-7程序和图10-8程序中有两个sleep的实现,但它们都是有缺陷的。
#include <unistd.h>
unsigned int sleep(unsigned int seconds);
返回值:0或未休眠完的秒数
此函数使调用进程被挂起直到满足下面两个条件之一。
(1)已经过了seconds所指定的墙上时钟时间。
(2)调用进程捕捉到一个信号并从信号处理程序返回。
如同alarm信号一样,由于其他系统活动,实际返回时间比所要求的会迟一些。
在第1种情形,返回值是0。当由于捕捉到某个信号sleep提早返回时(第2种情形),返回值是未休眠完的秒数(所要求的时间减去实际休眠时间)。
尽管sleep可以用alarm函数(见10.10节)实现,但这并不是必需的。如果使用alarm,则这两个函数之间可能相互影响。POSIX.1 标准对这些相互影响并未做任何说明。例如,若先调用alarm(10),过了3秒后又调用sleep(5),那么将如何呢?sleep将在5秒后返回(假定在这段时间内没有捕捉到另一个信号),但是否在2秒后又产生另一个SIGALRM信号呢?此细节与具体实现有关。
FreeBSD 8.0、Linux 3.2.0、Mac OS X 10.6.8和Solaris 10用nanosleep函数实现sleep,使sleep具体实现与信号和闹钟定时器相互独立。考虑到可移植性,不应对sleep的实现进行任何假定,但是如果混合调用sleep和其他与时间有关的函数,则需了解它们之间可能产生的交互。
实例
图10-29给出的是一个POSIX.1 sleep函数的实现。此函数是图10-7程序的修改版,它可靠地处理信号,避免了早期实现中的竞争条件,但是仍未处理与以前设置的闹钟的交互作用(正如前面提到的,POSIX.1并未显式地对这些交互进行定义)。
图10-29 sleep的可靠实现
与图10-7相比,为了可靠地实现sleep,图10-29的代码比较长。程序中没有使用任何形式的非局部转移(如图10-8中为了避免在alarm和pause之间的竞争条件所做的那样),所以对处理SIGALRM信号期间可能执行的其他信号处理程序没有任何影响。
nanosleep函数与sleep函数类似,但提供了纳秒级的精度。
#include <time.h>
int nanosleep(const struct timespec *reqtp, struct timespec *remtp);
返回值:若休眠到要求的时间,返回0;若出错,返回−1
这个函数挂起调用进程,直到要求的时间已经超时或者某个信号中断了该函数。reqtp参数用秒和纳秒指定了需要休眠的时间长度。如果某个信号中断了休眠间隔,进程并没有终止,remtp参数指向的 timespec 结构就会被设置为未休眠完的时间长度。如果对未休眠完的时间并不感兴趣,可以把该参数置为NULL。
如果系统并不支持纳秒这一精度,要求的时间就会取整。因为nanosleep函数并不涉及产生任何信号,所以不需要担心与其他函数的交互。
nanosleep函数过去属于Single UNIX Specification的定时器选项,现已被移至SUSv4的基础部分。
随着多个系统时钟的引入(回忆 6.10 节),需要使用相对于特定时钟的延迟时间来挂起调用线程。clock_nanosleep函数提供了这种功能。
#include <time.h>
int clock_nanosleep(clockid_t clock_id, int flags,
const struct timespec *reqtp, struct timespec *remtp);
返回值:若休眠要求的时间,返回0;若出错,返回错误码
clock_id参数指定了计算延迟时间基于的时钟。时钟标识符列于图6-8中。flags参数用于控制延迟是相对的还是绝对的。flags为0时表示休眠时间是相对的(例如,希望休眠的时间长度),如果flags值设置为TIMER_ABSTIME,表示休眠时间是绝对的(例如,希望休眠到时钟到达某个特定的时间)。
其他的参数reqtp和remtp,与nanosleep函数中的相同。但是,使用绝对时间时,remtp参数未使用,因为没有必要。在时钟到达指定的绝对时间值以前,可以为其他的clock_nanosleep调用复用reqtp参数相同的值。
注意,除了出错返回,调用
clock_nanosleep(CLOCK_REALTIME, 0, reqtp, remtp);
和调用
nanosleep(reqtp, remtp);
的效果是相同的。使用相对休眠的问题是有些应用对休眠长度有精度要求,相对休眠时间会导致实际休眠时间比要求的长。例如,某个应用程序希望按固定的时间间隔执行任务,就必须获取当前时间,计算下次执行任务的时间,然后调用nanosleep。在获取当前时间和调用nanosleep之间,处理器调度和抢占可能会导致相对休眠时间超过实际需要的时间间隔。即便分时进程调度程序对休眠时间结束后是否会马上执行用户任务并没有给出保证,使用绝对时间还是改善了精度。
在Single UNIX Specification的早期版本中,clock_nanosleep函数属于时钟选择选项,在SUSv4中,该函数已移至基础部分。
在10.8节中,我们介绍了大部分UNIX系统不对信号排队。在POSIX.1的实时扩展中,有些系统开始增加对信号排队的支持。在SUSv4中,排队信号功能已从实时扩展部分移至基础说明部分。
通常一个信号带有一个位信息:信号本身。除了对信号排队以外,这些扩展允许应用程序在递交信号时传递更多的信息(回忆10.14节)。这些信息嵌入在siginfo结构中。除了系统提供的信息,应用程序还可以向信号处理程序传递整数或者指向包含更多信息的缓冲区指针。
使用排队信号必须做以下几个操作。
(1)使用sigaction函数安装信号处理程序时指定SA_SIGINFO标志。如果没有给出这个标志,信号会延迟,但信号是否进入队列要取决于具体实现。
(2)在sigaction结构的sa_sigaction成员中(而不是通常的sa_handler字段)提供信号处理程序。实现可能允许用户使用sa_handler字段,但不能获取sigqueue函数发送出来的额外信息。
(3)使用sigqueue函数发送信号。
#include <signal.h>
int sigqueue(pid_t pid, int signo, const union sigval value);
返回值:若成功,返回0;若出错,返回−1
sigqueue函数只能把信号发送给单个进程,可以使用value参数向信号处理程序传递整数和指针值,除此之外,sigqueue函数与kill函数类似。
信号不能被无限排队。回忆图2-9和图2-11中的SIGQUEUE_MAX限制。到达相应的限制以后,sigqueue就会失败,将errno设为EAGAIN。
随着实时信号的增强,引入了用于应用程序的独立信号集。这些信号的编号在SIGRTMIN~SIGRTMAX之间,包括这两个限制值。注意,这些信号的默认行为是终止进程。
图10-30总结了排队信号在本书不同的实现中的行为上的差异。
Mac OS X 10.6.8并不支持sigqueue或者实时信号。在Solaris 10中,sigqueue在实时库librt中。
图10-30 不同平台上排队信号的行为
在图10-1所示的信号中,POSIX.1认为有以下6个与作业控制有关。
SIGCHLD 子进程已停止或终止。
SIGCONT 如果进程已停止,则使其继续运行。
SIGSTOP 停止信号(不能被捕捉或忽略)。
SIGTSTP 交互式停止信号。
SIGTTIN 后台进程组成员读控制终端。
SIGTTOU 后台进程组成员写控制终端。
除SIGCHLD以外,大多数应用程序并不处理这些信号,交互式shell则通常会处理这些信号的所有工作。当键入挂起字符(通常是Ctrl+Z)时,SIGTSTP被送至前台进程组的所有进程。当我们通知shell在前台或后台恢复运行一个作业时,shell向该作业中的所有进程发送SIGCONT信号。与此类似,如果向一个进程递送了SIGTTIN或SIGTTOU信号,则根据系统默认的方式,停止此进程,作业控制shell了解到这一点后就通知我们。
一个例外是管理终端的进程,例如,vi(1)编辑器。当用户要挂起它时,它需要能了解到这一点,这样就能将终端状态恢复到 vi 启动时的情况。另外,当在前台恢复它时,它需要将终端状态设置回它所希望的状态,并需要重新绘制终端屏幕。可以在下面的例子中观察到与 vi 类似的程序是如何处理这种情况的。
在作业控制信号间有某些交互。当对一个进程产生 4 种停止信号(SIGTSTP、SIGSTOP、SIGTTIN或SIGTTOU)中的任意一种时,对该进程的任一未决SIGCONT信号就被丢弃。与此类似,当对一个进程产生SIGCONT信号时,对同一进程的任一未决停止信号被丢弃。
注意,如果进程是停止的,则SIGCONT的默认动作是继续该进程;否则忽略此信号。通常,对该信号无需做任何事情。当对一个停止的进程产生一个 SIGCONT 信号时,该进程就继续,即使该信号是被阻塞或忽略的也是如此。
实例
图10-31中的程序演示了当一个程序处理作业控制时通常所使用的规范代码序列。该程序只是将其标准输入复制到其标准输出,而在信号处理程序中以注释形式给出了管理屏幕的程序所执行的典型操作。
图10-31 如何处理SIGTSTP
当图10-31中的程序启动时,仅当SIGTSTP信号的配置是SIG_DFL,它才安排捕捉该信号。其理由是:当此程序由不支持作业控制的shell(如/bin/sh)启动时,此信号的配置应当设置为SIG_IGN。实际上,shell并不显式地忽略此信号,而是由init将这3个作业控制信号SIGTSTP、SIGTTIN和SIGTTOU设置为SIG_IGN。然后,这种配置由所有登录shell继承。只有作业控制shell才应将这3个信号重新设置为SIG_DFL。
当键入挂起字符时,进程接到 SIGTSTP 信号,然后调用该信号处理程序。此时,应当进行与终端有关的处理:将光标移到左下角、恢复终端工作方式等。在将SIGTSTP重置为默认值(停止该进程),并且解除了对此信号的阻塞之后,进程向自己发送同一信号SIGTSTP。因为正在处理 SIGTSTP 信号,而在捕捉该信号期间系统自动地阻塞它,所以应当解除对此信号的阻塞。到达这一点时,系统停止该进程。仅当某个进程(通常是正响应一个交互式fg命令的作业控制shell)向该进程发送一个 SIGCONT 信号时,该进程才继续。我们不捕捉 SIGCONT 信号。该信号的默认配置是继续运行停止的进程,当此发生时,此程序如同从 kill 函数返回一样继续运行。当此程序继续运行时,将SIGTSTP信号重置为捕捉,并且做我们所希望做的终端处理(如重新绘制屏幕)。
本节介绍如何在信号编号和信号名之间进行映射。某些系统提供数组
extern char *sys_siglist[];
数组下标是信号编号,数组中的元素是指向信号名符串的指针。
FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8都提供这种信号名数组。Solaris 10也提供信号名数组,但该数组名是_sys_siglist。
可以使用psignal函数可移植地打印与信号编号对应的字符串。
#include <signal.h>
void psignal(int signo, const char *msg);
字符串msg(通常是程序名)输出到标准错误文件,后面跟随一个冒号和一个空格,再后面对该信号的说明,最后是一个换行符。如果msg为NULL,只有信号说明部分输出到标准错误文件,该函数类似于perror(1.7节)。
如果在sigaction信号处理程序中有siginfo结构,可以使用psiginfo函数打印信号信息。
#include <signal.h>
void psiginfo(const siginfo_t *info, const char *msg);
它的工作方式与 psignal 函数类似。虽然这个函数访问除信号编号以外的更多信息,但不同的平台输出的这些额外信息可能有所不同。
如果只需要信号的字符描述部分,也不需要把它写到标准错误文件中(如可以写到日志文件中),可以使用strsignal函数,它类似于strerror(另见1.7节)。
#include <string.h>
char *strsignal(int signo);
返回值:指向描述该信号的字符串的指针
给出一个信号编号,strsignal 将返回描述该信号的字符串。应用程序可用该字符串打印关于接收到信号的出错消息。
本书讨论的所有平台都提供psignal和strsignal函数,但相互之间有些差别。在Solaris 10中,若信号编号无效,strsignal将返回一个空指针,而FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8则返回一个字符串,它指出信号编号是不可识别的。
只有Linux 3.2.0和Solaris 10支持psiginfo函数。
Solaris提供一对函数,一个函数将信号编号映射为信号名,另一个则反之。
#include <signal.h>
int sig2str(int signo, char *str);
int str2sig(const char *str, int *signop);
两个函数的返回值:若成功,返回0;若出错,返回−1
在编写交互式程序,其中需接收和打印信号名和信号编号时,这两个函数是有用的。
sig2str函数将给定信号编号翻译成字符串,并将结果存放在str指向的存储区。调用者必须保证该存储区足够大,可以保存最长字符串,包括终止 null 字节。Solaris 在<signal.h>中包含了常量SIG2STR_MAX,它定义了最大字符串长度。该字符串包括不带“SIG”前缀的信号名。例如,SIGKILL被翻译为字符串“KILL”,并存放在str指向的存储缓冲区中。
str2sig 函数将给出的信号名翻译成信号编号。该信号编号存放在signop指向的整型中。名字要么是不带“SIG”前缀的信号名,要么是表示十进制信号编号的字符串(如“9”)。
注意,sig2str和str2sig与常用的函数做法不同,当它们失败时,并不设置errno。
信号用于大多数复杂的应用程序中。理解进行信号处理的原因和方式对于高级UNIX编程极其重要。本章对UNIX信号进行了详细而且比较深入的介绍。首先说明了早期信号实现的问题以及它们是如何显现出来的。然后介绍了POSIX.1的可靠信号概念以及所有相关的函数。在此基础上提供了abort、system和sleep函数的POSIX.1实现。最后以观察分析作业控制信号以及信号名和信号编号之间的转换结束。
10.1 删除图10-2程序中的for(;;)语句,结果会怎样?为什么?
10.2 实现10.22节中说明的sig2str函数。
10.3 画出运行图10-9程序时的栈帧情况。
10.4 图10-11程序中利用setjmp和longjmp设置I/O操作的超时,下面的代码也常见用于此种目的:
signal(SIGALRM, sig_alrm);
alarm(60);
if (setjmp(env_alrm) != 0) {
/* handle timeout */
.
}
..
这段代码有什么错误?
10.5 仅使用一个定时器(alarm或较高精度的setitimer),构造一组函数,使得进程在该单一定时器基础上可以设置任一数量的定时器。
10.6 编写一段程序测试图 10-24中父进程和子进程的同步函数,要求进程创建一个文件并向文件写一个整数 0,然后,进程调用 fork,接着,父进程和子进程交替增加文件中的计数器值,每次计数器值增加1时,打印是哪一个进程(子进程或父进程)进行了该增加1操作。
10.7 在图10-25中,若调用者捕捉了SIGABRT并从该信号处理程序中返回,为什么不是仅仅调用_exit,而要恢复其默认设置并再次调用kill?
10.8 为什么在siginfo结构(见10.14节)的si_uid字段中包括实际用户ID而非有效用户ID?
10.9 重写图10-14中的函数,要求它处理图10-1中的所有信号,每次循环处理当前信号屏蔽字中的一个信号(并不是对每一个可能的信号都循环一次)。
10.10 编写一段程序,要求在一个无限循环中调用sleep(60)函数,每5分钟(即5次循环)取当前的日期和时间,并打印tm_sec字段。将程序执行一晚上,请解释其结果。有些程序,如cron守护进程,每分钟运行一次,它是如何处理这类工作的?
10.11 修改图3-5的程序,要求:(a)将BUFFSIZE改为100;(b)用signal_intr函数捕捉SIGXFSZ信号量并打印消息,然后从信号处理程序中返回;(c)如果没有写满请求的字节数,则打印write的返回值。将软资源限制RLIMIT_FSIZE(见7.11节)更改为1 024字节(在shell中设置软资源限制,如果不行就直接在程序中调用setrlimit),然后复制一个大于1 024字节的文件,在各种不同的系统上运行新程序,其结果如何?为什么?
10.12 编写一段调用fwrite的程序,它使用一个较大的缓冲区(约1 GB),调用fwrite前调用alarm使得1 s以后产生信号。在信号处理程序中打印捕捉到的信号,然后返回。fwrite可以完成吗?结果如何?
在前面的章节中讨论了进程,学习了UNIX进程的环境、进程间的关系以及控制进程的不同方式。可以看到在相关的进程间可以存在一定的共享。
本章将进一步深入理解进程,了解如何使用多个控制线程(或者简单地说就是线程)在单进程环境中执行多个任务。一个进程中的所有线程都可以访问该进程的组成部件,如文件描述符和内存。
不管在什么情况下,只要单个资源需要在多个用户间共享,就必须处理一致性问题。本章的最后将讨论目前可用的同步机制,防止多个线程在共享资源时出现不一致的问题。
典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。这种方法有很多好处。
•通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
•多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,我们将在第 15 章和第 17 章中学习这方面的内容。而多个线程自动地可以访问相同的存储地址空间和文件描述符。
•有些问题可以分解从而提高整个程序的吞吐量。在只有一个控制线程的情况下,一个单线程进程要完成多个任务,只需要把这些任务串行化。但有多个控制线程时,相互独立的任务的处理就可以交叉进行,此时只需要为每个任务分配一个单独的线程。当然只有在两个任务的处理过程互不依赖的情况下,两个任务才可以交叉执行。
•交互的程序同样可以通过使用多线程来改善响应时间,多线程可以把程序中处理用户输入输出的部分与其他部分分开。
有些人把多线程的程序设计与多处理器或多核系统联系起来。但是即使程序运行在单处理器上,也能得到多线程编程模型的好处。处理器的数量并不影响程序结构,所以不管处理器的个数多少,程序都可以通过使用线程得以简化。而且,即使多线程程序在串行化任务时不得不阻塞,由于某些线程在阻塞的时候还有另外一些线程可以运行,所以多线程程序在单处理器上运行还是可以改善响应时间和吞吐量。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量(见1.7节)以及线程私有数据(见12.6 节)。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。
我们将要讨论的线程接口来自POSIX.1-2001。线程接口也称为“pthread”或“POSIX线程”,原来在POSIX.1-2001中是一个可选功能,但后来SUSv4把它们放入了基本功能。POSIX线程的功能测试宏是_POSIX_THREADS。应用程序可以把这个宏用于#ifdef测试,从而在编译时确定是否支持线程;也可以把_SC_THREADS常数用于调用sysconf函数,进而在运行时确定是否支持线程。遵循SUSv4的系统定义符号_POSIX_THREADS的值为200809L。
就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程 ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
回忆一下进程ID,它是用pid_t数据类型来表示的,是一个非负整数。线程ID是用pthread_t数据类型来表示的,实现的时候可以用一个结构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进行比较。
#include <pthread.h>
int pthread_equal(pthread_t tid1, pthread_t tid2);
返回值:若相等,返回非0数值;否则,返回0
Linux 3.2.0使用无符号长整型表示pthread_t数据类型。Solaris 10把pthread_t数据类型表示为无符号整型。FreeBSD 8.0和Mac OS X 10.6.8用一个指向pthread结构的指针来表示pthread_t数据类型。
用结构表示pthread_t数据类型的后果是不能用一种可移植的方式打印该数据类型的值。在程序调试过程中打印线程ID有时是非常有用的,而在其他情况下通常不需要打印线程ID。最坏的情况是,有可能出现不可移植的调试代码,当然这也算不上是很大的局限性。
线程可以通过调用pthread_self函数获得自身的线程ID。
#include <pthread.h>
pthread_t pthread_self(void);
返回值:调用线程的线程ID
当线程需要识别以线程ID作为标识的数据结构时,pthread_self函数可以与pthread_equal函数一起使用。例如,主线程可能把工作任务放在一个队列中,用线程ID来控制每个工作线程处理哪些作业。如图11-1所示,主线程把新的作业放到一个工作队列中,由3个工作线程组成的线程池从队列中移出作业。主线程不允许每个线程任意处理从队列顶端取出的作业,而是由主线程控制作业的分配,主线程会在每个待处理作业的结构中放置处理该作业的线程ID,每个工作线程只能移出标有自己线程ID的作业。
图11-1 工作队列实例
在传统UNIX进程模型中,每个进程只有一个控制线程。从概念上讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别。新增的线程可以通过调用pthread_create函数创建。
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp,
const pthread_attr_t *restrict attr,
void *(*start_rtn)(void *), void *restrict arg);
返回值:若成功,返回0;否则,返回错误编号
当pthread_create成功返回时,新创建线程的线程ID会被设置成tidp指向的内存单元。attr参数用于定制各种不同的线程属性。我们将在12.3节中讨论线程属性,但现在我们把它置为NULL,创建一个具有默认属性的线程。
新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无类型指针参数arg。如果需要向start_rtn函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数传入。
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
注意,pthread 函数在调用失败时通常会返回错误码,它们并不像其他的 POSIX 函数一样设置errno。每个线程都提供errno的副本,这只是为了与使用errno的现有函数兼容。在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局状态,这样可以把错误的范围限制在引起出错的函数中。
实例
虽然没有可移植的打印线程 ID 的方法,但是可以写一个小的测试程序来完成这个任务,以便更深入地了解线程是如何工作的。图 11-2 中的程序创建了一个线程,打印了进程 ID、新线程的线程ID以及初始线程的线程ID。
图11-2 打印线程ID
这个实例有两个特别之处,需要处理主线程和新线程之间的竞争。(我们将在这章后面的内容中学习如何更好地处理这种竞争。)第一个特别之处在于,主线程需要休眠,如果主线程不休眠,它就可能会退出,这样新线程还没有机会运行,整个进程可能就已经终止了。这种行为特征依赖于操作系统中的线程实现和调度算法。
第二个特别之处在于新线程是通过调用pthread_self函数获取自己的线程ID的,而不是从共享内存中读出的,或者从线程的启动例程中以参数的形式接收到的。回忆 pthread_create函数,它会通过第一个参数(tidp)返回新建线程的线程ID。在这个例子中,主线程把新线程ID存放在 ntid 中,但是新建的线程并不能安全地使用它,如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的ntid的内容,这个内容并不是正确的线程ID。
在Solaris上运行图11-2中的程序,得到:
$ ./a.out
main thread: pid 20075 tid 1 (0x1)
new thread: pid 20075 tid 2 (0x2)
正如我们期望的,两个线程的进程ID相同,但线程ID不同。在FreeBSD上运行图11-2中的程序,得到:
$ ./a.out
main thread: pid 37396 tid 673190208 (0x28201140)
new thread: pid 37396 tid 673280320 (0x28217140)
也如我们期望的,两个线程有相同的进程ID。如果把线程ID看成是十进制整数,那么这两个值看起来很奇怪,但是如果把它们转化成十六进制,看起来就更合理了。就像前面提到的,FreeBSD使用指向线程数据结构的指针作为它的线程ID。
我们期望Mac OS X与FreeBSD相似,但事实上,在Mac OS X中,主线程ID与用pthread_create新创建的线程的线程ID不在相同的地址范围内:
$ ./a.out
main thread: pid 31807 tid 140735073889440 (0x7fff70162ca0)
new thread: pid 31807 tid 4295716864 (0x1000b7000)
相同的程序在Linux上运行得到:
$ ./a.out
main thread: pid 17874 tid 140693894424320 (0x7ff5d9996700)
new thread: pid 17874 tid 140693886129920 (0x7ff5d91ad700)
尽管Linux线程ID是用无符号长整型来表示的,但是它们看起来像指针。
Linux 2.4和Linux 2.6在线程实现上是不同的。Linux 2.4中,LinuxThreads是用单独的进程实现每个线程的,这使得它很难与POSIX线程的行为匹配。Linux 2.6中,对Linux内核和线程库进行了很大的修改,采用了一个称为Native POSIX线程库(Native POSIX Thread Library,NPTL)的新线程实现。它支持单个进程中有多个线程的模型,也更容易支持POSIX线程的语义。
如果进程中的任意线程调用了 exit、_Exit 或者_exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程(12.8节将讨论信号与线程间是如何交互的)。
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。
(1)线程可以简单地从启动例程中返回,返回值是线程的退出码。
(2)线程可以被同一进程中的其他线程取消。
(3)线程调用pthread_exit。
#include <pthread.h>
void pthread_exit(void *rval_ptr);
rval_ptr 参数是一个无类型指针,与传给启动例程的单个参数类似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指针。
#include <pthread.h>
int pthread_join(pthread_t thread, void **rval_ptr);
返回值:若成功,返回0;否则,返回错误编号
调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消。如果线程简单地从它的启动例程返回,rval_ptr就包含返回码。如果线程被取消,由rval_ptr指定的内存单元就设置为PTHREAD_CANCELED。
可以通过调用pthread_join自动把线程置于分离状态(马上就会讨论到),这样资源就可以恢复。如果线程已经处于分离状态,pthread_join调用就会失败,返回EINVAL,尽管这种行为是与具体实现相关的。
如果对线程的返回值并不感兴趣,那么可以把rval_ptr设置为 NULL。在这种情况下,调用pthread_join函数可以等待指定的线程终止,但并不获取线程的终止状态。
实例
图11-3展示了如何获取已终止的线程的退出码。
图11-3 获得线程退出状态
运行图11-3中的程序,得到的结果是:
$ ./a.out
thread 1 returning
thread 2 exiting
thread 1 exit code 1
thread 2 exit code 2
可以看到,当一个线程通过调用pthread_exit退出或者简单地从启动例程中返回时,进程中的其他线程可以通过调用pthread_join函数获得该线程的退出状态。
pthread_create和pthread_exit函数的无类型指针参数可以传递的值不止一个,这个指针可以传递包含复杂信息的结构的地址,但是注意,这个结构所使用的内存在调用者完成调用以后必须仍然是有效的。例如,在调用线程的栈上分配了该结构,那么其他的线程在使用这个结构时内存内容可能已经改变了。又如,线程在自己的栈上分配了一个结构,然后把指向这个结构的指针传给pthread_exit,那么调用pthread_join的线程试图使用该结构时,这个栈有可能已经被撤销,这块内存也已另作他用。
实例
图11-4中的程序给出了用自动变量(分配在栈上)作为pthread_exit的参数时出现的问题。
图11-4 pthread_exit参数的不正确使用
在Linux上运行此程序,得到:
$ ./a.out
thread 1:
structure at 0x7f2c83682ed0
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
parent starting second thread
thread 2: ID is 139829159933696
parent:
structure at 0x7f2c83682ed0
foo.a = -2090321472
foo.b = 32556
foo.c = 1
foo.d = 0
当然,运行结果根据内存体系结构、编译器以及线程库的实现会有所不同。在Solaris上的结果类似:
$ ./a.out
thread 1:
structure at 0xffffffff7f0fbf30
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
parent starting second thread
thread 2: ID is 3
parent:
structure at 0xffffffff7f0fbf30
foo.a = -1
foo.b = 2136969048
foo.c = -1
foo.d = 2138049024
可以看到,当主线程访问这个结构时,结构的内容(在线程tid1的栈上分配的)已经改变了。注意第二个线程(tid2)的栈是如何覆盖第一个线程的栈的。为了解决这个问题,可以使用全局结构,或者用malloc函数分配结构。
在Mac OS X上运行的结果有所不同:
$ ./a.out
thread 1:
structure at 0x1000b6f00
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
parent starting second thread
thread 2: ID is 4295716864
parent:
structure at 0x1000b6f00
Segmentation fault (core dumped)
在这种情况下,父进程试图访问已退出的第一个线程传给它的结构时,内存不再有效,这时得到的是SIGSEGV信号。
FreeBSD上,父进程访问内存时,内存并没有被覆写,得到的结果是:
thread 1:
structure at 0xbf9fef88
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
parent starting second thread
thread 2: ID is 673279680
parent:
structure at 0xbf9fef88
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
虽然线程退出后,内存依然是完整的,但我们不能期望情况总是这样的。从其他平台上的结果中可以看出,情况并不都是这样的。
线程可以通过调用pthread_cancel函数来请求取消同一进程中的其他线程。
#include <pthread.h>
int pthread_cancel(pthread_t tid);
返回值:若成功,返回0;否则,返回错误编号
在默认情况下,pthread_cancel 函数会使得由tid标识的线程的行为表现为如同调用了参数为PTHREAD_ CANCELED 的pthread_exit 函数,但是,线程可以选择忽略取消或者控制如何被取消。我们将在12.7节中详细讨论。注意pthread_cancel并不等待线程终止,它仅仅提出请求。
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数(见7.3节)安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。
#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *), void *arg);
void pthread_cleanup_pop(int execute);
当线程执行以下动作时,清理函数rtn是由pthread_cleanup_push函数调度的,调用时只有一个参数arg:
•调用pthread_exit时;
•响应取消请求时;
•用非零execute参数调用pthread_cleanup_pop时。
如果 execute 参数设置为 0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序。
这些函数有一个限制,由于它们可以实现为宏,所以必须在与线程相同的作用域中以匹配对的形式使用。pthread_cleanup_push 的宏定义可以包含字符{,这种情况下,在 pthread_cleanup_pop的定义中要有对应的匹配字符}。
实例
图11-5给出了一个如何使用线程清理处理程序的例子。虽然例子是人为编造的,但它描述了其中涉及的清理机制。注意,虽然我们从来没想过要传一个参数0给线程启动例程,但还是需要把pthread_cleanup_pop调用和pthread_cleanup_push调用匹配起来,否则,程序编译就可能通不过。
图11-5 线程清理处理程序
在Linux或者Solaris上运行图11-5中的程序会得到:
$ ./a.out
thread 1 start
thread 1 push complete
thread 2 start
thread 2 push complete
cleanup: thread 2 second handler
cleanup: thread 2 first handler
thread 1 exit code 1
thread 2 exit code 2
从输出结果可以看出,两个线程都正确地启动和退出了,但是只有第二个线程的清理处理程序被调用了。因此,如果线程是通过从它的启动例程中返回而终止的话,它的清理处理程序就不会被调用。还要注意,清理处理程序是按照与它们安装时相反的顺序被调用的。
如果在FreeBSD或者Mac OS X上运行相同的程序,可以看到程序会出现段异常并产生core文件。这是因为在这两个平台上,pthread_cleanup_push是用宏实现的,而宏把某些上下文存放在栈上。当线程1在调用pthread_cleanup_push和调用pthread_cleanup_pop之间返回时,栈已被改写,而这两个平台在调用清理处理程序时就用了这个被改写的上下文。在Single UNIX Specification中,函数如果在调用pthread_cleanup_push和pthread_cleanup_pop之间返回,会产生未定义行为。唯一的可移植方法是调用pthread_exit。
现在,让我们了解一下线程函数和进程函数之间的相似之处。图11-6总结了这些相似的函数。
图11-6 进程和线程原语的比较
在默认情况下,线程的终止状态会保存直到对该线程调用pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它的终止状态,因为对分离状态的线程调用pthread_join会产生未定义行为。可以调用pthread_detach分离线程。
#include <pthread.h>
int pthread_detach(pthread_t tid);
返回值:若成功,返回0;否则,返回错误编号
在下一章里,我们将学习通过修改传给pthread_create函数的线程属性,创建一个已处于分离状态的线程。
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取和修改的,那么就不存在一致性问题。同样,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保它们在访问变量的存储内容时不会访问到无效的值。
当一个线程修改变量时,其他线程在读取这个变量时可能会看到一个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。当然,这种行为是与处理器体系结构相关的,但是可移植的程序并不能对使用何种处理器体系结构做出任何假设。
图 11-7 描述了两个线程读写相同变量的假设例子。在这个例子中,线程 A读取变量然后给这个变量赋予一个新的数值,但写操作需要两个存储器周期。当线程B在这两个存储器写周期中间读取这个变量时,它就会得到不一致的值。
为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程访问该变量。图11-8描述了这种同步。如果线程B希望读取变量,它首先要获取锁。同样,当线程A更新变量时,也需要获取同样的这把锁。这样,线程B在线程A释放锁以前就不能读取变量。
图11-7 两个线程的交叉存储器周期
图11-8 两个线程同步内存访问
两个或多个线程试图在同一时间修改同一变量时,也需要进行同步。考虑变量增量操作的情况(图11-9),增量操作通常分解为以下3步。
(1)从内存单元读入寄存器。
(2)在寄存器中对变量做增量操作。
(3)把新的值写回内存单元。
如果两个线程试图几乎在同一时间对同一个变量做增量操作而不进行同步的话,结果就可能出现不一致,变量可能比原来增加了1,也有可能比原来增加了2,具体增加了1还是2要取决于第二个线程开始操作时获取的数值。如果第二个线程执行第1步要比第一个线程执行第3步要早,第二个线程读到的值与第一个线程一样,为变量加1,然后写回去,事实上没有实际的效果,总的来说变量只增加了1。
如果修改操作是原子操作,那么就不存在竞争。在前面的例子中,如果增加1只需要一个存储器周期,那么就没有竞争存在。如果数据总是以顺序一致出现的,就不需要额外的同步。当多个线程观察不到数据的不一致时,那么操作就是顺序一致的。在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以我们并不能保证数据是顺序一致的。
图11-9 两个非同步的线程对同一个变量做增量操作
在顺序一致环境中,可以把数据修改操作解释为运行线程的顺序操作步骤。可以把这样的操作描述为“线程A对变量增加了1,然后线程B对变量增加了1,所以变量的值就比原来的大2”,或者描述为“线程B对变量增加了1,然后线程A对变量增加了1,所以变量的值就比原来的大2”。这两个线程的任何操作顺序都不可能让变量出现除了上述值以外的其他值。
除了计算机体系结构以外,程序使用变量的方式也会引起竞争,也会导致不一致的情况发生。例如,我们可能对某个变量加 1,然后基于这个值做出某种决定。因为这个增量操作步骤和这个决定步骤的组合并非原子操作,所以就给不一致情况的出现提供了可能。
可以使用 pthread 的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
只有将所有线程都设计成遵守相同数据访问规则的,互斥机制才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
互斥变量是用pthread_mutex_t数据类型表示的。在使用互斥变量以前,必须首先对它进行初始化,可以把它设置为常量PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态分配互斥量(例如,通过调用malloc函数),在释放内存前需要调用pthread_mutex_destroy。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
两个函数的返回值:若成功,返回0;否则,返回错误编号
要用默认的属性初始化互斥量,只需把attr设为NULL。我们将在12.4节中讨论互斥量属性。
对互斥量进行加锁,需要调用 pthread_mutex_lock。如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用pthread_mutex_unlock。
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
所有函数的返回值:若成功,返回0;否则,返回错误编号
如果线程不希望被阻塞,它可以使用pthread_mutex_trylock尝试对互斥量进行加锁。如果调用 pthread_mutex_trylock 时互斥量处于未锁住状态,那么 pthread_mutex_trylock将锁住互斥量,不会出现阻塞直接返回0,否则pthread_mutex_trylock 就会失败,不能锁住互斥量,返回EBUSY。
实例
图11-10描述了用于保护某个数据结构的互斥量。当一个以上的线程需要访问动态分配的对象时,我们可以在对象中嵌入引用计数,确保在所有使用该对象的线程完成数据访问之前,该对象内存空间不会被释放。
在对引用计数加 1、减 1、检查引用计数是否到达 0 这些操作之前需要锁住互斥量。在foo_alloc 函数中将引用计数初始化为 1 时没必要加锁,因为在这个操作之前分配线程是唯一引用该对象的线程。但是在这之后如果要将该对象放到一个列表中,那么它就有可能被别的线程发现,这时候需要首先对它加锁。
在使用该对象前,线程需要调用foo_hold对这个对象的引用计数加1。当对象使用完毕时,必须调用foo_rele释放引用。最后一个引用被释放时,对象所占的内存空间就被释放。
在这个例子中,我们忽略了线程在调用foo_hold之前是如何找到对象的。如果有另一个线程在调用foo_hold时阻塞等待互斥锁,这时即使该对象引用计数为0,foo_rele释放该对象的内存仍然是不对的。可以通过确保对象在释放内存前不会被找到这种方式来避免上述问题。可以通过下面的例子来看看如何做到这一点。
图11-10 使用互斥量保护数据结构
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,但是使用互斥量时,还有其他不太明显的方式也能产生死锁。例如,程序中使用一个以上的互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。
可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁(当然在其他的资源上仍可能出现死锁)。类似地,如果所有的线程总是在锁住互斥量A之前锁住互斥量B,那么也不会发生死锁。可能出现的死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。
有时候,应用程序的结构使得对互斥量进行排序是很困难的。如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。在这种情况下,可以先释放占有的锁,然后过一段时间再试。这种情况可以使用pthread_mutex_trylock接口避免死锁。如果已经占有某些锁而且pthread_mutex_trylock接口返回成功,那么就可以前进。但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间再重新试。
实例
在这个例子中,我们更新了图11-10的程序,展示了两个互斥量的使用方法。在同时需要两个互斥量时,总是让它们以相同的顺序加锁,这样可以避免死锁。第二个互斥量维护着一个用于跟踪foo数据结构的散列列表。这样hashlock互斥量既可以保护foo数据结构中的散列表fh,又可以保护散列链字段f_next。foo结构中的f_lock互斥量保护对foo结构中的其他字段的访问。
图11-11 使用两个互斥量
比较图 11-11 和图 11-10,可以看出,分配函数现在锁住了散列列表锁,把新的结构添加到了散列桶中,而且在对散列列表的锁解锁之前,先锁定了新结构中的互斥量。因为新的结构是放在全局列表中的,其他线程可以找到它,所以在初始化完成之前,需要阻塞其他线程试图访问新结构。
foo_find函数锁住散列列表锁,然后搜索被请求的结构。如果找到了,就增加其引用计数并返回指向该结构的指针。注意,加锁的顺序是,先在foo_find函数中锁定散列列表锁,然后再在foo_hold函数中锁定foo结构中的f_lock互斥量。
现在有了两个锁以后,foo_rele函数就变得更加复杂了。如果这是最后一个引用,就需要对这个结构互斥量进行解锁,因为我们需要从散列列表中删除这个结构,这样才可以获取散列列表锁,然后重新获取结构互斥量。从上一次获得结构互斥量以来我们可能被阻塞着,所以需要重新检查条件,判断是否还需要释放这个结构。如果另一个线程在我们为满足锁顺序而阻塞时发现了这个结构并对其引用计数加1,那么只需要简单地对整个引用计数减1,对所有的东西解锁,然后返回。
这种锁方法很复杂,所以我们需要重新审视原来的设计。我们也可以使用散列列表锁来保护结构引用计数,使事情大大简化。结构互斥量可以用于保护foo结构中的其他任何东西。图11-12反映了这种变化。
图11-12 简化的锁
注意,与图11-11中的程序相比,图11-12中的程序就简单多了。两种用途使用相同的锁时,围绕散列列表和引用计数的锁的排序问题就不存在了。多线程的软件设计涉及这两者之间的折中。如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,这可能并不能改善并发性。如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得复杂。作为一个程序员,需要在满足锁需求的情况下,在代码复杂性和性能之间找到正确的平衡。
11.6.3 函数pthread_mutex_timedlock
当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock 互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock函数与pthread_mutex_lock是基本等价的,但是在达到超时时间值时,pthread_mutex_timedlock 不会对互斥量进行加锁,而是返回错误码ETIMEDOUT。
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
const struct timespec *restrict tsptr);
返回值:若成功,返回0;否则,返回错误编号
超时指定愿意等待的绝对时间(与相对时间对比而言,指定在时间X之前可以阻塞等待,而不是说愿意阻塞Y秒)。这个超时时间是用timespec结构来表示的,它用秒和纳秒来描述时间。
实例
图11-13给出了如何用pthread_mutex_timedlock避免永久阻塞。
图11-13 使用pthread_mutex_timedlock
图11-13中的程序运行结果输出如下:
$ ./a.out
mutex is locked
current time is 11:41:58 AM
the time is now 11:42:08 AM
can’t lock mutex again: Connection timed out
这个程序故意对它已有的互斥量进行加锁,目的是演示pthread_mutex_timedlock是如何工作的。不推荐在实际中使用这种策略,因为它会导致死锁。
注意,阻塞的时间可能会有所不同,造成不同的原因有多种:开始时间可能在某秒的中间位置,系统时钟的精度可能不足以精确到支持我们指定的超时时间值,或者在程序继续运行前,调度延迟可能会增加时间值。
Mac OS X 10.6.8还没有支持pthread_mutex_timedlock,但是FreeBSD 8.0、Linux 3.2.0以及Solaris 10支持该函数,虽然Solaris仍然把它放在实时库librt中。Solaris 10还提供了另一个使用相对超时时间的函数。
读写锁(reader-writer lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为一次只有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时,只要线程先获取了读模式下的读写锁,该锁所保护的数据结构就可以被多个获得读模式锁的线程读取。
读写锁也叫做共享互斥锁(shared-exclusive lock)。当读写锁是读模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住的时候,就可以说成是以互斥模式锁住的。
与互斥量相比,读写锁在使用之前必须初始化,在释放它们底层的内存之前必须销毁。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
两个函数的返回值:若成功,返回0;否则,返回错误编号
读写锁通过调用 pthread_rwlock_init 进行初始化。如果希望读写锁有默认的属性,可以传一个null指针给attr,我们将在12.4.2节中讨论读写锁的属性。
Single UNIX Specification在XSI扩展中定义了PTHREAD_RWLOCK_INITIALIZER常量。如果默认属性就足够的话,可以用它对静态分配的读写锁进行初始化。
在释放读写锁占用的内存之前,需要调用 pthread_rwlock_destroy 做清理工作。如果pthread_rwlock_init为读写锁分配了资源,pthread_rwlock_destroy将释放这些资源。如果在调用 pthread_rwlock_destroy 之前就释放了读写锁占用的内存空间,那么分配给这个锁的资源就会丢失。
要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock。要在写模式下锁定读写锁,需要调用pthread_rwlock_wrlock。不管以何种方式锁住读写锁,都可以调用pthread_rwlock_unlock进行解锁。
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
所有函数的返回值:若成功,返回0;否则,返回错误编号
各种实现可能会对共享模式下可获取的读写锁的次数进行限制,所以需要检查 pthread_rwlock_rdlock的返回值。即使pthread_rwlock_wrlock和pthread_rwlock_unlock有错误返回,而且从技术上来讲,在调用函数时应该总是检查错误返回,但是如果锁设计合理的话,就不需要检查它们。错误返回值的定义只是针对不正确使用读写锁的情况(如未经初始化的锁),或者试图获取已拥有的锁从而可能产生死锁的情况。但是需要注意,有些特定的实现可能会定义另外的错误返回。
Single UNIX Specification还定义了读写锁原语的条件版本。
#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
两个函数的返回值:若成功,返回0;否则,返回错误编号
可以获取锁时,这两个函数返回0。否则,它们返回错误EBUSY。这两个函数可以用于我们前面讨论的遵守某种锁层次但还不能完全避免死锁的情况。
实例
图11-14中的程序解释了读写锁的使用。作业请求队列由单个读写锁保护。这个例子给出了图11-1所示的一种可能的实现,多个工作线程获取单个主线程分配给它们的作业。
图11-14 使用读写锁
在这个例子中,凡是需要向队列中增加作业或者从队列中删除作业的时候,都采用了写模式来锁住队列的读写锁。不管何时搜索队列,都需要获取读模式下的锁,允许所有的工作线程并发地搜索队列。在这种情况下,只有在线程搜索作业的频率远远高于增加或删除作业时,使用读写锁才可能改善性能。
工作线程只能从队列中读取与它们的线程 ID 匹配的作业。由于作业结构同一时间只能由一个线程使用,所以不需要额外的加锁。
与互斥量一样,Single UNIX Specification提供了带有超时的读写锁加锁函数,使应用程序在获取读写锁时避免陷入永久阻塞状态。这两个函数是 pthread_rwlock_timedrdlock 和 pthread_rwlock_timedwrlock。
#include <pthread.h>
#include <time.h>
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock,
const struct timespec *restrict tsptr);
int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock,
const struct timespec *restrict tsptr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
这两个函数的行为与它们“不计时的”版本类似。tsptr参数指向timespec结构,指定线程应该停止阻塞的时间。如果它们不能获取锁,那么超时到期时,这两个函数将返回 ETIMEDOUT错误。与pthread_mutex_timedlock函数类似,超时指定的是绝对时间,而不是相对时间。
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。
在使用条件变量之前,必须先对它进行初始化。由pthread_cond_t数据类型表示的条件变量可以用两种方式进行初始化,可以把常量PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,但是如果条件变量是动态分配的,则需要使用pthread_cond_init函数对它进行初始化。
在释放条件变量底层的内存空间之前,可以使用pthread_cond_destroy函数对条件变量进行反初始化(deinitialize)。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
两个函数的返回值:若成功,返回0;否则,返回错误编号
除非需要创建一个具有非默认属性的条件变量,否则pthread_cond_init函数的attr参数可以设置为NULL。我们将在12.4.3节中讨论条件变量属性。
我们使用pthread_cond_wait等待条件变量变为真。如果在给定的时间内条件不能满足,那么会生成一个返回错误码的变量。
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict tsptr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住。
pthread_cond_timedwait函数的功能与pthread_cond_wait函数相似,只是多了一个超时(tsptr)。超时值指定了我们愿意等待多长时间,它是通过timespec结构指定的。
如图11-13所示,需要指定愿意等待多长时间,这个时间值是一个绝对数而不是相对数。例如,假设愿意等待3分钟。那么,并不是把3分钟转换成timespec结构,而是需要把当前时间加上3分钟再转换成timespec结构。
可以使用clock_gettime函数(见6.10节)获取timespec结构表示的当前时间。但是目前并不是所有的平台都支持这个函数,因此,也可以用另一个函数 gettimeofday 获取timeval结构表示的当前时间,然后把这个时间转换成timespec结构。要得到超时值的绝对时间,可以使用下面的函数(假设阻塞的最大时间使用分来表示的):
#include <sys/time.h>
#include <stdlib.h>
void
maketimeout(struct timespec *tsp, long minutes)
{
struct timeval now;
/* get the current time */
gettimeofday(&now, NULL);
tsp->tv_sec = now.tv_sec;
tsp->tv_nsec = now.tv_usec * 1000; /* usec to nsec */
/* add the offset to get timeout value */
tsp->tv_sec += minutes * 60;
}
如果超时到期时条件还是没有出现,pthread_cond_timewait 将重新获取互斥量,然后返回错误ETIMEDOUT。从pthread_cond_wait或者pthread_cond_timedwait调用成功返回时,线程需要重新计算条件,因为另一个线程可能已经在运行并改变了条件。
有两个函数可以用于通知线程条件已经满足。pthread_cond_signal函数至少能唤醒一个等待该条件的线程,而pthread_cond_broadcast函数则能唤醒等待该条件的所有线程。
POSIX 规范为了简化 pthread_cond_signal 的实现,允许它在实现的时候唤醒一个以上的线程。
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
两个函数的返回值:若成功,返回0;否则,返回错误编号
在调用pthread_cond_signal或者pthread_cond_broadcast时,我们说这是在给线程或者条件发信号。必须注意,一定要在改变条件状态以后再给线程发信号。
实例
图11-15给出了如何结合使用条件变量和互斥量对线程进行同步。
图11-15 使用条件变量
条件是工作队列的状态。我们用互斥量保护条件,在 while 循环中判断条件。把消息放到工作队列时,需要占有互斥量,但在给等待线程发信号时,不需要占有互斥量。只要线程在调用pthread_cond_signal之前把消息从队列中拖出了,就可以在释放互斥量以后完成这部分工作。因为我们是在 while 循环中检查条件,所以不存在这样的问题:线程醒来,发现队列仍为空,然后返回继续等待。如果代码不能容忍这种竞争,就需要在给线程发信号的时候占有互斥量。
自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。
自旋锁通常作为底层原语用于实现其他类型的锁。根据它们所基于的系统体系结构,可以通过使用测试并设置指令有效地实现。当然这里说的有效也还是会导致CPU资源的浪费:当线程自旋等待锁变为可用时,CPU不能做其他的事情。这也是自旋锁只能够被持有一小段时间的原因。
当自旋锁用在非抢占式内核中时是非常有用的:除了提供互斥机制以外,它们会阻塞中断,这样中断处理程序就不会让系统陷入死锁状态,因为它需要获取已被加锁的自旋锁(把中断想成是另一种抢占)。在这种类型的内核中,中断处理程序不能休眠,因此它们能用的同步原语只能是自旋锁。
但是,在用户层,自旋锁并不是非常有用,除非运行在不允许抢占的实时调度类中。运行在分时调度类中的用户层线程在两种情况下可以被取消调度:当它们的时间片到期时,或者具有更高调度优先级的线程就绪变成可运行时。在这些情况下,如果线程拥有自旋锁,它就会进入休眠状态,阻塞在锁上的其他线程自旋的时间可能会比预期的时间更长。
很多互斥量的实现非常高效,以至于应用程序采用互斥锁的性能与曾经采用过自旋锁的性能基本是相同的。事实上,有些互斥量的实现在试图获取互斥量的时候会自旋一小段时间,只有在自旋计数到达某一阈值的时候才会休眠。这些因素,加上现代处理器的进步,使得上下文切换越来越快,也使得自旋锁只在某些特定的情况下有用。
自旋锁的接口与互斥量的接口类似,这使得它可以比较容易地从一个替换为另一个。可以用pthread_spin_init 函数对自旋锁进行初始化。用 pthread_spin_destroy 函数进行自旋锁的反初始化。
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
两个函数的返回值:若成功,返回0;否则,返回错误编号
只有一个属性是自旋锁特有的,这个属性只在支持线程进程共享同步(Thread Process-Shared Synchronization)选项(这个选项目前在Single UNIX Specification中是强制的,见图2-5)的平台上才用得到。pshared 参数表示进程共享属性,表明自旋锁是如何获取的。如果它设为 PTHREAD_PROCESS_SHARED,则自旋锁能被可以访问锁底层内存的线程所获取,即便那些线程属于不同的进程,情况也是如此。否则pshared参数设为 PTHREAD_PROCESS_PRIVATE,自旋锁就只能被初始化该锁的进程内部的线程所访问。
可以用pthread_spin_lock或pthread_spin_trylock对自旋锁进行加锁,前者在获取锁之前一直自旋,后者如果不能获取锁,就立即返回EBUSY 错误。注意,pthread_spin_trylock不能自旋。不管以何种方式加锁,自旋锁都可以调用pthread_spin_unlock函数解锁。
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
所有函数的返回值:若成功,返回0;否则,返回错误编号
注意,如果自旋锁当前在解锁状态的话,pthread_spin_lock函数不要自旋就可以对它加锁。如果线程已经对它加锁了,结果就是未定义的。调用pthread_spin_lock会返回EDEADLK错误(或其他错误),或者调用可能会永久自旋。具体行为依赖于实际的实现。试图对没有加锁的自旋锁进行解锁,结果也是未定义的。
不管是pthread_spin_lock还是pthread_spin_trylock,返回值为0的话就表示自旋锁被加锁。需要注意,不要调用在持有自旋锁情况下可能会进入休眠状态的函数。如果调用了这些函数,会浪费CPU资源,因为其他线程需要获取自旋锁需要等待的时间就延长了。
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。我们已经看到一种屏障,pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出。
但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作。
可以使用 pthread_barrier_init 函数对屏障进行初始化,用 thread_barrier_destroy函数反初始化。
#include <pthread.h>
int pthread_barrier_init(pthread_barrier_t *restrict barrier,
const pthread_barrierattr_t *restrict attr,
unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);
两个函数的返回值:若成功,返回0;否则,返回错误编号
初始化屏障时,可以使用count参数指定,在允许所有线程继续运行之前,必须到达屏障的线程数目。使用attr参数指定屏障对象的属性,我们会在下一章详细讨论。现在设置attr为NULL,用默认属性初始化屏障。如果使用pthread_barrier_init函数为屏障分配资源,那么在反初始化屏障时可以调用pthread_barrier_destroy函数释放相应的资源。
可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等所有其他线程赶上来。
#include <pthread.h>
int pthread_barrier_wait(pthread_barrier_t *barrier);
返回值:若成功,返回0或者PTHREAD_BARRIER_SERIAL_THREAD;否则,返回错误编号
调用pthread_barrier_wait的线程在屏障计数(调用pthread_barrier_init时设定)未满足条件时,会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有的线程都被唤醒。
对于一个任意线程,pthread_barrier_wait函数返回了PTHREAD_BARRIER_SERIAL_THREAD。剩下的线程看到的返回值是0。这使得一个线程可以作为主线程,它可以工作在其他所有线程已完成的工作结果上。
一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用。但是除非在调用了pthread_barrier_destroy函数之后,又调用了pthread_barrier_init函数对计数用另外的数进行初始化,否则屏障计数不会改变。
实例
图11-16给出了在一个任务上合作的多个线程之间如何用屏障进行同步。
图11-16 使用屏障
这个例子给出了多个线程只执行一个任务时,使用屏障的简单情况。在更加实际的情况下,工作线程在调用pthread_barrier_wait函数返回后会接着执行其他的活动。
在这个实例中,使用8个线程分解了800万个数的排序工作。每个线程用堆排序算法对100万个数进行排序(详细算法请参阅Knuth[1998])。然后主线程调用一个函数对这些结果进行合并。
并不需要使用 pthread_barrier_wait 函数中的返回值 PTHREAD_BARRIER_SERIAL_THREAD 来决定哪个线程执行结果合并操作,因为我们使用了主线程来完成这个任务。这也是把屏障计数值设为工作线程数加1的原因,主线程也作为其中的一个候选线程。
如果只用一个线程去完成800万个数的堆排序,那么与图11-16中的程序相比,我们将能看到图11-16中的程序在性能上有显著提升。在8核处理器系统上,单线程程序对800万个数进行排序需要12.14秒。同样的系统,使用8个并行线程和1个合并结果的线程,相同的800万个数的排序仅需要1.91秒,速度提升了6倍。
本章介绍了线程的概念,讨论了现有的创建和销毁线程的POSIX.1原语;此外,还介绍了线程同步问题,讨论了 5 个基本的同步机制(互斥量、读写锁、条件变量、自旋锁以及屏障),了解了如何使用它们来保护共享资源。
11.1 修改图11-4所示的实例代码,正确地在两个线程之间传递结构。
11.2 在图11-14所示的实例代码中,需要另外添加什么同步(如果需要的话)可以使得主线程改变与挂起作业关联的线程ID?这会对job_remove函数产生什么影响?
11.3 把图11-15中的技术运用到工作线程实例(图11-1和图11-14)中实现工作线程函数。不要忘记更新queue_init 函数对条件变量进行初始化,修改 job_insert 和job_append函数给工作线程发信号。会出现什么样的困难?
11.4 下面哪个步骤序列是正确的?
(1)对互斥量加锁(pthread_mutex_lock)。
(2)改变互斥量保护的条件。
(3)给等待条件的线程发信号(pthread_cond_broadcast)。
(4)对互斥量解锁(pthread_mutex_unlock)。
或者
(1)对互斥量加锁(pthread_mutex_lock)。
(2)改变互斥量保护的条件。
(3)对互斥量解锁(pthread_mutex_unlock)。
(4)给等待条件的线程发信号(pthread_cond_broadcast)。
11.5 实现屏障需要什么同步原语?给出pthread_barrier_wait函数的一个实现。
第 11 章讲了线程以及线程同步的基础知识。本章将讲解控制线程行为方面的详细内容,介绍线程属性和同步原语属性。前面的章节中使用的都它们的默认行为,没有进行详细介绍。
接下来还将介绍同一进程中的多个线程之间如何保持数据的私有性。最后讨论基于进程的系统调用如何与线程进行交互。
在2.5.4节中讨论了sysconf函数。Single UNIX Specification定义了与线程操作有关的一些限制,图2-11并没有列出这些限制。与其他的系统限制一样,这些限制也可以通过sysconf函数进行查询。图12-1总结了这些限制。
图12-1 线程限制和sysconf的name参数
与 sysconf 报告的其他限制一样,这些限制的使用是为了增强应用程序在不同的操作系统实现之间的可移植性。例如,如果应用程序需要为它管理的每个文件创建4个线程,但是系统却并不允许创建所有这些线程,这时可能就必须限制当前可并发管理的文件数。
图12-2给出了本书描述的4种操作系统实现中线程限制的值。如果操作系统实现的限制是不确定的,列出的值就是“没有确定的限制”(no limit)。但这并不意味着值是无限制的。
图12-2 线程配置限制的实例
注意,虽然某个操作系统实现可能没有提供访问这些限制的方法,但这并不意味着这些限制不存在,这只是意味着操作系统实现没有为使用sysconf访问这些值提供可用的方法。
pthread 接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。
(1)每个对象与它自己类型的属性对象进行关联(线程与线程属性关联,互斥量与互斥量属性关联,等等)。一个属性对象可以代表多个属性。属性对象对应用程序来说是不透明的。这意味着应用程序并不需要了解有关属性对象内部结构的详细细节,这样可以增强应用程序的可移植性。取而代之的是,需要提供相应的函数来管理这些属性对象。
(2)有一个初始化函数,把属性设置为默认值。
(3)还有一个销毁属性对象的函数。如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源。
(4)每个属性都有一个从属性对象中获取属性值的函数。由于函数成功时会返回0,失败时会返回错误编号,所以可以通过把属性值存储在函数的某一个参数指定的内存单元中,把属性值返回给调用者。
(5)每个属性都有一个设置属性值的函数。在这种情况下,属性值作为参数按值传递。
在第11章所有调用pthread_create函数的实例中,传入的参数都是空指针,而不是指向pthread_attr_t结构的指针。可以使用pthread_attr_t结构修改线程默认属性,并把这些属性与创建的线程联系起来。可以使用pthread_attr_init函数初始化pthread_attr_t结构。在调用pthread_attr_init以后,pthread_attr_t结构所包含的就是操作系统实现支持的所有线程属性的默认值。
#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
如果要反初始化pthread_attr_t结构,可以调用pthread_attr_destroy函数。如果pthread_attr_init的实现对属性对象的内存空间是动态分配的,pthread_attr_destroy就会释放该内存空间。除此之外,pthread_attr_destroy还会用无效的值初始化属性对象,因此,如果该属性对象被误用,将会导致pthread_create函数返回错误码。
图 12-3 总结了 POSIX.1 定义的线程属性。POSIX.1 还为线程执行调度(Thread Execution Scheduling)选项定义了额外的属性,用以支持实时应用,但我们并不打算在这里讨论这些属性。图12-3同时给出了各个操作系统平台对每个线程属性的支持情况。
图12-3 POSIX.1线程属性
11.5节介绍了分离线程的概念。如果对现有的某个线程的终止状态不感兴趣的话,可以使用pthread_detach函数让操作系统在线程退出时收回它所占用的资源。
如果在创建线程时就知道不需要了解线程的终止状态,就可以修改 pthread_attr_t 结构中的detachstate线程属性,让线程一开始就处于分离状态。可以使用 pthread_attr_setdetachstate函数把线程属性detachstate设置成以下两个合法值之一:PTHREAD_CREATE_DETACHED,以分离状态启动线程;或者PTHREAD_CREATE_JOINABLE,正常启动线程,应用程序可以获取线程的终止状态。
#include <pthread.h>
int pthread_attr_getdetachstate(const pthread_attr_t *restrict attr,
int *detachstate);
int pthread_attr_setdetachstate(pthread_attr_t *attr, int *detachstate);
两个函数的返回值:若成功,返回0;否则,返回错误编号
可以调用pthread_attr_getdetachstate函数获取当前的detachstate线程属性。第二个参数所指向的整数要么设置成 PTHREAD_CREATE_DETACHED,要么设置成 PTHREAD_CREATE_JOINABLE,具体要取决于给定pthread_attr_t结构中的属性值。
实例
图12-4给出了一个以分离状态创建线程的函数。
图12-4 以分离状态创建线程
注意,此例忽略了pthread_attr_destroy函数调用的返回值。在这个实例中,我们对线程属性进行了合理的初始化,因此 pthread_attr_destroy 应该不会失败。但是,如果pthread_attr_destroy确实出现了失败的情况,将难以清理:必须销毁刚刚创建的线程,也许这个线程可能已经运行,并且与 pthread_attr_destroy 函数可能是异步执行的。忽略pthread_attr_destroy 的错误返回可能出现的最坏情况是,如果 pthread_attr_init 已经分配了内存空间,就会有少量的内存泄漏。另一方面,如果 pthread_attr_init 成功地对线程属性进行了初始化,但之后pthread_attr_destroy的清理工作失败,那么将没有任何补救策略,因为线程属性结构对应用程序来说是不透明的,可以对线程属性结构进行清理的唯一接口是pthread_attr_destroy,但它失败了。
对于遵循POSIX标准的操作系统来说,并不一定要支持线程栈属性,但是对于遵循Single UNIX Specification 中 XSI 选项的系统来说,支持线程栈属性就是必需的。可以在编译阶段使用_POSIX_THREAD_ATTR_STACKADDR和_POSIX_THREAD_ATTR_STACKSIZE符号来检查系统是否支持每一个线程栈属性。如果系统定义了这些符号中的一个,就说明它支持相应的线程栈属性。或者,也可以在运行阶段把_SC_THREAD_ATTR_ STACKADDR 和_SC_THREAD_ATTR_STACKSIZE 参数传给sysconf函数,检查运行时系统对线程栈属性的支持情况。
可以使用函数pthread_attr_getstack和pthread_attr_setstack对线程栈属性进行管理。
#include <pthread.h>
int pthread_attr_getstack(const pthread_attr_t *restrict attr,
void **restrict stackaddr,
size_t *restrict stacksize);
int pthread_attr_setstack(pthread_attr_t *attr,
void *stackaddr, size_t stacksize);
两个函数的返回值:若成功,返回0;否则,返回错误编号
对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。
如果线程栈的虚地址空间都用完了,那可以使用malloc或者mmap(见14.8节)来为可替代的栈分配空间,并用pthread_attr_setstack函数来改变新建线程的栈位置。由stackaddr参数指定的地址可以用作线程栈的内存范围中的最低可寻址地址,该地址与处理器结构相应的边界应对齐。当然,这要假设malloc和mmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。
stackaddr线程属性被定义为栈的最低内存地址,但这并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长的,那么stackaddr线程属性将是栈的结尾位置,而不是开始位置。
应用程序也可以通过pthread_attr_getstacksize和pthread_attr_setstacksize函数读取或设置线程属性stacksize。
#include <pthread.h>
int pthread_attr_getstacksize(const pthread_attr_t *restrict attr,
size_t *restrict stacksize);
int pthread_attr_setstacksize (pthread_attr_t *attr, size_t stacksize);
两个函数的返回值:若成功,返回0;否则,返回错误编号
如果希望改变默认的栈大小,但又不想自己处理线程栈的分配问题,这时使用pthread_attr_setstacksize函数就非常有用。设置stacksize属性时,选择的stacksize不能小于PTHREAD_STACK_MIN。
线程属性guardsize控制着线程栈末尾之后用以避免栈溢出的扩展内存的大小。这个属性默认值是由具体实现来定义的,但常用值是系统页大小。可以把guardsize线程属性设置为0,不允许属性的这种特征行为发生:在这种情况下,不会提供警戒缓冲区。同样,如果修改了线程属性stackaddr,系统就认为我们将自己管理栈,进而使栈警戒缓冲区机制无效,这等同于把guardsize线程属性设置为0。
#include <pthread.h>
iint pthread_attr_getguardsize(const pthread_attr_t *restrict attr,
size_t *restrict guardsize);
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
两个函数的返回值:若成功,返回0;否则,返回错误编号
如果guardsize线程属性被修改了,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序就可能通过信号接收到出错信息。
Single UNIX Specification还定义了一些其他的可选线程属性供实时应用程序使用,但在这里不讨论这些属性。
线程还有一些其他的pthread_attr_t结构中没有表示的属性:可撤销状态和可撤销类型。我们将在12.7节中讨论它们。
就像线程具有属性一样,线程的同步对象也有属性。11.6.7节中介绍了自旋锁,它有一个属性称为进程共享属性。本节讨论互斥量属性、读写锁属性、条件变量属性和屏障属性。
互斥量属性是用pthread_mutexattr_t结构表示的。第11章中每次对互斥量进行初始化时,都是通过使用PTHREAD_MUTEX_INITIALIZER常量或者用指向互斥量属性结构的空指针作为参数调用pthread_mutex_init函数,得到互斥量的默认属性。
对于非默认属性,可以用pthread_mutexattr_init初始化pthread_mutexattr_t结构,用pthread_mutexattr_destroy来反初始化。
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
pthread_mutexattr_init 函数将用默认的互斥量属性初始化 pthread_mutexattr_t结构。值得注意的3个属性是:进程共享属性、健壮属性以及类型属性。POSIX.1中,进程共享属性是可选的。可以通过检查系统中是否定义了_POSIX_THREAD_PROCESS_SHARED 符号来判断这个平台是否支持进程共享这个属性,也可以在运行时把_SC_THREAD_PROCESS_SHARED 参数传给sysconf函数进行检查。虽然这个选项并不是遵循POSIX标准的操作系统必须提供的,但是Single UNIX Specification要求遵循XSI标准的操作系统支持这个选项。
在进程中,多个线程可以访问同一个同步对象。正如在第11章中看到的,这是默认的行为。在这种情况下,进程共享互斥量属性需设置为PTHREAD_PROCESS_PRIVATE。
我们将在第14章和第15章中看到,存在这样的机制:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。就像多个线程访问共享数据一样,多个进程访问共享数据通常也需要同步。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。
可以使用pthread_mutexattr_getpshared函数查询pthread_mutexattr_t结构,得到它的进程共享属性,使用pthread_mutexattr_setpshared函数修改进程共享属性。
#include <pthread.h>
int pthread_mutexattr_getpshared(const pthread_mutexattr_t
*restrict attr,
int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr,
int pshared);
两个函数的返回值:若成功,返回0;否则,返回错误编号
进程共享互斥量属性设置为PTHREAD_PROCESS_PRIVATE时,允许pthread线程库提供更有效的互斥量实现,这在多线程应用程序中是默认的情况。在多个进程共享多个互斥量的情况下, pthread线程库可以限制开销较大的互斥量实现。
互斥量健壮属性与在多个进程间共享的互斥量有关。这意味着,当持有互斥量的进程终止时,需要解决互斥量状态恢复的问题。这种情况发生时,互斥量处于锁定状态,恢复起来很困难。其他阻塞在这个锁的进程将会一直阻塞下去。
可以使用 pthread_mutexattr_getrobust 函数获取健壮的互斥量属性的值。可以调用 pthread_mutexattr_setrobust函数设置健壮的互斥量属性的值。
#include <pthread.h>
int pthread_mutexattr_getrobust(const pthread_mutexattr_t
*restrict attr,
int *restrict robust);
int pthread_mutexattr_setrobust(pthread_mutexattr_t *attr,
int robust);
两个函数的返回值:若成功,返回0;否则,返回错误编号
健壮属性取值有两种可能的情况。默认值是 PTHREAD_MUTEX_STALLED,这意味着持有互斥量的进程终止时不需要采取特别的动作。这种情况下,使用互斥量后的行为是未定义的,等待该互斥量解锁的应用程序会被有效地“拖住”。另一个取值是PTHREAD_MUTEX_ROBUST。这个值将导致线程调用pthread_mutex_lock获取锁,而该锁被另一个进程持有,但它终止时并没有对该锁进行解锁,此时线程会阻塞,从pthread_mutex_lock返回的值为EOWNERDEAD而不是0。应用程序可以通过这个特殊的返回值获知,若有可能(要保护状态的细节以及如何进行恢复会因不同的应用程序而异),不管它们保护的互斥量状态如何,都需要进行恢复。
使用健壮的互斥量改变了我们使用pthread_mutex_lock的方式,因为现在必须检查3个返回值而不是之前的两个:不需要恢复的成功、需要恢复的成功以及失败。但是,即使不用健壮的互斥量,也可以只检查成功或者失败。
在本书的4个平台中,只有Linux 3.2.0目前支持健壮的线程互斥量。Solaris 10只在它的Solaris线程库中支持健壮的线程互斥量(参阅Solaris手册的mutex_init(3C)获取相关的信息)。但是Solaris 11支持健壮的线程互斥量。
如果应用状态无法恢复,在线程对互斥量解锁以后,该互斥量将处于永久不可用状态。为了避免这样的问题,线程可以调用 pthread_mutex_consistent 函数,指明与该互斥量相关的状态在互斥量解锁之前是一致的。
#include <pthread.h>
int pthread_mutex_consistent(pthread_mutex_t *mutex);
返回值:若成功,返回0;否则,返回错误编号
如果线程没有先调用 pthread_mutex_consistent 就对互斥量进行了解锁,那么其他试图获取该互斥量的阻塞线程就会得到错误码ENOTRECOVERABLE。如果发生这种情况,互斥量将不再可用。线程通过提前调用pthread_mutex_consistent,能让互斥量正常工作,这样它就可以持续被使用。
类型互斥量属性控制着互斥量的锁定特性。POSIX.1定义了4种类型。
PTHREAD_MUTEX_NORMAL 一种标准互斥量类型,不做任何特殊的错误检查或死锁检测。
PTHREAD_MUTEX_ERRORCHECK 此互斥量类型提供错误检查。
PTHREAD_MUTEX_RECURSIVE 此互斥量类型允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。递归互斥量维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁。所以,如果对一个递归互斥量加锁两次,然后解锁一次,那么这个互斥量将依然处于加锁状态,对它再次解锁以前不能释放该锁。
PTHREAD_MUTEX_DEFAULT 此互斥量类型可以提供默认特性和行为。操作系统在实现它的时候可以把这种类型自由地映射到其他互斥量类型中的一种。例如,Linux 3.2.0把这种类型映射为普通的互斥量类型,而FreeBSD 8.0则把它映射为错误检查互斥量类型。
这4种类型的行为如图12-5所示。“不占用时解锁”这一栏指的是,一个线程对被另一个线程加锁的互斥量进行解锁的情况。“在已解锁时解锁”这一栏指的是,当一个线程对已经解锁的互斥量进行解锁时将会发生什么,这通常是编码错误引起的。
图12-5 互斥量类型行为
可以用pthread_mutexattr_gettype函数得到互斥量类型属性,用pthread_mutexattr_settype函数修改互斥量类型属性。
#include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t*restrict attr,int*restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
两个函数的返回值: 若成功,返回0;否则,返回错误编号
回忆 11.6.6 节中学过的,互斥量用于保护与条件变量关联的条件。在阻塞线程之前,pthread_cond_wait和pthread_cond_timedwait函数释放与条件相关的互斥量。这就允许其他线程获取互斥量、改变条件、释放互斥量以及给条件变量发信号。既然改变条件时必须占有互斥量,使用递归互斥量就不是一个好主意。如果递归互斥量被多次加锁,然后用在调用pthread_cond_wait函数中,那么条件永远都不会得到满足,因为pthread_cond_wait所做的解锁操作并不能释放互斥量。
如果需要把现有的单线程接口放到多线程环境中,递归互斥量是非常有用的,但由于现有程序兼容性的限制,不能对函数接口进行修改。然而,使用递归锁可能很难处理,因此应该只在没有其他可行方案的时候才使用它们。
实例
图12-6描述了一种情况,在这种情况中递归互斥量看起来像是在解决并发问题。假设func1和 func2 是函数库中现有的函数,其接口不能改变,因为存在调用这两个接口的应用程序,而且应用程序不能改动。
图12-6 使用递归锁的一种可能情况
为了保持接口跟原来相同,我们把互斥量嵌入到了数据结构中,把这个数据结构的地址(x)作为参数传入。这种方案只有在为此数据结构提供分配函数时才可行,所以应用程序并不知道数据结构的大小(假设我们在其中增加互斥量之后必须扩大该数据结构的大小)。
如果在最早定义数据结构时,预留了足够的可填充字段,允许把某些填充字段替换成互斥量,这种方法也是可行的。不过遗憾的是,大多数程序员并不善于预测未来,所以这并不是普遍可行的实践。
如果func1和func2函数都必须操作这个结构,而且可能会有一个以上的线程同时访问该数据结构,那么 func1 和 func2 必须在操作数据以前对互斥量加锁。如果 func1 必须调用func2,这时如果互斥量不是递归类型的,那么就会出现死锁。如果能在调用 func2 之前释放互斥量,在 func2 返回后重新获取互斥量,那么就可以避免使用递归互斥量,但这也给其他的线程提供了机会,其他的线程可以在 func1 执行期间抓住互斥量的控制,修改这个数据结构。这也许是不可接受的,当然具体的情况要取决于互斥量试图提供什么样的保护。
图12-7显示了这种情况下使用递归互斥量的一种替代方法。通过提供func2函数的私有版本,称之为func2_locked函数,可以保持func1和func2函数接口不变,而且避免使用递归互斥量。要调用 func2_locked 函数,必须占有嵌入在数据结构中的互斥量,这个数据结构的地址是作为参数传入的。func2_locked的函数体包含func2的副本,func2现在只是获取互斥量,调用func2_locked,然后释放互斥量。
图12-7 避免使用递归锁的一种可能情况
如果并不一定要保持库函数接口不变,就可以在每个函数中增加第二个参数表明这个结构是否被调用者锁定。但是,如果可以的话,保持接口不变通常是更好的选择,可以避免实现过程中人为加入的东西对原有系统产生不良影响。
提供加锁和不加锁版本的函数,这样的策略在简单的情况下通常是可行的。在更加复杂的情况下,比如,库需要调用库以外的函数,而且可能会再次回调库中的函数时,就需要依赖递归锁。
实例
图12-8中的程序解释了有必要使用递归互斥量的另一种情况。这里,有一个“超时”(timeout)函数,它允许安排另一个函数在未来的某个时间运行。假设线程并不是很昂贵的资源,就可以为每个挂起的超时函数创建一个线程。线程在时间未到时将一直等待,时间到了以后再调用请求的函数。
图12-8 使用递归互斥量
如果我们不能创建线程,或者安排函数运行的时间已过,这时问题就出现了。在这些情况下,我们只需在当前上下文中调用之前请求运行的函数。因为函数要获取的锁和我们现在占有的锁是同一个,所以除非该锁是递归的,否则就会出现死锁。
在图12-4中我们使用makethread函数以分离状态创建线程。因为传递给timeout函数的func函数参数将在未来运行,所以我们不希望一直空等线程结束。
可以调用sleep等待超时到期,但它提供的时间粒度是秒级的。如果希望等待的时间不是整数秒,就需要用nanosleep或者clock_nanosleep函数,它们两个提供了更高精度的休眠时间。
在未定义CLOCK_REALTIME的系统中,我们根据nanosleep定义clock_nanosleep。然而,FreeBSD 8.0 定义这个符号支持 clock_gettime 和 clock_settime,但并不支持clock_nanosleep。(只有Linux 3.2.0和Solaris 10目前支持clock_nanosleep。)
另外,在未定义CLOCK_REALTIME的系统中,我们提供了我们自己的clock_gettime实现,该实现调用了gettimeofday并把微妙转换成纳秒。
timeout的调用者需要占有互斥量来检查条件,并且把retry函数安排为原子操作。retry函数试图对同一个互斥量进行加锁。除非互斥量是递归的,否则,如果 timeout 函数直接调用retry,会导致死锁。
读写锁与互斥量类似,也是有属性的。可以用 pthread_rwlockattr_init 初始化pthread_rwlockattr_t结构,用pthread_rwlockattr_destroy反初始化该结构。
#include <pthread.h>
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
读写锁支持的唯一属性是进程共享属性。它与互斥量的进程共享属性是相同的。就像互斥量的进程共享属性一样,有一对函数用于读取和设置读写锁的进程共享属性。
#include <pthread.h>
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *
restrict attr,
int *restrict pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr,
int pshared);
两个函数的返回值:若成功,返回0;否则,返回错误编号
虽然POSIX只定义了一个读写锁属性,但不同平台的实现可以自由地定义额外的、非标准的属性。
Single UNIX Specification目前定义了条件变量的两个属性:进程共享属性和时钟属性。与其他的属性对象一样,有一对函数用于初始化和反初始化条件变量属性。
#include <pthread.h>
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
与其他的同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用。要获取进程共享属性的当前值,可以用 pthread_condattr_getpshared函数。设置该值可以用pthread_condattr_setpshared函数。
#include <pthread.h>
int pthread_condattr_getpshared(const pthread_condattr_t *
restrict attr,
int *restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t *attr,
int pshared);
两个函数的返回值:若成功,返回0;否则,返回错误编号
时钟属性控制计算pthread_cond_timedwait函数的超时参数(tsptr)时采用的是哪个时钟。合法值取自图 6-8 中列出的时钟 ID。可以使用 pthread_condattr_getclock 函数获取可被用于pthread_cond_timedwait 函数的时钟 ID,在使用 pthread_cond_timedwait 函数前需要用pthread_condattr_t对象对条件变量进行初始化。可以用pthread_condattr_setclock函数对时钟ID进行修改。
#include <pthread.h>
int pthread_condattr_getclock(const pthread_condattr_t *
restrict attr,
clockid_t *restrict clock_id);
int pthread_condattr_setclock(pthread_condattr_t *attr,
clockid_t clock_id);
两个函数的返回值:若成功,返回0;否则,返回错误编号
奇怪的是,Single UNIX Specification并没有为其他有超时等待函数的属性对象定义时钟属性。
屏障也有属性。可以使用pthread_barrierattr_init函数对屏障属性对象进行初始化,用pthread_barrierattr_destroy函数对屏障属性对象进行反初始化。
#include <pthread.h>
int pthread_barrierattr_init(pthread_barrierattr_t *attr);
int pthread_barrierattr_destroy(pthread_barrierattr_t *attr);
两个函数的返回值:若成功,返回0;否则,返回错误编号
目前定义的屏障属性只有进程共享属性,它控制着屏障是可以被多进程的线程使用,还是只能被初始化屏障的进程内的多线程使用。与其他属性对象一样,有一个获取属性值的函数(pthread_barrierattr_getpshared)和一个设置属性值的函数(pthread_barrierattr_ setpshared)。
#include <pthread.h>
int pthread_barrierattr_getpshared(const pthread_barrierattr_t *
restrict attr,
int *restrict pshared);
int pthread_barrierattr_setpshared(pthread_barrierattr_t *attr,
int pshared);
两个函数的返回值:若成功,返回0;否则,返回错误编号
进程共享属性的值可以是 PTHREAD_PROCESS_SHARED(多进程中的多个线程可用),也可以是PTHREAD_PROCESS_PRIVATE(只有初始化屏障的那个进程内的多个线程可用)。
10.6节讨论了可重入函数和信号处理程序。线程在遇到重入问题时与信号处理程序是类似的。在这两种情况下,多个控制线程在相同的时间有可能调用相同的函数。
如果一个函数在相同的时间点可以被多个线程安全地调用,就称该函数是线程安全的。在Single UNIX Specification中定义的所有函数中,除了图12-9中列出的函数,其他函数都保证是线程安全的。另外,ctermid和tmpnam函数在参数传入空指针时并不能保证是线程安全的。类似地,如果参数mbstate_t传入的是空指针,也不能保证wcrtomb和wcsrtombs函数是线程安全的。
支持线程安全函数的操作系统实现会在<unistd.h>中定义符号_POSIX_THREAD_SAFE_FUNCTIONS。应用程序也可以在sysconf函数中传入_SC_THREAD_SAFE_FUNCTIONS参数在运行时检查是否支持线程安全函数。在SUSv4之前,要求所有遵循XSI的实现都必须支持线程安全函数,但是在SUSv4中,线程安全函数支持这个需求已经要求具体实现考虑遵循POSIX。
操作系统实现支持线程安全函数这个特性时,对POSIX.1中的一些非线程安全函数,它会提供可替代的线程安全版本。图12-10列出了这些函数的线程安全版本。这些函数的命名方式与它们的非线程安全版本的名字相似,只不过在名字最后加了_r,表明这些版本是可重入的。很多函数并不是线程安全的,因为它们返回的数据存放在静态的内存缓冲区中。通过修改接口,要求调用者自己提供缓冲区可以使函数变为线程安全。
图12-9 POSIX.1中不能保证线程安全的函数
图12-10 替代的线程安全函数
如果一个函数对多个线程来说是可重入的,就说这个函数就是线程安全的。但这并不能说明对信号处理程序来说该函数也是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就可以说函数是异步信号安全的。我们在10.6节中讨论可重入函数时,图10-4中的函数就是异步信号安全函数。
除了图12-10中列出的函数,POSIX.1还提供了以线程安全的方式管理FILE对象的方法。可以使用flockfile和ftrylockfile获取给定FILE对象关联的锁。这个锁是递归的:当你占有这把锁的时候,还是可以再次获取该锁,而且不会导致死锁。虽然这种锁的具体实现并无规定,但要求所有操作 FILE 对象的标准 I/O 例程的动作行为必须看起来就像它们内部调用了flockfile和funlockfile。
#include <stdio.h>
int ftrylockfile(FILE *fp);
返回值:若成功,返回0;若不能获取锁,返回非0数值
void flockfile(FILE *fp);
void funlockfile(FILE *fp);
虽然标准的I/O例程可能从它们各自的内部数据结构的角度出发,是以线程安全的方式实现的,但有时把锁开放给应用程序也是非常有用的。这允许应用程序把多个对标准I/O函数的调用组合成原子序列。当然,在处理多个FILE对象时,需要注意潜在的死锁,需要对所有的锁仔细地排序。
如果标准I/O例程都获取它们各自的锁,那么在做一次一个字符的I/O时就会出现严重的性能下降。在这种情况下,需要对每一个字符的读写操作进行获取锁和释放锁的动作。为了避免这种开销,出现了不加锁版本的基于字符的标准I/O例程。
#include <stdio.h>
int getchar_unlocked(void);
int getc_unlocked(FILE *fp);
两个函数的返回值: 若成功,返回下一个字符;若遇到文件尾或者出错,返回EOF
int putchar_unlocked(int c);
int putc_unlocked(int c, FILE *fp);
两个函数的返回值:若成功,返回c;若出错,返回EOF
除非被flockfile(或ftrylockfile)和funlockfile的调用包围,否则尽量不要调用这4个函数,因为它们会导致不可预期的结果(比如,由于多个控制线程非同步访问数据引起的种种问题)。
一旦对FILE对象进行加锁,就可以在释放锁之前对这些函数进行多次调用。这样就可以在多次的数据读写上分摊总的加解锁的开销。
实例
图12-11显示了getenv(见7.9节)的一个可能实现。这个版本不是可重入的。如果两个线程同时调用这个函数,就会看到不一致的结果,因为所有调用getenv的线程返回的字符串都存储在同一个静态缓冲区中。
图12-11 getenv的非可重入版本
图12-12给出了getenv的可重入的版本。这个版本叫做getenv_r。它使用pthread_once函数来确保不管多少线程同时竞争调用getenv_r,每个进程只调用thread_init函数一次。12.6节会详细描述pthread_once函数。
图12-12 getenv的可重入(线程安全)版本
要使getenv_r可重入,需要改变接口,调用者必须提供它自己的缓冲区,这样每个线程可以使用各自不同的缓冲区避免其他线程的干扰。但是,注意,要想使getenv_r成为线程安全的,这样做还不够,需要在搜索请求的字符时保护环境不被修改。可以使用互斥量,通过getenv_r和putenv函数对环境列表的访问进行串行化。
可以使用读写锁,从而允许对getenv_r进行多次并发访问,但增加的并发性可能并不会在很大程度上改善程序的性能,这里面有两个原因:第一,环境列表通常并不会很长,所以扫描列表时并不需要长时间地占有互斥量;第二,对getenv和putenv的调用也不是频繁发生的,所以改善它们的性能并不会对程序的整体性能产生很大的影响。
即使可以把getenv_r变成线程安全的,这也不意味着它对信号处理程序是可重入的。如果使用的是非递归的互斥量,线程从信号处理程序中调用 getenv_r 就有可能出现死锁。如果信号处理程序在线程执行getenv_r时中断了该线程,这时我们已经占有加锁的env_mutex,这样其他线程试图对这个互斥量的加锁就会被阻塞,最终导致线程进入死锁状态。所以,必须使用递归互斥量阻止其他线程改变我们正需要的数据结构,还要阻止来自信号处理程序的死锁。问题是pthread函数并不保证是异步信号安全的,所以不能把pthread函数用于其他函数,让该函数成为异步信号安全的。
线程特定数据(thread-specific data),也称为线程私有数据(thread-private data),是存储和查询某个特定线程相关数据的一种机制。我们把这种数据称为线程特定数据或线程私有数据的原因是,我们希望每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题。
线程模型促进了进程中数据和属性的共享,许多人在设计线程模型时会遇到各种麻烦。那么为什么有人想在这样的模型中促进阻止共享的接口呢?这其中有两个原因。
第一,有时候需要维护基于每线程(per-thread)的数据。因为线程ID并不能保证是小而连续的整数,所以就不能简单地分配一个每线程数据数组,用线程ID作为数组的索引。即使线程ID确实是小而连续的整数,我们可能还希望有一些额外的保护,防止某个线程的数据与其他线程的数据相混淆。
采用线程私有数据的第二个原因是,它提供了让基于进程的接口适应多线程环境的机制。一个很明显的实例就是errno。回忆1.7节中对errno 的讨论。以前的接口(线程出现以前)把errno 定义为进程上下文中全局可访问的整数。系统调用和库例程在调用或执行失败时设置errno,把它作为操作失败时的附属结果。为了让线程也能够使用那些原本基于进程的系统调用和库例程,errno被重新定义为线程私有数据。这样,一个线程做了重置errno的操作也不会影响进程中其他线程的errno值。
我们知道一个进程中的所有线程都可以访问这个进程的整个地址空间。除了使用寄存器以外,一个线程没有办法阻止另一个线程访问它的数据。线程特定数据也不例外。虽然底层的实现部分并不能阻止这种访问能力,但管理线程特定数据的函数可以提高线程间的数据独立性,使得线程不太容易访问到其他线程的线程特定数据。
在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。使用pthread_key_create创建一个键。
#include <pthread.h>
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
返回值:若成功,返回0;否则,返回错误编号
创建的键存储在keyp指向的内存单元中,这个键可以被进程中的所有线程使用,但每个线程把这个键与不同的线程特定数据地址进行关联。创建新键时,每个线程的数据地址设为空值。
除了创建键以外,pthread_key_create 可以为该键关联一个可选择的析构函数。当这个线程退出时,如果数据地址已经被置为非空值,那么析构函数就会被调用,它唯一的参数就是该数据地址。如果传入的析构函数为空,就表明没有析构函数与这个键关联。当线程调用pthread_exit或者线程执行返回,正常退出时,析构函数就会被调用。同样,线程取消时,只有在最后的清理处理程序返回之后,析构函数才会被调用。如果线程调用了exit、_exit、_Exit或abort,或者出现其他非正常的退出时,就不会调用析构函数。
线程通常使用malloc为线程特定数据分配内存。析构函数通常释放已分配的内存。如果线程在没有释放内存之前就退出了,那么这块内存就会丢失,即线程所属进程就出现了内存泄漏。
线程可以为线程特定数据分配多个键,每个键都可以有一个析构函数与它关联。每个键的析构函数可以互不相同,当然所有键也可以使用相同的析构函数。每个操作系统实现可以对进程可分配的键的数量进行限制(回忆一下图12-1中的PTHREAD_KEYS_MAX)。
线程退出时,线程特定数据的析构函数将按照操作系统实现中定义的顺序被调用。析构函数可能会调用另一个函数,该函数可能会创建新的线程特定数据,并且把这个数据与当前的键关联起来。当所有的析构函数都调用完成以后,系统会检查是否还有非空的线程特定数据值与键关联,如果有的话,再次调用析构函数。这个过程将会一直重复直到线程所有的键都为空线程特定数据值,或者已经做了PTHREAD_DESTRUCTOR_ITERATIONS(见图12-1)中定义的最大次数的尝试。
对所有的线程,我们都可以通过调用pthread_key_delete来取消键与线程特定数据值之间的关联关系。
#include <pthread.h>
int pthread_key_delete(pthread_key_t key);
返回值:若成功,返回0;否则,返回错误编号
注意,调用pthread_key_delete并不会激活与键关联的析构函数。要释放任何与键关联的线程特定数据值的内存,需要在应用程序中采取额外的步骤。
需要确保分配的键并不会由于在初始化阶段的竞争而发生变动。下面的代码会导致两个线程都调用pthread_key_create。
void destructor(void *);
pthread_key_t key;
int init_done = 0;
int
threadfunc(void *arg)
{
if (!init_done) {
init_done = 1;
err = pthread_key_create(&key, destructor);
}
}
有些线程可能看到一个键值,而其他的线程看到的可能是另一个不同的键值,这取决于系统是如何调度线程的,解决这种竞争的办法是使用pthread_once。
#include <pthread.h>
pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));
返回值:若成功,返回0;否则,返回错误编号
initflag 必须是一个非本地变量(如全局变量或静态变量),而且必须初始化为 PTHREAD_ONCE_INIT。
如果每个线程都调用pthread_once,系统就能保证初始化例程initfn只被调用一次,即系统首次调用pthread_once时。创建键时避免出现冲突的一个正确方法如下:
void destructor(void *);
pthread_key_t key;
pthread_once_t init_done = PTHREAD_ONCE_INIT;
void
thread_init(void)
{
err = pthread_key_create(&key, destructor);
}
int
threadfunc(void *arg)
{
}
pthread_once(&init_done, thread_init);
键一旦创建以后,就可以通过调用pthread_setspecific函数把键和线程特定数据关联起来。可以通过pthread_getspecific函数获得线程特定数据的地址。
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);
返回值:线程特定数据值;若没有值与该键关联,返回NULL
int pthread_setspecific(pthread_key_t key, const void *value);
返回值:若成功,返回0;否则,返回错误编号
如果没有线程特定数据值与键关联,pthread_getspecific将返回一个空指针,我们可以用这个返回值来确定是否需要调用pthread_setspecific。
实例
图12-11给出了getenv的假设实现。接着又给出了一个新的接口,提供的功能相同,不过它是线程安全的(见图12-12)。但是如果不修改应用程序,直接使用新的接口会出现什么问题呢?这种情况下,可以使用线程特定数据来维护每个线程的数据缓冲区副本,用于存放各自的返回字符串,如图12-13所示。
图12-13 线程安全的getenv的兼容版本
我们使用 pthread_once 来确保只为我们将使用的线程特定数据创建一个键。如果pthread_getspecific 返回的是空指针,就需要先分配内存缓冲区,然后再把键与该内存缓冲区关联。否则,如果返回的不是空指针,就使用pthread_getspecific返回的内存缓冲区。对析构函数,使用free来释放之前由malloc分配的内存。只有当线程特定数据值为非空时,析构函数才会被调用。
注意,虽然这个版本的getenv是线程安全的,但它并不是异步信号安全的。对信号处理程序而言,即使使用递归的互斥量,这个版本的 getenv 也不可能是可重入的,因为它调用了malloc,而malloc函数本身并不是异步信号安全的。
有两个线程属性并没有包含在pthread_attr_t结构中,它们是可取消状态和可取消类型。这两个属性影响着线程在响应pthread_cancel函数调用时所呈现的行为(见11.5节)。
可取消状态属性可以是PTHREAD_CANCEL_ENABLE,也可以是PTHREAD_CANCEL_DISABLE。线程可以通过调用pthread_setcancelstate修改它的可取消状态。
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
返回值:若成功,返回0;否则,返回错误编号
pthread_setcancelstate把当前的可取消状态设置为state,把原来的可取消状态存储在由oldstate指向的内存单元,这两步是一个原子操作。
回忆11.5节,pthread_cancel调用并不等待线程终止。在默认情况下,线程在取消请求发出以后还是继续运行,直到线程到达某个取消点。取消点是线程检查它是否被取消的一个位置,如果取消了,则按照请求行事。POSIX.1保证在线程调用图12-14中列出的任何函数时,取消点都会出现。
图12-14 POSIX.1定义的取消点
线程启动时默认的可取消状态是 PTHREAD_CANCEL_ENABLE。当状态设为 PTHREAD_CANCEL_DISABLE时,对pthread_cancel的调用并不会杀死线程。相反,取消请求对这个线程来说还处于挂起状态,当取消状态再次变为PTHREAD_CANCEL_ENABLE时,线程将在下一个取消点上对所有挂起的取消请求进行处理。
除了图12-14中列出的函数,POSIX.1还指定了图12-15中列出的函数作为可选的取消点。
图12-15中列出的有些函数并没有在本书中进一步讨论,例如,处理消息分类和宽字符集的函数。
如果应用程序在很长的一段时间内都不会调用图12-14或图12-15中的函数(如数学计算领域的应用程序),那么你可以调用pthread_testcancel函数在程序中添加自己的取消点。
#include <pthread.h>
void pthread_testcancel(void);
调用pthread_testcancel时,如果有某个取消请求正处于挂起状态,而且取消并没有置为无效,那么线程就会被取消。但是,如果取消被置为无效,pthread_testcancel调用就没有任何效果了。
图12-15 POSIX.1定义的可选取消点
我们所描述的默认的取消类型也称为推迟取消。调用pthread_cancel以后,在线程到达取消点之前,并不会出现真正的取消。可以通过调用pthread_setcanceltype来修改取消类型。
#include <pthread.h>
int pthread_setcanceltype(int type, int *oldtype);
返回值:若成功,返回0;否则,返回错误编号
pthread_setcanceltype函数把取消类型设置为type(类型参数可以是PTHREADCANCEL_DEFERRED,也可以是 PTHREAD_CANCEL_ASYNCHRONOUS),把原来的取消类型返回到oldtype指向的整型单元。
异步取消与推迟取消不同,因为使用异步取消时,线程可以在任意时间撤消,不是非得遇到取消点才能被取消。
即使是在基于进程的编程范型中,信号的处理有时候也是很复杂的。把线程引入编程范型,就使信号的处理变得更加复杂。
每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。这意味着单个线程可以阻止某些信号,但当某个线程修改了与某个给定信号相关的处理行为以后,所有的线程都必须共享这个处理行为的改变。这样,如果一个线程选择忽略某个给定信号,那么另一个线程就可以通过以下两种方式撤消上述线程的信号选择:恢复信号的默认处理行为,或者为信号设置一个新的信号处理程序。
进程中的信号是递送到单个线程的。如果一个信号与硬件故障相关,那么该信号一般会被发送到引起该事件的线程中去,而其他的信号则被发送到任意一个线程。
10.12 节讨论了进程如何使用 sigprocmask 函数来阻止信号发送。然而,sigprocmask的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask。
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set,
sigset_t *restrict oset);
返回值:若成功,返回0;否则,返回错误编号
pthread_sigmask函数与sigprocmask函数基本相同,不过pthread_sigmask工作在线程中,而且失败时返回错误码,不再像sigprocmask中那样设置errno并返回−1。set参数包含线程用于修改信号屏蔽字的信号集。how参数可以取下列3个值之一:SIG_BLOCK,把信号集添加到线程信号屏蔽字中,SIG_SETMASK,用信号集替换线程的信号屏蔽字;SIG_UNBLOCK,从线程信号屏蔽字中移除信号集。如果oset参数不为空,线程之前的信号屏蔽字就存储在它指向的sigset_t结构中。线程可以通过把set参数设置为NULL,并把oset参数设置为sigset_t结构的地址,来获取当前的信号屏蔽字。这种情况中的how参数会被忽略。
线程可以通过调用sigwait等待一个或多个信号的出现。
#include <signal.h>
int sigwait(const sigset_t *restrict set, int *restrict signop);
返回值:若成功,返回0;否则,返回错误编号
set参数指定了线程等待的信号集。返回时,signop指向的整数将包含发送信号的数量。
如果信号集中的某个信号在sigwait调用的时候处于挂起状态,那么sigwait将无阻塞地返回。在返回之前,sigwait 将从进程中移除那些处于挂起等待状态的信号。如果具体实现支持排队信号,并且信号的多个实例被挂起,那么sigwait将会移除该信号的一个实例,其他的实例还要继续排队。
为了避免错误行为发生,线程在调用 sigwait 之前,必须阻塞那些它正在等待的信号。sigwait函数会原子地取消信号集的阻塞状态,直到有新的信号被递送。在返回之前,sigwait将恢复线程的信号屏蔽字。如果信号在 sigwait 被调用的时候没有被阻塞,那么在线程完成对sigwait的调用之前会出现一个时间窗,在这个时间窗中,信号就可以被发送给线程。
使用sigwait的好处在于它可以简化信号处理,允许把异步产生的信号用同步的方式处理。为了防止信号中断线程,可以把信号加到每个线程的信号屏蔽字中。然后可以安排专用线程处理信号。这些专用线程可以进行函数调用,不需要担心在信号处理程序中调用哪些函数是安全的,因为这些函数调用来自正常的线程上下文,而非会中断线程正常执行的传统信号处理程序。
如果多个线程在 sigwait 的调用中因等待同一个信号而阻塞,那么在信号递送的时候,就只有一个线程可以从 sigwait 中返回。如果一个信号被捕获(例如进程通过使用 sigaction建立了一个信号处理程序),而且一个线程正在sigwait调用中等待同一信号,那么这时将由操作系统实现来决定以何种方式递送信号。操作系统实现可以让 sigwait 返回,也可以激活信号处理程序,但这两种情况不会同时发生。
要把信号发送给进程,可以调用kill(见10.9节)。要把信号发送给线程,可以调用pthread_kill。
#include <signal.h>
int pthread_kill(pthread_t thread, int signo);
返回值:若成功,返回0;否则,返回错误编号
可以传一个0值的signo来检查线程是否存在。如果信号的默认处理动作是终止该进程,那么把信号传递给某个线程仍然会杀死整个进程。
注意,闹钟定时器是进程资源,并且所有的线程共享相同的闹钟。所以,进程中的多个线程不可能互不干扰(或互不合作)地使用闹钟定时器(这是习题12.6的内容)。
实例
回忆图10-23所示的程序,我们等待信号处理程序设置标志表明主程序应该退出。唯一可运行的控制线程就是主线程和信号处理程序,所以阻塞信号足以避免错失标志修改。在线程中,我们需要使用互斥量来保护标志,如图12-16中的程序所示。
图12-16 同步信号处理
我们不用依赖信号处理程序中断主控线程,有专门的独立控制线程进行信号处理。在互斥量的保护下改动quitflag的值,这样主控线程不会在调用pthread_cond_signal时错失唤醒调用。在主控线程中使用相同的互斥量来检查标志的值,并且原子地释放互斥量,等待条件的发生。
注意,在主线程开始时阻塞 SIGINT 和 SIGQUIT。当创建线程进行信号处理时,新建线程继承了现有的信号屏蔽字。因为 sigwait 会解除信号的阻塞状态,所有只有一个线程可以用于信号的接收。这可以使我们对主线程进行编码时不必担心来自这些信号的中断。
运行这个程序可以得到与图10-23类似的输出结果:
$ ./a.out
^? 输入中断字符
^? 再次输入中断字符
interrupt
interrupt
^? 再次输入中断字符
^\$ 现在用退出符终止
interrupt
当线程调用fork时,就为子进程创建了整个进程地址空间的副本。回忆8.3节中讨论的写时复制,子进程与父进程是完全不同的进程,只要两者都没有对内存内容做出改动,父进程和子进程之间还可以共享内存页的副本。
子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在fork返回以后,如果紧接着不是马上调用exec的话,就需要清理锁状态。
在子进程内部,只存在一个线程,它是由父进程中调用fork的线程的副本构成的。如果父进程中的线程占有锁,子进程将同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁、需要释放哪些锁。
如果子进程从fork返回以后马上调用其中一个exec函数,就可以避免这样的问题。这种情况下,旧的地址空间就被丢弃,所以锁的状态无关紧要。但如果子进程需要继续做处理工作的话,这种策略就行不通,还需要使用其他的策略。
在多线程的进程中,为了避免不一致状态的问题,POSIX.1声明,在fork返回和子进程调用其中一个exec函数之间,子进程只能调用异步信号安全的函数。这就限制了在调用exec之前子进程能做什么,但不涉及子进程中锁状态的问题。
要清除锁状态,可以通过调用pthread_atfork函数建立fork处理程序。
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void),
void (*child)(void));
返回值:若成功,返回0;否则,返回错误编号
用pthread_atfork函数最多可以安装3个帮助清理锁的函数。prepare fork处理程序由父进程在fork创建子进程前调用。这个fork处理程序的任务是获取父进程定义的所有锁。parent fork处理程序是在fork 创建子进程以后、返回之前在父进程上下文中调用的。这个fork处理程序的任务是对prepare fork处理程序获取的所有锁进行解锁。child fork处理程序在fork返回之前在子进程上下文中调用。与parent fork处理程序一样,child fork处理程序也必须释放prepare fork处理程序获取的所有锁。
注意,不会出现加锁一次解锁两次的情况,虽然看起来也许会出现。子进程地址空间在创建时就得到了父进程定义的所有锁的副本。因为prepare fork处理程序获取了所有的锁,父进程中的内存和子进程中的内存内容在开始的时候是相同的。当父进程和子进程对它们锁的副本进程解锁的时候,新的内存是分配给子进程的,父进程的内存内容是复制到子进程的内存中(写时复制),所以我们就会陷入这样的假象,看起来父进程对它所有的锁的副本进行了加锁,子进程对它所有的锁的副本进行了加锁。父进程和子进程对在不同内存单元的重复的锁都进行了解锁操作,就好像出现了下列事件序列。
(1)父进程获取所有的锁。
(2)子进程获取所有的锁。
(3)父进程释放它的锁。
(4)子进程释放它的锁。
可以多次调用pthread_atfork函数从而设置多套fork处理程序。如果不需要使用其中某个处理程序,可以给特定的处理程序参数传入空指针,它就不会起任何作用了。使用多个fork处理程序时,处理程序的调用顺序并不相同。parent和child fork处理程序是以它们注册时的顺序进行调用的,而prepare fork 处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。
例如,假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次是A在B之前,模块B必须在模块A之前设置它的fork处理程序。当父进程调用fork时,就会执行以下的步骤,假设子进程在父进程之前运行:
(1)调用模块A的prepare fork处理程序获取模块A的所有锁。
(2)调用模块B的prepare fork处理程序获取模块B的所有锁。
(3)创建子进程。
(4)调用模块B中的child fork处理程序释放子进程中模块B的所有锁。
(5)调用模块A中的child fork处理程序释放子进程中模块A的所有锁。
(6)fork函数返回到子进程。
(7)调用模块B中的parent fork处理程序释放父进程中模块B的所有锁。
(8)调用模块A中的parent fork处理程序来释放父进程中模块A的所有锁。
(9)fork函数返回到父进程。
如果fork处理程序是用来清理锁状态的,那么又由谁来负责清理条件变量的状态呢?在有些操作系统的实现中,条件变量可能并不需要做任何清理。但是有些操作系统实现把锁作为条件变量实现的一部分,这种情况下的条件变量就需要清理。问题是目前不存在允许清理锁状态的接口。如果锁是嵌入到条件变量的数据结构中的,那么在调用fork之后就不能使用条件变量,因为还没有可移植的方法对锁进行状态清理。另外,如果操作系统的实现是使用全局锁保护进程中所有的条件变量数据结构,那么操作系统实现本身可以在fork库例程中做清理锁的工作,但是应用程序不应该依赖操作系统实现中类似这样的细节。
实例
图12-17中的程序描述了如何使用pthread_atfork和fork处理程序。
图12-17 pthread_atfork实例
图12-17中定义了两个互斥量,lock1和lock2,prepare fork处理程序获取这两把锁,child fork处理程序在子进程上下文中释放它们,parent fork处理程序在父进程上下文中释放它们。
运行该程序,得到如下输出:
$ ./a.out
thread started...
parent about to fork...
preparing locks...
child unlocking locks...
child returned from fork
parent unlocking locks...
parent returned from fork
可以看到,prepare fork处理程序在调用fork以后运行,child fork处理程序在fork调用返回到子进程之前运行,parent fork处理程序在fork调用返回给父进程之前运行。
虽然pthread_atfork机制的意图是使fork之后的锁状态保持一致,但它还是存在一些不足之处,只能在有限情况下可用。
•没有很好的办法对较复杂的同步对象(如条件变量或者屏障)进行状态的重新初始化。
•某些错误检查的互斥量实现在child fork处理程序试图对被父进程加锁的互斥量进行解锁时会产生错误。
•递归互斥量不能在child fork处理程序中清理,因为没有办法确定该互斥量被加锁的次数。
•如果子进程只允许调用异步信号安全的函数,child fork处理程序就不可能清理同步对象,因为用于操作清理的所有函数都不是异步信号安全的。实际的问题是同步对象在某个线程调用fork时可能处于中间状态,除非同步对象处于一致状态,否则无法被清理。
•如果应用程序在信号处理程序中调用了fork(这是合法的,因为fork本身是异步信号安全的),pthread_atfork注册的fork处理程序只能调用异步信号安全的函数,否则结果将是未定义的。
3.11节介绍了pread和pwrite函数。这些函数在多线程环境下是非常有用的,因为进程中的所有线程共享相同的文件描述符。
考虑两个线程,在同一时间对同一个文件描述符进行读写操作。
线程A 线程B
lseek(fd, 300, SEEK_SET); lseek(fd, 700, SEEK_SET);
read(fd, buf1, 100); read(fd, buf2, 100);
如果线程A执行lseek然后线程B在线程A调用read之前调用lseek,那么两个线程最终会读取同一条记录。很显然这不是我们希望的。
为了解决这个问题,可以使用pread,使偏移量的设定和数据的读取成为一个原子操作。
线程A 线程B
pread(fd, buf1, 100, 300); pread(fd, buf2, 100, 700);
使用pread可以确保线程A读取偏移量为300的记录,而线程B读取偏移量为700的记录。可以使用pwrite来解决并发线程对同一文件进行写操作的问题。
在UNIX系统中,线程提供了分解并发任务的另一种模型。线程促进了独立控制线程之间的共享,但也出现了它特有的同步问题。本章中,我们了解了如何调整线程和它们的同步原语,讨论了线程的可重入性,还学习了线程如何与其他面向进程的系统调用进行交互。
12.1 在Linux系统中运行图12-17中的程序,但把输出结果重定向到一个文件中,并解释结果。
12.2 实现 putenv_r,即 putenv 的可重入版本。确保你的实现既是线程安全的,也是异步信号安全的。
12.3 是否可以通过在getenv函数开始的时候阻塞信号,并在getenv函数返回之前恢复原来的信号屏蔽字这种方法,让图12-13中的getenv函数变成异步信号安全的?解释其原因。
12.4 写一个程序练习图12-13中的getenv版本,在FreeBSD上编译并运行程序,会出现什么结果?解释其原因。
12.5 假设可以在一个程序中创建多个线程执行不同的任务,为什么还是有可能会需要用fork?解释其原因。
12.6 重新实现图10-29中的程序,在不使用nanosleep或clock_nanosleep的情况下使它成为线程安全的。
12.7 调用fork以后,是否可以通过首先用pthread_cond_destroy销毁条件变量,然后用pthread_cond_init 初始化条件变量这种方法安全地在子进程中对条件变量进行重新初始化?
12.8 图12-8中的timeout函数可以大大简化,解释其原因。
守护进程(daemon)是生存期长的一种进程。它们常常在系统引导装入时启动,仅在系统关闭时才终止。因为它们没有控制终端,所以说它们是在后台运行的。UNIX系统有很多守护进程,它们执行日常事务活动。
本章将说明守护进程结构,以及如何编写守护进程程序。因为守护进程没有控制终端,我们需要了解在出现问题时,守护进程如何报告出错情况。
有关守护进程这一术语被应用于计算机系统的历史背景,详见Raymond[1996]。
让我们先来看一些常用的系统守护进程,以及它们是怎样和第9章中叙述的进程组、控制终端和会话这三个概念相关联的。ps(1)命令打印系统中各个进程的状态。该命令有多个选项,有关细节请参考系统手册。为了解本节讨论中所需的信息,我们在基于BSD的系统下执行:
ps -axj
选项-a显示由其他用户所拥有的进程的状态,-x显示没有控制终端的进程状态,-j显示与作业有关的信息:会话ID、进程组ID、控制终端以及终端进程组ID。在基于System V的系统中,与此相类似的命令是ps -efj(为了提高安全性,某些UNIX系统不允许用户使用ps命令查看不属于自己的进程)。ps的输出大致是:
UID PID PPID PGID SID TTY COMD
root 1 0 1 1 ? /sbin/init
root 2 0 0 0 ? [kthreadd]
root 3 2 0 0 ? [ksoftirqd/0]
root 6 2 0 0 ? [migration/0]
root 7 2 0 0 ? [watchdog/0]
root 21 2 0 0 ? [cpuset]
root 22 2 0 0 ? [khelper]
root 26 2 0 0 ? [sync_supers]
root 27 2 0 0 ? [bdi-default]
root 29 2 0 0 ? [kblockd]
root 35 2 0 0 ? [kswapd0]
root 49 2 0 0 ? [scsi_eh_0]
root 256 2 0 0 ? [jbd2/sda5-8]
root 26464 1 26464 26464 ? rpcbind -w
root 14596 2 0 0 ? [flush-8:0]
root 13047 2 0 0 ? [kworker/1:0]
root 8196 1 8196 8196 ? /usr/sbin/sshd -D
daemon 1068 1 1068 1068 ? atd
root 1067 1 1067 1067 ? cron
root 1037 1 1037 1037 ? /usr/sbin/inetd
root 906 1 906 906 ? /usr/sbin/cupsd -F
syslog 847 1 843 843 ? rsyslogd -c5
root 257 2 0 0 ? [ext4-dio-unwrit]
statd 28490 1 28490 28490 ? rpc.statd -L
root 28561 1 28561 28561 ? rpc.idmapd
root 28554 2 0 0 ? [nfsiod]
root 28553 2 0 0 ? [rpciod]
root 28775 1 28775 28775 ? /usr/sbin/rpc.mountd --manage-gids
root 28764 2 0 0 ? [nfsd]
root 28761 2 0 0 ? [lockd]
其中,已移去了一些我们不感兴趣的列,如累计CPU时间。按照顺序,各列标题的意义分别是用户ID、进程ID、父进程ID、进程组ID、会话ID、终端名称以及命令字符串。
此ps命令在支持会话ID的系统(Linux 3.2.0)上运行,9.5节的setsid函数中曾提及会话ID。简单地说,它就是会话首进程的进程ID。但是,一些基于BSD的系统,如Mac OS X 10.6.8,将打印与本进程所属进程组对应的session结构的地址(见9.11节),而非会话ID的地址。
系统进程依赖于操作系统实现。父进程ID 为0 的各进程通常是内核进程,它们作为系统引导装入过程的一部分而启动。(init是个例外,它是一个由内核在引导装入时启动的用户层次的命令。)内核进程是特殊的,通常存在于系统的整个生命期中。它们以超级用户特权运行,无控制终端,无命令行。的服务。rsyslogd守护进程可以被由管理员启用的将系统消息记入日志的任何程序使用。可以在一台
rpcbind守护进程提供将远程过程调用(Remote Procedure Call,RPC)程序号映射为网络端口号
在ps 的输出实例中,内核守护进程的名字出现在方括号中。该版本的 Linux使用一个名为kthreadd 的特殊内核进程来创建其他内核进程,所以 kthreadd 表现为其他内核进程的父进程。对于需要在进程上下文执行工作但却不被用户层进程上下文调用的每一个内核组件,通常有它自己的内核守护进程。例如,在Linux中:
• kswapd守护进程也称为内存换页守护进程。它支持虚拟内存子系统在经过一段时间后将脏页面慢慢地写回磁盘来回收这些页面。
• flush守护进程在可用内存达到设置的最小阈值时将脏页面冲洗至磁盘。它也定期地将脏页面冲洗回磁盘来减少在系统出现故障时发生的数据丢失。多个冲洗守护进程可以同时存在,每个写回的设备都有一个冲洗守护进程。输出实例中显示出一个名为flush-8:0的冲洗守护进程。从名字中可以看出,写回设备是通过主设备号(8)和副设备号(0)来识别的。
•sync_supers守护进程定期将文件系统元数据冲洗至磁盘。
•jbd守护进程帮助实现了ext4文件系统中的日志功能。
进程1通常是init(Mac OS X中是launchd),8.2节对此做过说明。它是一个系统守护进程,除了其他工作外,主要负责启动各运行层次特定的系统服务。这些服务通常是在它们自己拥有的守护进程的帮助下实现的。实际的控制台上打印这些消息,也可将它们写到一个文件中。(13.4节将对syslog设施进行说明。)
9.3节已谈到inetd守护进程。它侦听系统网络接口,以便取得来自网络的对各种网络服务进程的请求。nfsd、nfsiod、lockd、rpciod、rpc.idmapd、rpc.statd和rpc.mountd守护进程提供对网络文件系统(Network File System,NFS)的支持。注意,前4个是内核守护进程,后3个是用户级守护进程。
cron守护进程在定期安排的日期和时间执行命令。许多系统管理任务是通过cron每隔一段固定的时间就运行相关程序而得以实现的。atd守护进程与cron类似,它允许用户在指定的时间执行任务,但是每个任务它只执行一次,而非在定期安排的时间反复执行。cupsd 守护进程是个打印假脱机进程,它处理对系统提出的各个打印请求。sshd守护进程提供了安全的远程登录和执行设施。
注意,大多数守护进程都以超级用户(root)特权运行。所有的守护进程都没有控制终端,其终端名设置为问号。内核守护进程以无控制终端方式启动。用户层守护进程缺少控制终端可能是守护进程调用了setsid的结果。大多数用户层守护进程都是进程组的组长进程以及会话的首进程,而且是这些进程组和会话中的唯一进程(rsyslogd 是一个例外)。最后,应当引起注意的是用户层守护进程的父进程是init进程。
在编写守护进程程序时需遵循一些基本规则,以防止产生不必要的交互作用。下面先说明这些规则,然后给出一个按照这些规则编写的函数daemonize。
(1)首先要做的是调用umask将文件模式创建屏蔽字设置为一个已知值(通常是0)。由继承得来的文件模式创建屏蔽字可能会被设置为拒绝某些权限。如果守护进程要创建文件,那么它可能要设置特定的权限。例如,若守护进程要创建组可读、组可写的文件,继承的文件模式创建屏蔽字可能会屏蔽上述两种权限中的一种,而使其无法发挥作用。另一方面,如果守护进程调用的库函数创建了文件,那么将文件模式创建屏蔽字设置为一个限制性更强的值(如 007)可能会更明智,因为库函数可能不允许调用者通过一个显式的函数参数来设置权限。
(2)调用fork,然后使父进程exit。这样做实现了下面几点。第一,如果该守护进程是作为一条简单的shell命令启动的,那么父进程终止会让shell认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组 ID,但获得了一个新的进程 ID,这就保证了子进程不是一个进程组的组长进程。这是下面将要进行的setsid调用的先决条件。
(3)调用setsid创建一个新会话。然后执行9.5节中列出的3个步骤,使调用进程:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程,(c)没有控制终端。
在基于System V的系统中,有些人建议在此时再次调用fork,终止父进程,继续使用子进程中的守护进程。这就保证了该守护进程不是会话首进程,于是按照System V规则(见9.6节)可以防止它取得控制终端。为了避免取得控制终端的另一种方法是,无论何时打开一个终端设备,都一定要指定O_NOCTTY。
(4)将当前工作目录更改为根目录。从父进程处继承过来的当前工作目录可能在一个挂载的文件系统中。因为守护进程通常在系统再引导之前是一直存在的,所以如果守护进程的当前工作目录在一个挂载文件系统中,那么该文件系统就不能被卸载。
或者,某些守护进程还可能会把当前工作目录更改到某个指定位置,并在此位置进行它们的全部工作。例如,行式打印机假脱机守护进程就可能将其工作目录更改到它们的spool目录上。
(5)关闭不再需要的文件描述符。这使守护进程不再持有从其父进程继承来的任何文件描述符(父进程可能是 shell 进程,或某个其他进程)。可以使用 open_max 函数(见 2.17 节)或getrlimit函数(见7.11节)来判定最高文件描述符值,并关闭直到该值的所有描述符。
(6)某些守护进程打开/dev/null使其具有文件描述符0、1和2,这样,任何一个试图读标准输入、写标准输出或标准错误的库例程都不会产生任何效果。因为守护进程并不与终端设备相关联,所以其输出无处显示,也无处从交互式用户那里接收输入。即使守护进程是从交互式会话启动的,但是守护进程是在后台运行的,所以登录会话的终止并不影响守护进程。如果其他用户在同一终端设备上登录,我们不希望在该终端上见到守护进程的输出,用户也不期望他们在终端上的输入被守护进程读取。
实例
图13-1所示的函数可由一个想要初始化为守护进程的程序调用。
图13-1 初始化一个守护进程
若daemonize函数由main程序调用,然后main程序进入休眠状态,那么可以用ps命令检查该守护进程的状态:
$ ./a.out
$ ps -efj
UID PID PPID PGID SID TTY CMD
sar 13800 1 13799 13799 ? ./a.out
$ ps -efj | grep 13799
sar 13800 1 13799 13799 ? ./a.out
我们也可用ps命令验证,没有活动进程存在的ID是13799。这意味着,守护进程在一个孤儿进程组中(见 9.10 节),它不是会话首进程,因此没有机会被分配到一个控制终端。这一结果是在daemonize函数中执行第二个fork造成的。可以看出,守护进程已经被正确地初始化了。
守护进程存在的一个问题是如何处理出错消息。因为它本就不应该有控制终端,所以不能只是简单地写到标准错误上。我们不希望所有守护进程都写到控制台设备上,因为在很多工作站上控制台设备都运行着一个窗口系统。我们也不希望每个守护进程将它自己的出错消息写到一个单独的文件中。对任何一个系统管理人员而言,如果要关心哪一个守护进程写到哪一个记录文件中,并定期地检查这些文件,那么一定会使他感到头痛。所以,需要有一个集中的守护进程出错记录设施。
BSD syslog 设施是在伯克利开发的,广泛应用于 4.2BSD。从 BSD 派生的很多系统都支持syslog。在 SVR4 之前,System V 中从来没有一个集中的守护进程记录设施。在 Single UNIX Specification的XSI扩展中包括了syslog函数。
自4.2BSD以来,BSD的syslog设施得到了广泛的应用。大多数守护进程都使用这一设施。图13-2显示了syslog设施的详细组织结构。
图13-2 BSD的syslog设施
有以下3种产生日志消息的方法。
(1)内核例程可以调用 log 函数。任何一个用户进程都可以通过打开(open)并读取(read)/dev/klog设备来读取这些消息。因为我们无意编写内核例程,所以不再进一步说明此函数。
(2)大多数用户进程(守护进程)调用syslog(3)函数来产生日志消息。我们将在下面说明其调用序列。这使消息被发送至UNIX域数据报套接字/dev/log。
(3)无论一个用户进程是在此主机上,还是在通过TCP/IP网络连接到此主机的其他主机上,都可将日志消息发向UDP端口514。注意,syslog函数从不产生这些UDP数据报,它们要求产生此日志消息的进程进行显式的网络编程。
关于UNIX域套接字以及UDP套接字的细节,请参阅Stevens、Fenner和Rudoff[2004]。
通常,syslogd守护进程读取所有3种格式的日志消息。此守护进程在启动时读一个配置文件,其文件名一般为/etc/syslog.conf,该文件决定了不同种类的消息应送向何处。例如,紧急消息可发送至系统管理员(若已登录),并在控制台上打印,而警告消息则可记录到一个文件中。
该设施的接口是syslog函数。
#include <syslog.h>
void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
int setlogmask(int maskpri);
返回值:前日志记录优先级屏蔽字值
调用openlog是可选择的。如果不调用openlog,则在第一次调用syslog时,自动调用openlog。调用 closelog也是可选择的,因为它只是关闭曾被用于与syslogd守护进程进行通信的描述符。
调用openlog 使我们可以指定一个ident,以后,此ident将被加至每则日志消息中。ident一般是程序的名称(如cron、inetd)。option参数是指定各种选项的位屏蔽。图13-3介绍了可用的option(选项)。若在Single UNIX Specification的openlog定义中包括了该选项,则在XSI列中用一个黑点表示。
图13-3 openlog的option参数
openlog的facility参数值选取自图13-4。注意,Single UNIX Specification只定义了facility所有参数值中的一个子集,该子集一般只能用在一个给定的平台上。设置facility参数的目的是可以让配置文件说明,来自不同设施的消息将以不同的方式进行处理。如果不调用openlog,或者以facility为0来调用它,那么在调用syslog时,可将facility作为priority参数的一个部分进行说明。
调用syslog产生一个日志消息。其priority参数是facility和level的组合,它们可选取的值分别列于facility(见图13-4)和level(见图13-5)中。level值按优先级从最高到最低依次排列。
图13-4 openlog的facility参数
图13-5 syslog中的level(按序排列)
将format参数以及其他所有参数传至vsprintf函数以便进行格式化。在format中,每个出现的%m字符都先被代换成与errno值对应的出错消息字符串(strerror)。
setlogmask函数用于设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字。当设置了记录优先级屏蔽字时,各条消息除非已在记录优先级屏蔽字中进行了设置,否则将不被记录。注意,试图将记录优先级屏蔽字设置为0并不会有什么作用。
很多系统也将logger(1)程序作为向syslog设施发送日志消息的方法。虽然Single UNIX Specification 没有定义任何可选参数,但某些实现允许将该程序的可选参数指定为 facility、level和ident。logger命令是专门为以非交互方式运行的需要产生日志消息的shell脚本设计的。
实例
在一个(假定的)行式打印机假脱机守护进程中,可能包含有下面的调用序列:
openlog("lpd", LOG_PID, LOG_LPR);
syslog(LOG_ERR, "open error for %s: %m", filename);
第一个调用将ident字符串设置为程序名,指定该进程ID要始终被打印,并且将系统默认的facility设定为行式打印机系统。对 syslog 的调用指定一个出错条件和一个消息字符串。如若不调用openlog,则第二个调用的形式可能是:
syslog(LOG_ERR | LOG_LPR, "open error for %s: %m", filename);
其中,将priority参数指定为level和facility的组合。
除了syslog,很多平台还提供它的一种变体来处理可变参数列表。
#include <syslog.h>
#include <stdarg.h>
void vsyslog(int priority, const char *format, va_list arg);
本书说明的所有4种平台都提供vsyslog,但Single UNIX Specification中并不包括它。注意,如果要使它的声明对应用程序可见,可能需要定义一个额外的符号,例如,在FreeBSD中定义__BSD_VISIBLE或在Linux中定义__USE_BSD。
大多数syslog实现将使消息短时间处于队列中。如果在此段时间中有重复消息到达,那么syslog 守护进程不会把它写到日志记录中,而是会打印输出一条类似于“上一条消息重复了N次”的消息。
为了正常运作,某些守护进程会实现为,在任一时刻只运行该守护进程的一个副本。例如,这种守护进程可能需要排它地访问一个设备。对cron守护进程而言,如果同时有多个实例运行,那么每个副本都可能试图开始某个预定的操作,于是造成该操作的重复执行,这很可能导致出错。
如果守护进程需要访问一个设备,而该设备驱动程序有时会阻止想要多次打开/dev 目录下相应设备节点的尝试。这就限制了在一个时刻只能运行守护进程的一个副本。但是如果没有这种设备可供使用,那么我们就需要自行处理。
文件和记录锁机制为一种方法提供了基础,该方法保证一个守护进程只有一个副本在运行。(文件和记录锁将在14.3节中讨论。)如果每一个守护进程创建一个有固定名字的文件,并在该文件的整体上加一把写锁,那么只允许创建一把这样的写锁。在此之后创建写锁的尝试都会失败,这向后续守护进程副本指明已有一个副本正在运行。
文件和记录锁提供了一种方便的互斥机制。如果守护进程在一个文件的整体上得到一把写锁,那么在该守护进程终止时,这把锁将被自动删除。这就简化了复原所需的处理,去除了对以前的守护进程实例需要进行清理的有关操作。
实例
图13-6所示的函数说明了如何使用文件和记录锁来保证只运行一个守护进程的一个副本。
图13-6 保证只运行一个守护进程的一个副本
守护进程的每个副本都将试图创建一个文件,并将其进程 ID 写到该文件中。这使管理人员易于标识该进程。如果该文件已经加了锁,那么lockfile函数将失败,errno设置为EACCES或EAGAIN,图13-6中的函数返回1,表明该守护进程已在运行。否则将文件长度截断为0,将进程ID写入该文件,图13-6中的函数返回0。
需要将文件长度截断为0,其原因是之前的守护进程实例的进程ID字符串可能长于调用此函数的当前进程的进程ID字符串。例如,若以前的守护进程的进程ID是12345,而新实例的进程ID是9999,那么将此进程ID写入文件后,在文件中留下的是99995。将文件长度截断为0就解决了此问题。
在UNIX系统中,守护进程遵循下列通用惯例。
•若守护进程使用锁文件,那么该文件通常存储在/var/run目录中。然而需要注意的是,守护进程可能需要具有超级用户权限才能在此目录下创建文件。锁文件的名字通常是name.pid,其中,name是该守护进程或服务的名字。例如,cron守护进程锁文件的名字是/var/run/crond.pid。
•若守护进程支持配置选项,那么配置文件通常存放在/etc目录中。配置文件的名字通常是name.conf,其中,name是该守护进程或服务的名字。例如,syslogd守护进程的配置文件通常是/etc/syslog.conf。
•守护进程可用命令行启动,但通常它们是由系统初始化脚本之一(/etc/rc*或/etc/init.d/*)启动的。如果在守护进程终止时,应当自动地重新启动它,则我们可在/etc/inittab中为该守护进程包括respawn记录项,这样,init就将重新启动该守护进程。(假定系统使用System V风格的init命令。)
•若一个守护进程有一个配置文件,那么当该守护进程启动时会读该文件,但在此之后一般就不会再查看它。若某个管理员更改了配置文件,那么该守护进程可能需要被停止,然后再启动,以使配置文件的更改生效。为避免此种麻烦,某些守护进程将捕捉SIGHUP信号,当它们接收到该信号时,重新读配置文件。因为守护进程并不与终端相结合,它们或者是无控制终端的会话首进程,或者是孤儿进程组的成员,所以守护进程没有理由期望接收SIGHUP。于是,守护进程可以安全地重复使用SIGHUP。
实例
图13-7所示的程序说明了守护进程可以重读其配置文件的一种方法。该程序使用sigwait以及多线程,对此我们已经在12.8节讨论过。
图13-7 守护进程重读配置文件
该程序调用了图13-1中的daemonize来初始化守护进程。从该函数返回后,调用图13-6中的already_running函数以确保该守护进程只有一个副本在运行。到达这一点时,SIGHUP信号仍被忽略,所以需恢复对该信号的系统默认处理方式;否则调用sigwait的线程决不会见到该信号。
如同对多线程程序所推荐的那样,阻塞所有信号,然后创建一个线程处理信号。该线程的唯一工作是等待SIGHUP和SIGTERM。当接收到SIGHUP信号时,该线程调用reread函数重读它的配置文件。当它接收到SIGTERM信号时,会记录消息并退出。
回顾图10-1,SIGHUP和SIGTERM的默认动作是终止进程。因为我们阻塞了这些信号,所以当SIGHUP和SIGTERM的其中一个被发送到守护进程时,守护进程不会消亡。作为替代,调用sigwait的线程在返回时将指示已接收到该信号。
实例
并非所有守护进程都是多线程的。图 13-8 中的程序说明一个单线程守护进程如何捕捉SIGHUP并重读其配置文件。
图13-8 守护进程重读配置文件的另一种实现
在初始化守护进程后,我们为SIGHUP和SIGTERM配置了信号处理程序。可以将重读逻辑放在信号处理程序中,也可以只在信号处理程序中设置一个标志,并由守护进程的主线程完成所有的工作。