CSAPP Shell Lab 信号竞争解决:3步阻塞SIGCHLD,避免addjob/deletejob冲突
CSAPP Shell Lab 信号竞争解决:3步阻塞SIGCHLD,避免addjob/deletejob冲突
1. Shell Lab并发问题的核心挑战
在实现Unix shell时,进程管理与信号处理的交互会引发一类经典的并发问题:当子进程在父进程执行addjob前终止,父进程可能收到SIGCHLD信号并先执行deletejob,导致最终jobs列表中出现不一致状态。这种addjob与deletejob的竞争条件(race condition)是Shell Lab实验中最需要警惕的陷阱。
典型的问题场景时序如下:
- 父进程fork子进程后,子进程立即终止并发送SIGCHLD信号
- 父进程的信号处理器先捕获信号并执行deletejob
- 随后父进程才执行addjob操作
- 最终导致已终止的进程仍残留在jobs列表中
这种竞争条件的本质在于信号处理的异步性——内核可能在任意时刻将信号递送给进程。要解决这个问题,我们需要精确控制SIGCHLD信号的接收时机,确保关键操作的原子性。
2. 信号阻塞的三步解决方案
2.1 信号阻塞的基本原理
Unix信号阻塞机制允许进程暂时屏蔽特定信号的接收。通过sigprocmask系统调用,我们可以控制当前被阻塞的信号集。被阻塞的信号会保持pending状态,直到解除阻塞后才被递送。
关键系统调用:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);参数说明:
how:SIG_BLOCK(添加阻塞)、SIG_UNBLOCK(解除阻塞)或SIG_SETMASK(直接设置)set:要修改的信号集oldset:保存原先的信号掩码
2.2 三步阻塞实现流程
以下是解决竞争条件的标准实现模式:
sigset_t mask_all, mask_one, prev_one; // 1. 初始化信号集 Sigfillset(&mask_all); // 所有信号 Sigemptyset(&mask_one); // 空信号集 Sigaddset(&mask_one, SIGCHLD); // 仅SIGCHLD // 2. 在fork前阻塞SIGCHLD Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); if ((pid = fork()) == 0) { // 子进程解除阻塞 Sigprocmask(SIG_SETMASK, &prev_one, NULL); // ...执行子进程代码... exit(0); } // 3. 在addjob前后保护临界区 Sigprocmask(SIG_BLOCK, &mask_all, NULL); addjob(jobs, pid, state, cmdline); Sigprocmask(SIG_SETMASK, &mask_one, NULL); // 解除SIGCHLD阻塞 Sigprocmask(SIG_SETMASK, &prev_one, NULL);2.3 关键操作时序图
父进程 子进程 | | |-- fork() --| | 阻塞SIGCHLD | | |-- 快速终止 | |-- 发送SIGCHLD(被阻塞) |-- addjob() --| | 临界区保护 | |-- 解除SIGCHLD阻塞 --| |-- 处理pending信号 --| |-- deletejob() --|3. 实现细节与注意事项
3.1 信号处理器的安全实现
正确的SIGCHLD处理器需要处理三种子进程状态变化:
void sigchld_handler(int sig) { int olderrno = errno; sigset_t mask_all, prev; Sigfillset(&mask_all); while ((pid = waitpid(-1, &status, WNOHANG|WUNTRACED)) > 0) { // 保护全局数据结构访问 Sigprocmask(SIG_BLOCK, &mask_all, &prev); if (WIFEXITED(status)) { // 正常退出 deletejob(jobs, pid); } else if (WIFSIGNALED(status)) { // 信号终止 printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status)); deletejob(jobs, pid); } else if (WIFSTOPPED(status)) { // 信号停止 struct job_t *job = getjobpid(jobs, pid); job->state = ST; printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(status)); } Sigprocmask(SIG_SETMASK, &prev, NULL); } errno = olderrno; }3.2 waitfg的正确实现
等待前台作业完成时,应使用sigsuspend而非简单的循环检查,避免CPU空转:
void waitfg(pid_t pid) { sigset_t mask; Sigemptyset(&mask); while (fgpid(jobs) == pid) { sigsuspend(&mask); // 原子性地解除阻塞并等待信号 } }3.3 进程组管理
为确保信号能正确传递给整个进程组,子进程需要设置新的进程组:
if ((pid = fork()) == 0) { setpgid(0, 0); // 创建新进程组,PGID=子进程PID // ...其余代码... }在发送信号时使用负PID表示进程组:
kill(-pid, SIGINT); // 发送给整个进程组4. 测试验证方法
4.1 关键测试场景
| 测试用例 | 验证目标 |
|---|---|
| trace01-02 | 基本命令处理 |
| trace03-05 | 前后台作业管理 |
| trace06-08 | SIGINT/SIGTSTP信号处理 |
| trace09-10 | bg/fg命令功能 |
| trace11-13 | 进程组信号传播 |
| trace14-16 | 错误处理与边界条件 |
4.2 测试对比方法
使用提供的测试工具进行自动化验证:
make test01 # 单独测试trace01 make rtest01 # 参考实现输出 diff -u tsh.out tshref.out典型输出对比:
- ./myspin 1 & - [1] (15514) ./myspin 1 & + ./myspin 1 & + [1] (15708) ./myspin 1 &5. 高级优化技巧
5.1 错误处理增强
所有系统调用都应检查返回值并处理错误:
void Sigprocmask(int how, const sigset_t *set, sigset_t *oldset) { if (sigprocmask(how, set, oldset) < 0) unix_error("sigprocmask error"); }5.2 全局变量访问保护
任何对全局jobs列表的访问都应处于信号阻塞状态:
sigset_t mask_all, prev; Sigfillset(&mask_all); Sigprocmask(SIG_BLOCK, &mask_all, &prev); // 访问jobs列表 Sigprocmask(SIG_SETMASK, &prev, NULL);5.3 避免内存泄漏
确保所有终止的子进程都被正确回收:
while ((pid = waitpid(-1, &status, WNOHANG|WUNTRACED)) > 0) { // 处理每个终止的子进程 }6. 总结与经验分享
在实际调试过程中,有几个容易忽视的细节值得特别注意:
- 信号堆栈:某些平台对信号处理器的调用会使用特殊堆栈,大缓冲区可能导致栈溢出
- errno保存:信号处理器中修改的errno可能影响主程序逻辑
- 系统调用重启:被信号中断的系统调用可能需要特殊处理
一个实用的调试技巧是添加详细的日志输出:
void eval(char *cmdline) { if (verbose) { printf("eval: parsing '%s'\n", cmdline); } // ... }通过系统性地应用信号阻塞技术,我们能够构建出健壮的shell实现,正确处理各种并发场景。这种模式不仅适用于Shell Lab,也是Unix系统编程中处理异步事件的重要范式。
