Post

进程与异常控制流

导言

这篇文章是对 CSAPP 第八章的总结。提到异常,我们第一反应是程序中的异常处理机制,比如 try...catch,但是我们今天要讲的内容其实会更加的底层。计算机系统异常控制远比 try...catch 要复杂的多,异常的处理需要软硬件的配合才能实现,我们在前面的 文章 中提到了硬件方面的异常控制,硬件通常只是捕获异常状态,处理则需要交由操作系统,这也是我们今天要主要学习的内容。

异常

什么是异常呢?异常通常是指,程序没法按照正常设定的状态运行下去,可能是在执行过程中发生了错误,或者是有新的事件发生导致 CPU 中断,下图可以高度概括这一过程:

当 CPU 检测到了异常,它会通过一系列的机制(后面会详细讲)将程序的控制权转移到异常处理模块中,根据处理的结果来做决定,最终有 3 种可能:

  1. 继续执行当前正在执行的指令 $I_curr$
  2. 执行下一条指令 $I_next$
  3. 终止执行,退出程序

异常处理

异常处理模块的定位是通过 异常表(Exception Handler) 来实现的,异常表的结构如下图所示:

异常表其实就是一个数组,每个元素是一个处理程序的地址。有了异常表,再借助运行时的寄存器,我们就可以定位到指定的异常处理程序:

需要注意的是,虽然异常处理最终也是通过程序完成,但是异常处理程序和普通程序有着本质的区别:

  1. 异常处理程序不会有任何的返回值
  2. 处理器在执行异常程序之前,会将一些额外信息放在程序栈上(比如进程的状态),在后面恢复用户程序时要用到
  3. 异常处理程序通常会使用内核的栈,而不是普通程序使用的栈
  4. 异常处理程序会在内核模式下运行

异常的分类

异常通常来说会被分为下面四类:

ClassCauseAsync/syncReturn behavior
InterrputSignal from I/O deviceAsyncAlways returns to next instruction
TrapIntentional exceptionSyncAlways returns to next instruction
FaultPotentially recoverable errorSyncMight return to current instruction
AbortNonrecoverable errorSyncNever returns

这里我们挨个来看,首先是 中断(Interrupt),这个应该比较常见,比如说当 CPU 接收到 I/O 设备的请求时,为了及时响应,CPU 会先停下手中的活,先去处理 I/O 的请求,下图可以概括这一过程:

可以看到的是,中断只会发生在当前指令执行完成后,程序仅仅是被暂停了,当中断处理完成后,程序继续正常执行,并且程序的状态均不受影响。

第二个种异常是 陷阱(Trap),这种异常是由于程序主动调用异常处理程序所导致的,下图可以概括这一过程:

这种异常主要是为用户程序提供一个调用系统级函数方式,我们后面要讲到的 信号 就是 Linux 系统提供的一系列系统调用接口。

第三种异常是 故障(Fault),发生的原因主要是因为指令在执行的过程中发生了不确定因素,需要操作系统的介入,下图可以概括这一过程:

这种异常有两种结果,一是操作系统解决了不确定因素,将控制权转交回用户程序,二是操作系统认为当前的故障无法恢复,于是终止程序。一个比较常见的故障就是 缺页异常(Page Fault),其实就是程序没法在内存中获取需要的信息,需要操作系统的介入,将所需信息从硬盘中拷贝到内存,这个我们会在虚拟内存中详细讲解。

最后一种异常是 终止(Abort),这个就是当程序中发生了不可挽救的错误,最终程序会被终止执行:

进程

由于异常处理机制牵扯到 CPU 的执行和切换,这里就必须要弄清楚 进程(Processes) 这一概念,没错,我们平常所听到的进程只是一个抽象的概念,它其实并没有对应的实体。

在这个概念下,我们会觉得我们的程序好像一直在被 CPU 执行,并且我们的程序有着独有的内存空间(Private Address Space)。

逻辑控制流

要知道 CPU 每段时间只能执行一条指令,也就是说 CPU 每段时间只能执行某一个程序(进程),但是为什么我们可以同时运行多个程序,并且这些程序也都在被执行,这是因为 CPU 在不同的进程间做切换,如下图所示:

可以看到,每当某个程序被执行时,其他程序都会被暂停(中断),CPU 在这些程序之间做切换,保证在一定的时间内,所有的程序都会被执行到,因为 CPU 来回切换的速度很快,给我们一种错觉好像所有的进程都在执行。

这里要提到两个关于时间的概念,一个是 CPU 时间,另一个是 进程的执行时间。对于一个进程来说,CPU 时间指的是 CPU 真正花在该进程上的时间,而进程的执行时间指的是进程从开始运行到结束中间相隔的时间。可以看到进程的执行时间肯定是超过 CPU 时间的。这在宏观上也很好理解,一个程序的运行时间往往是不固定的,比如在一个时间段,计算机运行的程序很多,CPU 繁忙,那么分到每个程序的 CPU 时间就会少,相应的程序运行时间就会变长。

在这两个时间的概念下,又会衍生出另外两个概念—— 并发(Concurrent)并行(Parallel)

并发指的是多个程序的执行时间重叠,比如说上图中的进程 ABC 就是属于并发,并发并不意味着 CPU 时间也要重叠,只要事件的执行时间重叠即可。

而并行的要求会更高,需要 CPU 时间和程序的执行时间都要有重叠,通常并行的实现需要多核的 CPU 或者多个计算机。

权限

每个进程都有着自己的独立的内存空间,结构都是像下面这样:

独立的内存空间下,每个进程的运行状态和运行时的数据不会被其他的进程获取,这方便了管理,可是很多时候进程间也需要相互通信,相互联络,这如何做到呢?

进程有两种运行时环境,第一种环境是用户环境,在这种环境下,进程只有普通权限,另外一种环境是内核环境(Kernel Environment),在这种环境下,进程拥有管理员权限。

在一般情况下,进程只有普通权限,只有当异常发生后,异常处理程序启动,这个时候程序才会在内核环境下运行,因此要想正常运行在内核环境,进程只能通过调用系统函数,也就是通过我们之前说的 陷阱(Trap) 的方式来进入异常处理程序,从而完成权限的切换。

当然,Linux 系统也提供了 /proc 文件系统,允许用户程序获取内核的数据。

上下文切换

像我们之前提到的那样,CPU 会在进程之间做切换来保证每个进程都会被执行到,但是每个程序有着不同的地址空间,有着不同的运行时数据,CPU 不能做简单的切换,在切换前还需要保存正在运行的进程的上下文,方便往后回过头来继续执行。

当 CPU 要执行另外一个进程时,有下列步骤:

  1. 保存当前进程的执行上下文
  2. 恢复准备执行的进程的上下文
  3. 将控制权从当前进程转移到准备执行的进程

下图可以大致概括这一过程:

从上图,我们可以清楚地看到,上下文的切换时需要消耗 CPU 时间的,重要的是,这个时间并没有花在任何一个进程上,因而我们应该尽量避免创建过多的进程,否则整体的执行效率会下降。

包装函数

Linux 系统为我们提供了很多的系统调用函数,通过这些函数我们可以和操作系统内核沟通,而这些函数比较底层也比较原始,很多时候使用起来并不友好,比如它们没有任何的错误提示。这里我们可以将这些函数稍作封装,拿非常常用的 fork 函数举例(这个函数是做什么的后面会具体讲到)。比如在每次使用 fork 时,我们都需要手动检查,如果有错误的话,需要进行输出并退出程序的执行:

1
2
3
4
if ((pid = fork()) < 0) {
  fprintf(stderr, "fork error: %s\n", strerror(errno));
  exit(0);
}

我们可以将上述逻辑封装起来,这样使用的时候会更加的友好:

1
2
3
4
5
6
7
void unix_error(char *msg) { /* Unix-style error */
  fprintf(stderr, "%s: %s\n", msg, strerror(errno));
  exit(0);
}

if ((pid = fork()) < 0)
  unix_error("fork error");

我们还可以扩大封装的范围,不仅仅对错误处理封装,而是对整个 fork 的逻辑进行封装:

1
2
3
4
5
6
7
pid_t Fork(void){
  pid_t pid;

  if ((pid = fork()) < 0)
    unix_error("Fork error");
  return pid;
}

现在我们只需要执行一条 pid = Fork() 指令就可以完成之前所有的逻辑。这里的 Forkfork 的包装函数,前者在后者的基础上增加了错误处理流程。

进程控制

通过上面对进程的讲解,我们可以知道进程有下面三个状态:

  1. 运行:进程当前正在被 CPU 执行
  2. 暂停:进程被挂起,CPU 此时在做其他事情,没有执行该进程
    • 当进程接收到到 SIGSTOPSIGTSTPSIGTTIN 或者 SIGTTOU 信号时(信号后面会讲到),进程会被暂停,直到它收到 SIGCONT 时,才会恢复执行
  3. 终止:进程被终止,也就是说 CPU 此时或者以后都不会考虑执行该进程,有三个原因会导致进程终止:
    • 收到终止进程的信号
    • main 函数中返回
    • 调用 exit 系统函数

用户程序如何对进程进行控制呢?答案是通过 Linux 系统提供的接口,下面我们会介绍一些比较常见的系统调用函数。

获取进程 ID

每个进程都有唯一的 ID 用来标识进程,下面这两个函数可以获取进程自身的 ID,以及其父进程的 ID:

1
2
3
4
5
6
7
8
#include <sys/types.h>
#include <unistd.h>

// returns the PID of the calling process
pid_t getpid(void);

// returns the PID of its parent
pid_t getppid(void);

Linux 中 pid_t 的数据类型为 int

退出

我们前面说过,有三种情况会终止进程的执行,其中就是调用系统函数 exit

1
2
3
4
#include <stdlib.h>

// This function does not return
void exit(int status);

exit 函数可以手动传入并设定退出状态 status,另外一种设定退出状态的方式是设定 main 函数的返回值。

创建新进程

我们可以通过 fork 函数在一个进程中创建另一个进程,这也是非常常见的一个系统调用函数:

1
2
3
4
5
#include <sys/types.h>
#include <unistd.h>

// Returns: 0 to child, PID of child to parent, −1 on error
pid_t fork(void);

需要注意的有下面两点:

  • fork 函数被调用一次,但是返回两次,一次是在父进程中(也就是调用 fork 的进程),另一次是在子进程(也就是新创建的进程)。在父进程中返回值为新创建进程的 ID,在子进程中返回值为 0
  • 新创建的进程拥有自己的独立内存空间,但是其空间内的数据全部是从父进程拷贝而来的,所以,新创建的进程除了 ID 和父进程不一样,其余的基本相同

下面这个例子是一个简单的 fork 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
  pid_t pid;
  int x = 1;
  pid = Fork();
  if (pid == 0) { /* Child */
    printf("child : x=%d\n", ++x); // child : x=2
    exit(0);
  }

  /* Parent */
  printf("parent: x=%d\n", --x); // parent: x=0
  exit(0);
}

可以看到,我们需要通过 fork 的返回值来确定当前执行程序的进程是子进程还是父进程,然后执行不一样的逻辑。

因为 fork 函数调用一次,返回两次这样的特性,很多时候如果在程序中多次调用 fork,我们从直觉上很难弄清楚进程和进程的对应关系,我们需要借助类似于下面的流程图(对应于上面的样例程序):

上面这个例子可能比较简单,我们再来看看下面这个:

1
2
3
4
5
6
int main() {
  Fork();
  Fork();
  printf("hello\n");
  exit(0);
}

上面的程序调用了两次嵌套的 fork,用流程图表示如下:

可以看到,短短的四行代码最后有四个进程,如果程序再复杂些,如果不借助流程图我们是很难缕清每个进程的执行过程,流程图很多时候可以辅助我们 debug 以及思考。

另外需要指出的是,fork 下的进程是并发的,比如上图中的四个 printf 的执行先后次序并不唯一,理论来说你可以把流程图当作一个有向图,每个节点发生的先后次序只要满足 拓扑排序,那么都是有可能的。

进程的回收

当一个进程被终止后,操作系统内核并不会马上将其所占资源(比如执行上下文,内存空间)从系统中移除,只有当该进程的父进程调用系统函数回收了该进程,该进程才会真正地从系统中消失。对于已经被终止了,但是未被回收的进程常常被称为 僵尸进程,想来也很形象,已经不会被执行了(没有生命特征),但是还占据着资源(尸体还在)。

如果说一个僵尸进程的父进程也被终止了,那么操作系统就会通过 init 进程来对终止的父子进行收割,这里的 init 进程是由操作系统在系统启动时创建的初始化进程,这个进程的 ID 为 1,也就是系统上的第一个进程,它是所有进程的祖先,不会被终止。

进程的回收主要是通过 waitpid 来完成的:

1
2
3
4
5
#include <sys/types.h>
#include <sys/wait.h>

// Returns: PID of child if OK, 0 (if WNOHANG), or −1 on error
pid_t waitpid(pid_t pid, int *statusp, int options);

waitpid 比较灵活多变,它有三个输入参数,我们挨个来看。

第一个参数 pid 是等待集合,有下面两种情况:

  • pid > 0:表明说当前进程只需要等待子进程 pid 即可
  • pid = -1:表明说当前等待集合包含该进程的所有子进程

只要等待序列中有任何一个子进程终止或者已经终止,那么 waitpid 就会返回终止进程的 ID,并且该进程会被回收,内核会删除掉它的所有痕迹。

第二个参数 statusp 是回收子进程的推出状态,如果这个参数不为空,那么 waitpid 就会在 status(statusp 所指向的值)中放上导致返回的子进程的状态信息。在 wait.h 中定义了解释 status 的几个宏,常见的如下:

  • WIFEXITED(status):返回 true 如果子进程正常终止(通过调用 exit 或者是返回)
  • WEXITSTATUS(status):返回正常终止的子进程的推出状态(status),仅仅当 WIFEXITED() 返回 true 时, status 才会被定义
  • WIFSIGNALED(status):返回 true 如果子进程是因为没有捕获的信号而终止的
  • WTERMSIG(status):返回导致子进程终止的信号的编号,只有在 WIFSIGNALED() 返回为 true 时才会定义这个状态
  • WIFSTOPPED(status):如果引起 waitpid 返回的子进程当前是终止的,那么就返回 true
  • WSTOPSIG(status):返回导致子进程终止的信号的编号,只有在 WIFSTOPPED() 返回为 true 时才会定义这个状态
  • WIFCONTINUED(status):如果子进程收到 SIGCONT(重新启动)的信号,则返回 true

第三个参数可以修改 waitpid 的默认行为,这个参数的选型可以基于类似下列常项或是常项的组合:

  • WNOHANG:如果等待集合中的任何子进程都没有终止,那么就立即返回(返回值为 0)
  • WUNTRACED:挂起当前进程,直到等待集合中的某个进程被终止或者被暂停
  • WCONTINUED:挂起当前进程,直到等待集合中的某个正在运行的进程被终止或者被暂停,或者是等待集合中的被暂停的进程收到 SIGCONT 信号重新开始执行
  • WNOHANG | WUNTRACED: 如果等待集合中的任何子进程都没有终止,那么就立即返回(返回值为 0),或是有一个停止或终止,则返回该子进程的 PID

这个函数的执行也需要分情况来看:

  • 如果当前进程没有子进程,那么 waitpid 会直接返回 -1 并设置全局错误变量 errno = ECHILD
  • 如果 waitpid 函数在执行过程中被信号打断,那么它会返回 -1 并设置全局错误变量 errno = EINTR

wait 函数是 waitpid 的简化版本,调用 wait(&status) 等同于 waitpid(-1, &status, 0)

1
2
3
4
5
#include <sys/types.h>
#include <sys/wait.h>

// Returns: PID of child if OK or −1 on error
pid_t wait(int *statusp);

下面是个使用 waitpid 函数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "csapp.h"
#define N 2

int main() {
  int status, i;
  pid_t pid;
  /* Parent creates N children */
  for (i = 0; i < N; i++)
    if ((pid = Fork()) == 0) /* Child */
      exit(100+i);

  /* Parent reaps N children in no particular order */
  while ((pid = waitpid(-1, &status, 0)) > 0) {
    if (WIFEXITED(status))
      printf("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
    else
      printf("child %d terminated abnormally\n", pid);
  }

  /* The only normal termination is if there are no more children */
  if (errno != ECHILD)
    unix_error("waitpid error");

  exit(0);
}

上面的程序一开始通过 for 循环调用两次 fork 函数,由于子进程直接调用 exit 退出,所以并不存在嵌套调用。当前进程执行完 fork 后,会开始回收子进程,通过执行我们发现这个回收的顺序是不固定的,画个流程图也好理解。

那如果要按顺序回收程序该如何修改呢?其实只需要更改 waitpid 的传入参数即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "csapp.h"
#define N 2

int main() {
  int status, i;
  pid_t pid[N], retpid;

  /* Parent creates N children */
  for (i = 0; i < N; i++)
    if ((pid[i] = Fork()) == 0) /* Child */
      exit(100+i);

  /* Parent reaps N children in order */
  i = 0;
  while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
    if (WIFEXITED(status))
      printf("child %d terminated normally with exit status=%d\n", retpid, WEXITSTATUS(status));
    else
      printf("child %d terminated abnormally\n", retpid);
  }

  /* The only normal termination is if there are no more children */
  if (errno != ECHILD)
    unix_error("waitpid error");

  exit(0);
}

进程的休眠

休眠函数让进程暂停指定时间:

1
2
3
4
#include <unistd.h>

// Returns: seconds left to sleep
unsigned int sleep(unsigned int secs);

另外一个函数可以一直暂停进程直到收到了恢复的信号,这个比 sleep 要实用,因为很多时候我们并不清楚需要暂停多久,并且往往实际情况是动态变化的:

1
2
3
4
#include <unistd.h>

// Always returns −1
int pause(void);

加载和运行程序

进程的一个很重要的目的就是运行实际的用户程序,这一点我们可以通过调用 execve 系统函数来实现,函数定义如下:

1
2
3
4
#include <unistd.h>

// Does not return if OK; returns −1 on error
int execve(const char *filename, const char *argv[], const char *envp[]);

这个函数会加载并运行可执行文件 filename,并传入参数列表 argv,以及环境变量列表 envpexecve 函数的特点是调用一次,除非有错误否则不会返回,按照惯例,argv[0] 是可执行文件的名字。

另外,Linux 也提供了一些操作环境变量数组的函数:

1
2
3
4
5
6
7
8
9
10
#include <stdlib.h>

// Returns: pointer to name if it exists, NULL if no match
char *getenv(const char *name);

// Returns: 0 on success, −1 on error 
int setenv(const char *name, const char *newvalue, int overwrite);

// Returns: nothing
void unsetenv(const char *name);

在实际的应用中,execve 会结合 fork 来使用,比如下面这个程序实现了一个简易版的 shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include "csapp.h"
#define MAXARGS 128

/* Function prototypes */
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);

int main() {
  char cmdline[MAXLINE]; /* Command line */
  while (1) {
    /* Read */
    printf("> ");
    Fgets(cmdline, MAXLINE, stdin);
    if (feof(stdin))
      exit(0);
    /* Evaluate */
    eval(cmdline);
  }
}

/* eval - Evaluate a command line */
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;
}

/* If first arg is a builtin command, run it and return true */
int builtin_command(char **argv) {
  if (!strcmp(argv[0], "quit")) /* quit command */
    exit(0);

  if (!strcmp(argv[0], "&")) /* Ignore singleton & */
    return 1;

  return 0; /* Not a builtin command */
}

/* parseline - Parse the command line and build the argv array */
int parseline(char *buf, char **argv) {
  char *delim; /* Points to first space delimiter */
  int argc; /* Number of args */
  int bg; /* Background job? */
  buf[strlen(buf)-1] = ' '; /* Replace trailing ’\n’ with space */

  while (*buf && (*buf == ' ')) /* Ignore leading spaces */
  buf++;

  /* Build the argv list */
  argc = 0;
  while ((delim = strchr(buf,  ))) {
    argv[argc++] = buf;
    *delim = ’\0;
    buf = delim + 1;
    while (*buf && (*buf ==  )) /* Ignore spaces */
      buf++;
  }

  argv[argc] = NULL;
  if (argc == 0) /* Ignore blank line */
    return 1;
  /* Should the job run in the background? */
  if ((bg = (*argv[argc-1] == &)) != 0)
    argv[--argc] = NULL;

  return bg;
}

需要注意的是,上面的程序其实是有缺陷的,因为它没有回收子进程的逻辑。

信号

在前面通过对进程的学习,我们知道信号在进程之间的通信起到重要作用,很多时候,进程也是通过信号来改变自身的状态。信号其实是 Linux 系统下的一个概念,因为硬件层面的异常控制流程通常是对普通用户不可见的,而信号提供了一个机制来让用户程序也能清楚知道异常的发生,从而做执行相对应的逻辑。

Linux 提供了下面这些信号:

NumberNameDefault actionCorresponding event 
1SIGHUPTerminateTerminal line hangup 
2SIGINTTerminateInterrupt from keyboard 
3SIGQUITTerminateQuit from keyboard 
4SIGILLTerminateIllegal instruction 
5SIGTRAPTerminate and dump coreTrace trap 
6SIGABRTTerminateand dump coreAbort signal from abort function
7SIGBUSTerminateBus error 
8SIGFPETerminate and dump coreFloating-point exception 
9SIGKILLTerminateKill program 
10SIGUSR1TerminateUser-defined signal 1 
11SIGSEGVTerminate and dump coreInvalid memory reference (seg fault) 
12SIGUSR2TerminateUser-defined signal 2 
13SIGPIPETerminateWrote to a pipe with no reader 
14SIGALRMTerminateTimer signal from alarm function 
15SIGTERMTerminateSoftware termination signal 
16SIGSTKFLTTerminateStack fault on coprocessor 
17SIGCHLDIgnoreA child process has stopped or terminated 
18SIGCONTIgnoreContinue process if stopped 
19SIGSTOPStop until next SIGCONTStop signal not from terminal 
20SIGTSTPStop until next SIGCONTStop signal from terminal 
21SIGTTINStop until next SIGCONTBackground process read from terminal 
22SIGTTOUStop until next SIGCONTBackground process wrote to terminal 
23SIGURGIgnoreUrgent condition on socket 
24SIGXCPUTerminateCPU time limit exceeded 
25SIGXFSZTerminateFile size limit exceeded 
26SIGVTALRMTerminateVirtual timer expired 
27SIGPROFTerminateProfiling timer expired 
28SIGWINCHIgnoreWindow size changed 
29SIGIOTerminateI/O now possible on a descriptor 
30SIGPWRTerminatePower failure 

我们之前提到过,信号其实就是异常的一种(Trap),信号处理的流程也和异常一致,如下图所示:

发送信号

这里我们引入一个进程组的概念,每个进程归属于一个进程组,进程组和进程一样也是通过整形的 ID 来标识,下面的这个 getpgrp 函数返回当前进程所属的进程组:

1
2
3
4
#include <unistd.h>

// Returns: process group ID of calling process
pid_t getpgrp(void);

默认,新创建的子进程归属于父进程所属的进程组。另外,一个进程也可以通过 setpgid 来更改自身或者其他进程的进程组:

1
2
3
4
#include <unistd.h>

// Returns: 0 on success, −1 on error
int setpgid(pid_t pid, pid_t pgid);

这个函数有两个参数,分别表示要更改的进程 ID,以及所要设置的进程组 ID,另外还有下面两个细节:

  • 如果 pid == 0,那么 setpgid 将会作用于当前的进程
  • 如果 pgid == 0,那么就用 pid 指定的进程的 ID 作为进程组 ID

信号是如何发送的呢?比如下面这个命令:

1
$> /bin/kill -9 15213

/bin/kill 是一个程序,这个程序可以发送指定的任意信号给其他的进程。而 Linux 的 shell 会使用这种方式来创建进程,在 shell 中,任何时候最多只有一个前台作业,可以有 0 个或者多个后台作业,如下图所示:

也正如我们所熟知的那样,按下 Ctrl+C 会让内核发送 SIGINT 信号给前台作业组中的所有进程,通过上面的表格,我们可以知道这个信号会终止进程,所以这也是为什么我们可以通过这样的操作终止一个正在运行的程序。

另外,按下 Ctrl+Z 会使内核发送 SIGTSTP 信号给前台作业组中的所有进程,而这个信号仅仅只是暂停进程的执行,并没有终止。

Linux 也提供了发送信号给其他进程的系统函数:

1
2
3
4
5
#include <sys/types.h>
#include <signal.h>

// Returns: 0 if OK, −1 on error
int kill(pid_t pid, int sig);

在使用上,需要注意 pid

  • 如果 pid > 0,函数将会发送信号 sigpid 所表示的进程
  • 如果 pid == 0,函数将会发送信号 sig 给当前进程(也就是调用 kill 的进程)的进程组中的所有进程(包括当前进程)
  • 如果 pid < 0,函数将会发送信号 sig 给进程组 |pid| 中的所有进程

kill 可以和 fork 函数配合起来使用,让父进程发送信号给创建的子进程,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "csapp.h"

int main() {
  pid_t pid;
  /* Child sleeps until SIGKILL signal received, then dies */
  if ((pid = Fork()) == 0) {
    Pause(); /* Wait for a signal to arrive */
    printf("control should never reach here!\n");
    exit(0);
  }

  /* Parent sends a SIGKILL signal to a child */
  Kill(pid, SIGKILL);
  exit(0);
}

除此之外,Linux 也提供了进程给自身发送 SIGALRM 信号的函数

1
2
3
4
#include <unistd.h>

// Returns: remaining seconds of previous alarm, or 0 if no previous alarm
unsigned int alarm(unsigned int secs);

需要注意的是,调用 alarm 会使之前等待中的 alarm 被取消,并返回取消的 alarm 所剩余的时间,如果没有等待的 alarm,则返回 0。

接收信号

从上面的信号的定义表格我们可以看到,每种信号都有一个默认的触发行为(Default action),无外乎下面四种:

  • 将进程终止
  • 将进程终止并把内存的结果写到硬盘(dumps core)
  • 将进程挂起,直到收到了 SIGCONT 信号才继续执行
  • 进程忽略该信号

表格中定义的仅仅是接收到信号的默认行为,我们可以通过调用 signal 函数来修改这个默认行为:

1
2
3
4
5
#include <signal.h>
typedef void (*sighandler_t)(int);

// Returns: pointer to previous handler if OK, SIG_ERR on error (does not set errno)
sighandler_t signal(int signum, sighandler_t handler);

需要注意的是,SIGSTOPSIGKILL 两个信号的默认行为是不能被修改的。signal 函数可以通过设置 handler 来改变默认行为,有下面几种方式:

  • 如果 handler 是 SIG_IGN,那么 signum 类型的信号将会被忽略
  • 如果 handler 是 SIG_DFL,那么 signum 类型的信号将恢复默认行为
  • 如果不是上面两种,handler 也可以是用户定义的函数的地址,只要进程收到 signum 类型的信号就会调用这个函数

有了 signal 函数,信号的接收和处理变得非常的灵活,我们甚至可以在嵌套地定义信号的处理,比如下图所展示的:

可以看到,我们定义了信号处理函数 S,当 S 在运行的过程中如果收到了相关信号,S 会调用另外一个信号处理函数 T,在这样的嵌套机制下,我们可以很好地封装信号处理逻辑。

阻塞与解除阻塞信号

Linux 还提供了阻塞信号的机制,其中分为隐性和显性两种:

  • 隐性阻塞机制:默认情况下,内核会阻塞任何当前正在处理的信号,比如上图中,处理函数 S 如果已经接收到信号并在处理中,那么如果有相同类型的信号发过来,内核会直接忽略
  • 显性阻塞机制:用户的应用程序可以调用 Linux 提供的 sigprocmask 函数以及相关的辅助函数来明确地阻塞或解除阻塞选定的信号,函数定义如下:
1
2
3
4
5
6
7
8
9
10
11
#include <signal.h>

// Returns: 0 if OK, −1 on error
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);

// Returns: 1 if member, 0 if not, −1 on error
int sigismember(const sigset_t *set, int signum);

隐性机制说白了就是系统的设置,应用程序是无法改变的,清楚了这一特性后,我们就需要在设计我们的信号处理单元时考虑到这一点。

显性机制是一个工具,上面的函数所做的事情其实就是维护一个信号集合(set),sigprocmask 函数会定义这个集合中的信号是需要被阻塞的,还是说需要被解除阻塞的,其他的辅助函数是改变这个集合中的信号。具体我们来看一个例子,下面这个例子展示了如何用 sigprocmask 来临时阻塞接收 SIGINT 信号:

1
2
3
4
5
6
7
8
9
10
11
sigset_t mask, prev_mask;

Sigemptyset(&mask);
Sigaddset(&mask, SIGINT);

/* Block SIGINT and save previous blocked set */
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);

// Code region that will not be interrupted by SIGINT
/* Restore previous blocked set, unblocking SIGINT */
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

信号处理函数的实现

有了上面的一些知识,我们来看看如何实现一个信号处理函数。在这之前,我们先来看看想要写出一个安全不出错的信号处理函数,有哪些需要注意的地方:

  • 尽可能保证处理函数的简单:和普通的函数一样,信号处理函数也需要简单,这样可以方便维护
  • 只调用异步信号安全的函数:Linux 中定义了很多的函数,但是有些函数是可以被中断,有些不是线程安全的,在信号处理函数中调用这些函数,稍有不慎就会弄出 bug,并且这也不好 debug 和测试,Linux 中有下列函数是异步信号安全的,当然你也可以运用我们前面所提到的包装函数的方法对不安全的函数进行包装,让其变得安全。

  • 保存并恢复 erronerron 是一个全局变量,每当进程中有错误发生时,这个变量都会被修改,如果在信号处理函数中也对这个变量进行更改,那么就会干扰主程序以及其他依赖 erron 的部分。解决办法就是在进入信号处理函数时将其保存至一个局部变量,并在返回前恢复它
  • 阻塞所有的信号,保护对共享全局数据结构的访问:这个也是异步安全的概念,当一个程序需要访问一个数据结构,在访问前需要保证这个数据结构不被其他的程序访问,否则会出现数据不一致的后果
  • 用 volatile 声明全局变量volatile 会告诉编译器不需要缓存这个变量,这会限定在代码中每次引用该变量时,都需要从内存中读取,也是保证数据的一致性手段之一
  • 用 sig_atomic_t 声明标志sig_atomic_t 是一个 C 提供的整形数据类型,对它的读和写保证会是原子的(不可中断)

下面我们来看一个有问题的信号处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* WARNING: This code is buggy! */
void handler1(int sig) {
  int olderrno = errno;
  if ((waitpid(-1, NULL, 0)) < 0)
    sio_error("waitpid error");

  Sio_puts("Handler reaped child\n");
  Sleep(1);
  errno = olderrno;
}

int main() {
  int i, n;
  char buf[MAXBUF];
  if (signal(SIGCHLD, handler1) == SIG_ERR)
    unix_error("signal error");

  /* Parent creates children */
  for (i = 0; i < 3; i++) {
    if (Fork() == 0) {
      printf("Hello from child %d\n", (int)getpid());
      exit(0);
    }
  }

  /* Parent waits for terminal input and then processes it */
  if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
    unix_error("read");

  printf("Parent processing input\n");
  while (1)
    ;

  exit(0);
}

上面这个程序的 main 函数中将信号处理函数 handler1SIGCHLD 进行绑定,并用 fork 函数创建了三个子进程,子进程会直接退出,当前进程会继续运行。这里的问题出在 handler1 函数,这里并没有考虑并解决之前我们提到的隐性阻塞,也就是信号并不会排队等待这一情况,如果运行上面这段程序,会发现最终只有两个子进程被回收:

1
2
3
4
5
6
7
8
linux> ./signal1
Hello from child 14073
Hello from child 14074
Hello from child 14075
Handler reaped child
Handler reaped child
CR
Parent processing input

其实对 handler1 稍作修改,就可以得到期望的结果:

1
2
3
4
5
6
7
8
9
10
11
12
void handler2(int sig) {
  int olderrno = errno;

  while (waitpid(-1, NULL, 0) > 0) {
    Sio_puts("Handler reaped child\n");
  }

  if (errno != ECHILD)
    Sio_error("waitpid error");
  Sleep(1);
  errno = olderrno;
}

我们再来看看另外一个线程安全的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* 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);
}

同样,上面的程序也是有问题的,原因在于我们前面我们说过的,fork 函数会使当前进程与新创建出来的进程并发执行,因此,main 函数里 addjob 的执行可能晚于信号处理函数 handler 中的 deletejob,这显然不合理,会导致程序出错。

这里我们可以使用我们前面讲到的 sigprocmask 函数来阻塞指定信号,以达到同步的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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);
}

需要注意的是,子进程会复制父进程的所有信息,包括 sigprocmask 函数中的阻塞信号集合,所以在子进程调用 execve 时,需要解除阻塞。sigprocmask 在这里等同于给对应的代码逻辑上锁。

等待信号

一个常见的逻辑是,程序会等待某个信号的发生,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "csapp.h"

volatile sig_atomic_t pid;
void sigchld_handler(int s) {
  int olderrno = errno;
  pid = waitpid(-1, NULL, 0);
  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);
}

上面的程序通过循环判断全局变量 pid 来等待 SIGCHLD 信号,程序逻辑不存在问题,但是这个循环是非常消耗资源的,CPU 要不断地执行这没有任何作用的逻辑。

我们也可以考虑使用我们之前提到的暂停函数:

1
2
3
4
5
while (!pid) /* Race! */
  pause();

while (!pid) /* Too slow! */
  sleep(1);

但是这又会有其他的问题,首先 pause 函数不是线程安全的,这会让程序在处理并发上遇到问题,而 sleep 函数虽说是线程安全的,但是这里我们并不知道等待的具体时间,如果设定的等待时间长了,比如我们暂停 1 秒钟,但是信号 10 毫秒就过来了,那么我们就白白浪费了 990 毫秒,如果把时间设定的非常短,那么这跟一开始的空循环没有多大区别。

那么正确的做法是什么呢?我们其实可以将 sigprocmaskpause 组合起来:

1
2
3
4
5
while (!pid) {
  sigprocmask(SIG_BLOCK, &mask, &prev);
  pause();
  sigprocmask(SIG_SETMASK, &prev, NULL);
}

当然了,Linux 也提供了等价的系统函数:

1
2
3
4
#include <signal.h>

// Returns: −1
int sigsuspend(const sigset_t *mask);

非本地跳转

我们知道,类似 C++ 和 Java 这样的面向对象编程语言中,都有自带的异常处理机制,比如 try...catch,但是对于 C 这种比较原始,比较底层的语言,并没有这种机制,但是 C 中有一种类似的机制——非本地跳转。

这一机制的实现其实靠的是两对函数:

1
2
3
4
5
6
7
8
9
10
#include <setjmp.h>

// Returns: 0 from setjmp, nonzero from longjmps
// called once, return multiple
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs); // version that can be used by signal handler

// called once, Never returns
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval); // // version that can be used by signal handler

setjmp 函数会保存当前的调用环境到 env 缓冲区,以供后面的 longjmp 使用,并返回 0。longjmp 会从 env 缓冲区中恢复之前的调用环境,并触发一个从最近一次初始化 env 的 setjmp 调用的返回。

这么解释可能还是有点绕,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include "csapp.h"

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() -> foo() -> bar(),当发生错误时,可以通过一次 longjmp 调用从 setjmp 返回,setjmp 的非零返回值指明了错误类型,随后可以被解码,并在代码中的某个位置进行处理。

使用非本地跳转时需要注意,longjmp 函数很可能导致内存泄漏或是一些其他问题,我想这也是为什么 Java 中会提供 try...catch...finally 的机制。

另外一个有趣的例子是,一个程序通过设置非本地跳转实现,当用户按下 Ctrl+C,该程序会重新运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#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 */
}

值得注意的是这些非本地跳转函数均不是线程安全的,因此我们需要保证跳转的函数需要安全。

常用工具

Linux 提供了大量的监控和操作进程的工具,常用的有下面这些:

  • strace:打印每次系统调用的路径
  • ps:查看当前系统中的所有进程(包括僵尸进程)
  • top:打印当前进程所用到的资源信息
  • pmap:展示当前进程的内存图
  • /proc:一个虚拟文件系统,可以输出大量内核数据结构和内容,用户程序可以读取这些内容,比如 cat /proc/loadavg 可以看到你的 Linux 系统上当前的平均负载
This post is licensed under CC BY 4.0 by the author.