# 8.5.6 同步流以避免讨厌的并发错误
如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。一般而言,流可能交错的数量与指令的数量呈指数关系。这些交错中的一些会产生正确的结果,而有些则不会。基本的问题是以某种方式同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。
code/src/csapp.c
handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask); /* Block sigs of type being handled */
action.sa_flags = SA_RESTART; /* Restart syscalls if possible */
if (sigaction(signum, &action, &old_action) < 0)
unix_error("Signal error");
return (old_action.sa_handler);
}
图 8-38 Signal:sigaction 的一个包装函数,它提供在 Posix 兼容系统上的可移植的信号处理
并发编程是一个很深且很重要的问题,我们将在第 12 章中更详细地讨论。不过,在本章中学习的有关异常控制流的知识,可以让你感觉一下与并发相关的有趣的智力挑战。例如,考虑图 8-39 中的程序,它总结了一个典型的 Unix shell 的结构。父进程在一个全局作业列表中记录着它的当前子进程,每个作业一个条目。addjob 和 deletejob 函数分别向这个作业列表添加和从中删除作业。
当父进程创建一个新的子进程后,它就把这个子进程添加到作业列表中。当父进程在 SIGCHLD 处理程序中回收一个终止的(僵死)子进程时,它就从作业列表中删除这个子进程。
乍一看,这段代码是对的。不幸的是,可能发生下面这样的事件序列:
- 父进程执行
fork函数,内核调度新创建的子进程运行,而不是父进程。 - 在父进程能够再次运行之前,子进程就终止,并且变成一个僵死进程,使得内核传递一个 SIGCHLD 信号给父进程。
- 后来,当父进程再次变成可运行但又在它执行之前,内核注意到有未处理的 SIGCHLD 信号,并通过在父进程中运行处理程序接收这个信号。
- 信号处理程序回收终止的子进程,并调用
deletejob,这个函数什么也不做,因为父进程还没有把该子进程添加到列表中。 - 在处理程序运行完毕后,内核运行父进程,父进程从
fork返回,通过调用addjob错误地把(不存在的)子进程添加到作业列表中。
因此,对于父进程的 main 程序和信号处理流的某些交错,可能会在 addjob 之前调用 deletejob。这导致作业列表中出现一个不正确的条目,对应于一个不再存在而且永远也不会被删除的作业。另一方面,也有一些交错,事件按照正确的顺序发生。例如,如果在 fork 调用返回时,内核刚好调度父进程而不是子进程运行,那么父进程就会正确地把子进程添加到作业列表中,然后子进程终止,信号处理函数把该作业从列表中删除。
这是一个称为竞争(race)的经典同步错误的示例。在这个情况中,main 函数中调用 addjob 和处理程序中调用 deletejob 之间存在竞争。如果 addjob 赢得进展,那么结果就是正确的。如果它没有,那么结果就是错误的。这样的错误非常难以调试,因为几乎不可能测试所有的交错。你可能运行这段代码十亿次,也没有一次错误,但是下一次测试却导致引发竞争的交错。
code/ecf/procmask1.c
/* WARNING: This code is buggy! */
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;
Sigfillset(&mask_all);
while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(pid); /* Delete the child from the job list */
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
if (errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, prev_all;
Sigfillset(&mask_all);
Signal(SIGCHLD, handler);
initjobs(); /* Initialize the job list */
while (1) {
if ((pid = Fork()) == 0) { /* Child process */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent process */
addjob(pid); /* Add the child to the job list */
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
exit(0);
}
图 8-39 一个具有细微同步错误的 shell 程序。如果子进程在父进程能够开始运行前就结束了,那么 addjob 和 deletejob 会以错误的方式被调用
图 8-40 展示了消除图 8-39 中竞争的一种方法。通过在调用 fork 之前,阻塞 SIGCHLD 信号,然后在调用 addjob 之后取消阻塞这些信号,我们保证了在子进程被添加到作业列表中之后回收该子进程。注意,子进程继承了它们父进程的被阻塞集合,所以我们必须在调用 execve 之前,小心地解除子进程中阻塞的 SIGCHLD 信号。
code/ecf/procmask2.c
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;
Sigfillset(&mask_all);
while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(pid); /* Delete the child from the job list */
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
if (errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, mask_one, prev_one;
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
Signal(SIGCHLD, handler);
initjobs(); /* Initialize the job list */
while (1) {
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
if ((pid = Fork()) == 0) { /* Child process */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
addjob(pid); /* Add the child to the job list */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
}
exit(0);
}
图 8-40 用 sigprocmask 来同步进程。在这个例子中,父进程保证在相应的 deletejob 之前执行 addjob