课程主页: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...

总结

  • 信号提供进程级异常处理
    • 可以从用户程序生成
    • 可以通过声明信号处理程序来定义效果
    • 编写信号处理程序时要非常小心
  • 非本地跳转提供了进程内的异常控制流
    • 在堆栈规则的约束下