CMU 15-213 Intro to Computer Systems Lecture 15
课程主页:http://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html
课程资料:https://github.com/EugeneLiu/translationCSAPP
课程视频:https://www.bilibili.com/video/av31289365/
这一讲介绍了信号和非本地跳转。
Shells
Linux进程层次结构
使用pstree命令可以看到层次结构。
Shell程序
- Shell是代表用户运行程序的应用程序。
- sh
- csh/tcsh
- bash
简易的Shell实现如下:
int main()
{
char cmdline[MAXLINE]; /* command line */
while (1) {
/* read */
printf("> ");
Fgets(cmdline, MAXLINE, stdin);
if (feof(stdin))
exit(0);
/* evaluate */
eval(cmdline);
}
}
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */
strcpy(buf, cmdline);
bg = parseline(buf, argv);
if (argv[0] == NULL)
return; /* Ignore empty lines */
if (!builtin_command(argv)) {
if ((pid = Fork()) == 0) { /* Child runs user job */
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
/* Parent waits for foreground job to terminate */
if (!bg) {
int status;
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: waitpid error");
}
else
printf("%d %s", pid, cmdline);
}
return;
}
简单Shell例子的问题
- 我们的示例shell正确地等待并获得前台作业
- 但是后台作业呢?
- 终结时将变成“僵死进程”
- 因为shell(通常)不会终止,所以永远不会回收
- 会造成内存泄漏,可能导致内核内存不足
- 解决方案:异常控制流
- 当后台进程完成时,内核将中断常规进程以提醒我们
- 在Unix中,警报机制称为信号
信号
- 信号是一条小消息,用于通知进程系统中发生了某种类型的事件
- 类似于异常和中断
- 从内核(有时是在另一个进程的请求下)发送到一个进程
- 信号类型由整数ID(1-30)标识
- 信号中的唯一信息就是其ID和到达的事实
信号序号及其对应事件:
信号概念:传递信号
- 内核通过在目标进程的上下文中更新某些状态来将信号发送(传递)到目标进程
- 内核出于以下原因之一发送信号:
- 内核检测到系统事件,例如被零除(SIGFPE)或子进程终止(SIGCHLD)。
- 另一个进程调用了kill,以明确请求内核将信号发送到目标进程。
信号概念:接收信号
当目标进程被内核强制以某种方式对信号的传递做出反应时,它就接收了信号
一些可能的反应方式:
忽略信号(不执行任何操作)
终止进程(可选转储内存)
通过执行称为信号处理程序的用户级函数来捕获信号
类似于响应异步中断而调用的硬件异常处理程序:
信号概念:待处理(Pending)和阻塞(Blocked)信号
- 如果已发送但尚未接收到信号,则信号是待处理
- 任何特定类型最多有一个待处理信号
- 重要提示:信号不会排队等待
- 如果某个进程具有类型为k的待处理信号,则丢弃发送给该进程的后续类型为k的信号
- 一个进程可能会阻塞某些信号的接收
- 可以传递被阻塞的信号,但是直到信号被解除阻塞才可以接收
- 最多收到一次待处理信号
- 内核在每个进程的上下文中维护待处理(Pending)和阻塞(Blocked)的比特向量
- 待处理:表示待处理信号集
- 当传递类型为k的信号时,内核将会设置pending的第k位
- 当接收到类型为k的信号时,内核清除pending的第k位
- 阻塞:表示已阻止信号的集合
- 可以使用sigprocmask函数设置和清除
- 也称为信号屏蔽(signal mask)。
信号概念:进程组
每个进程仅属于一个进程组
getpgrp():返回当前进程的进程组
setpgid():更改进程的进程组
使用/bin/kill程序传递信号
- /bin/kill程序向进程或进程组发送任意信号
- 例子
- /bin/kill –9 24818发送SIGKILL来处理24818
- /bin/kill –9 –24817将SIGKILL发送到进程组24817中的每个进程
linux> ./forks 16
Child1: pid=24818 pgrp=24817
Child2: pid=24819 pgrp=24817
linux> ps
PID TTY TIME CMD
24788 pts/2 00:00:00 tcsh
24818 pts/2 00:00:02 forks
24819 pts/2 00:00:02 forks
24820 pts/2 00:00:00 ps
linux> /bin/kill -9 -24817
linux> ps
PID TTY TIME CMD
24788 pts/2 00:00:00 tcsh
24823 pts/2 00:00:00 ps
linux>
从键盘传递信号
- 键入ctrl-c(ctrl-z)会导致内核向前台进程组中的每个作业发送SIGINT(SIGTSTP)。
- SIGINT–默认操作是终止每个进程
- SIGTSTP–默认操作是停止(挂起)每个进程
例子:
bluefish> ./forks 17
Child: pid=28108 pgrp=28107
Parent: pid=28107 pgrp=28107
<types ctrl-z>
Suspended
bluefish> ps w
PID TTY STAT TIME COMMAND
27699 pts/8 Ss 0:00 -tcsh
28107 pts/8 T 0:01 ./forks 17
28108 pts/8 T 0:01 ./forks 17
28109 pts/8 R+ 0:00 ps w
bluefish> fg
./forks 17
<types ctrl-c>
bluefish> ps w
PID TTY STAT TIME COMMAND
27699 pts/8 Ss 0:00 -tcsh
28110 pts/8 R+ 0:00 ps w
使用kill函数传递信号
void fork12()
{
pid_t pid[N];
int i;
int child_status;
for (i = 0; i < N; i++)
if ((pid[i] = fork()) == 0) {
/* Child: Infinite Loop */
while(1)
;
}
for (i = 0; i < N; i++) {
printf("Killing process %d\n", pid[i]);
kill(pid[i], SIGINT);
}
for (i = 0; i < N; i++) {
pid_t wpid = wait(&child_status);
if (WIFEXITED(child_status))
printf("Child %d terminated with exit status %d\n",
wpid, WEXITSTATUS(child_status));
else
printf("Child %d terminated abnormally\n", wpid);
}
}
接收信号
- 假设内核正在从异常处理程序中返回并准备将控制权传递给进程p
- 内核计算$\text{pnb = pending & ~blocked}$
- 进程p的非阻塞信号集
- 如果($\text{pnb == 0}$)
- 将控制传递给p的逻辑流程中的下一条指令
- 否则
- 在pnb中选择最小非零位k并强制进程p接收信号k
- 信号的接收触发了p的某种动作
- 对pnb中的所有非零k重复上述操作
- 将控制传递给逻辑流中的下一条指令以用于p
默认动作
- 每种信号类型都有一个预定义的默认操作,它是以下之一:
- 该进程终止
- 该进程将停止,直到通过SIGCONT信号重新启动
- 该进程忽略了信号
设置信号处理器
- 信号函数会修改与信号signum接收相关的默认操作:
- $\text{handler_t signal(int signum, handler_t handler)}$
- handler的不同值:
- SIG_IGN:忽略signum类型的信号
- SIG_DFL:收到signum类型的信号后恢复为默认操作
- 否则,handler是用户级信号处理程序的地址。
- 当进程接收到信号类型为signum的信号时调用
- 称为“设置”处理程序
- 执行处理程序称为“捕捉”或“处理”信号
- 当处理程序执行其return语句时,控制权返回到进程的控制流中的指令,该指令被接收到信号而中断
例子:
void sigint_handler(int sig) /* SIGINT handler */
{
printf("So you think you can stop the bomb with ctrl-c, do you?\n");
sleep(2);
printf("Well...");
fflush(stdout);
sleep(1);
printf("OK. :-)\n");
exit(0);
}
int main()
{
/* Install the SIGINT handler */
if (signal(SIGINT, sigint_handler) == SIG_ERR)
unix_error("signal error");
/* Wait for the receipt of a signal */
pause();
return 0;
}
处理程序作为并发流信号
- 信号处理程序是与主程序同时运行的独立逻辑流(不是进程)
嵌套信号处理程序
- 处理程序可以被其他处理程序中断
阻塞和解除阻塞信号
- 隐式阻塞机制
- 内核阻止当前正在处理的任何类型的待处理信号。
- 例如,一个SIGINT处理程序不能被另一个SIGINT中断
- 显式阻塞和解除阻塞机制
- sigprocmask函数
- 配套函数
- sigemptyset –创建空集
- sigfillset –添加要设置的每个信号编号
- sigaddset –添加信号编号进行设置
- sigdelset –从集中删除信号编号
安全信号处理
- 处理程序之所以棘手,是因为它们与主程序并发并且共享相同的全局数据结构。
- 共享的数据结构可能会损坏。
- 我们将在后续探讨并发问题。
- 目前,这里有一些准则可以帮助您避免麻烦。
编写安全处理程序的准则
- G0:处理程序尽可能简单
- 例如,设置全局标志并返回
- G1:仅在处理程序中调用异步信号安全函数
- printf,sprintf,malloc和exit不安全!
- G2:在进入和退出时保存并还原errno
- 这样其他处理程序就不会覆盖您的errno值
- G3:通过暂时阻止所有信号来保护对共享数据结构的访问。
- G4:将全局变量声明为volatile
- 为了防止编译器将它们存储在寄存器中
- G5:将全局标志声明为volatile sig_atomic_t
- flag:只适用于读取或写入的变量(例如,flag=1,flag++不适用)
- 以这种方式声明的标志不需要像其他全局变量那样受到保护
异步信号安全
- 如果函数是可重入的(例如,存储在堆栈帧中的所有变量(只访问局部变量))或不可被信号中断,则该函数是异步信号安全的。
- Posix保证117个函数是异步信号安全的
- 资料来源:“man 7 signal”
- 列表中的常用函数:
- _exit, write, wait, waitpid, sleep, kill
- 不在列表中的常用函数:
- printf, sprintf, malloc, exit
- 不幸的事实:write是唯一异步信号安全的输出函数
安全地生成格式化输出
在处理程序中使用csapp.c中的可重入SIO(安全I/O库)。
ssize_t sio_puts(char s[]) /* Put string */ ssize_t sio_putl(long v) /* Put long */ void sio_error(char s[]) /* Put msg & exit */
例子:
void sigint_handler(int sig) /* Safe SIGINT handler */
{
Sio_puts("So you think you can stop the bomb with ctrl-c, do you?\n");
sleep(2);
Sio_puts("Well...");
sleep(1);
Sio_puts("OK. :-)\n");
_exit(0);
}
正确的信号处理
- 待处理信号未排队
- 对于每种信号类型,一比特表示信号是否在处理。
- 因此最多有一个任何特定类型的待处理信号。
- 不能使用信号来计数事件,例如终止子进程的事件。
可移植的信号处理
- 不同版本的Unix可以具有不同的信号处理语义
- 一些较旧的系统在捕获信号后将操作恢复为默认值
- 某些中断的系统调用可以使用errno == EINTR返回
- 某些系统不会阻止正在处理的信号类型
- 解决方案:sigaction函数,它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。
#include <signal.h>
int sigaction(int signum, struct sigaction *act,
struct sigaction *oldact);
sigaction函数的使用较为复杂,实际中常用Signal函数,它调用sigaction:
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);
}
同步流以避免竞争(race)
- 简单shell有轻微的同步错误,因为它假定父级运行在子级之前。
错误示例:
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 */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent */
addjob(pid); /* Add the child to the job list */
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
exit(0);
}
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 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;
}
上述代码是错误的,因为main函数的addjob和处理程序中的deletejob存在竞争(即不一定保证先调用addjob,然后调用deletejob)。
正确代码:
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);
}
显式地等待信号
程序的处理程序显式等待SIGCHLD到达。
volatile sig_atomic_t pid; void sigchld_handler(int s) { int olderrno = errno; pid = Waitpid(-1, NULL, 0); /* Main is waiting for nonzero pid */ errno = olderrno; } void sigint_handler(int s) { } int main(int argc, char **argv) { sigset_t mask, prev; Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while (1) { Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */ if (Fork() == 0) /* Child */ exit(0); /* Parent */ pid = 0; Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */ /* Wait for SIGCHLD to be received (wasteful!) */ while (!pid) ; /* Do some work after receiving SIGCHLD */ printf("."); } exit(0); }
程序正确,但是非常浪费资源。
其他选择:
while (!pid) /* Race! */ pause(); while (!pid) /* Too slow! */ sleep(1);
解决方案:sigsuspend函数
int sigsuspend(const sigset_t *mask)
该函数等价于下述代码的原子(不可中断)版本
sigprocmask(SIG_BLOCK, &mask, &prev); pause(); sigprocmask(SIG_SETMASK, &prev, NULL);
修改后的代码如下:
int main(int argc, char **argv) { sigset_t mask, prev; Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while (1) { Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */ if (Fork() == 0) /* Child */ exit(0); /* Wait for SIGCHLD to be received */ pid = 0; while (!pid) Sigsuspend(&prev); /* Optionally unblock SIGCHLD */ Sigprocmask(SIG_SETMASK, &prev, NULL); /* Do some work after receiving SIGCHLD */ printf("."); } exit(0); }
非本地跳转
非本地跳转:setjmp/longjmp
- 强大(但很危险)的用户级机制,可将控制权转移到任意位置
- 控制方式打破程序调用/返回规则
- 对于错误恢复和信号处理很有用
- int setjmp(jmp_buf j)
- 必须在longjmp之前调用
- 标识后续longjmp的返回位置
- 调用一次,返回一次或多次
- 实现方式:
- 通过在jmp_buf中存储当前的寄存器上下文,堆栈指针和PC值来记住位置
- 返回0
- void longjmp(jmp_buf j, int i)
- 含义:
- 从跳转缓冲区j中恢复调用环境,然后触发一个从最近一次初始化j的setjmp调用的返回
- 这次返回i而不是0
- 在setjmp之后调用
- 调用一次,但永不返回
- 含义:
- longjmp实现:
- 从跳转缓冲区j恢复寄存器上下文(堆栈指针,基址指针,PC值)
- 将%eax(返回值)设置为i
- 跳转到由PC指示的位置,该位置存储在jump buf j中
例子
目标:从深度嵌套的函数直接返回到原始调用者
例子:
jmp_buf buf; int error1 = 0; int error2 = 1; void foo(void), bar(void); int main() { switch(setjmp(buf)) { case 0: foo(); break; case 1: printf("Detected an error1 condition in foo\n"); break; case 2: printf("Detected an error2 condition in foo\n"); break; default: printf("Unknown error condition in foo\n"); } exit(0); } /* Deeply nested function foo */ void foo(void) { if (error1) longjmp(buf, 1); bar(); } void bar(void) { if (error2) longjmp(buf, 2); }
上述例子中,main函数首先调用setjmp以保存当前的调用环境,然后调用函数foo,foo依次调用函数bar。如果foo或者bar遇到一个错误,它们立即通过一次longjmp调用从setjmp返回。setjmp的非零返回值指明了错误类型,随后可以被解码,且在代码中的某个位置进行处理。
非本地跳转的局限性
在堆栈规则内工作
- 只能跳到已调用但尚未完成的函数环境
例1:
jmp_buf env; P1() { if (setjmp(env)) { /* Long Jump to here */ } else { P2(); } } P2() { . . . P2(); . . . P3(); } P3() { longjmp(env, 1); }
堆栈图:
例2:
jmp_buf env; P1() { P2(); P3(); } P2() { if (setjmp(env)) { /* Long Jump to here */ } } P3() { longjmp(env, 1); }
堆栈图:
综合例子
#include "csapp.h"
sigjmp_buf buf;
void handler(int sig)
{
siglongjmp(buf, 1);
}
int main()
{
if (!sigsetjmp(buf, 1)) {
Signal(SIGINT, handler);
Sio_puts("starting\n");
}
else
Sio_puts("restarting\n");
while(1) {
Sleep(1);
Sio_puts("processing...\n");
}
exit(0); /* Control never reaches here */
}
结果:
greatwhite> ./restart
starting
processing...
processing...
processing...
Ctrl-c
restarting
processing...
processing...
Ctrl-c
restarting
processing...
processing...
processing...
总结
- 信号提供进程级异常处理
- 可以从用户程序生成
- 可以通过声明信号处理程序来定义效果
- 编写信号处理程序时要非常小心
- 非本地跳转提供了进程内的异常控制流
- 在堆栈规则的约束下