CSAPP第八章信号部分

信号基本含义

linux中信号就是一段消息,通知进程发生了某些事情,类比软件层面的异常(软中断)。

信号是一种软件形式的异常,允许进程和内核中断其他进程,可以用来通知用户进程发生了某些异常

Linux系统基本信号及其行为

常见的信号有:

  • SIGINT(利用ctrl+c键发送给前台组的每个进程,默认是终止进程)
  • SIGCHLD(子进程终止时,内核会发送这个信号给父进程)

信号术语

发送信号:内核更新进程的某个上下文;

接收信号:一个进程可以忽略一个信号,或者执行一段处理程序。

内核为每个进程维护了两个位向量,一个用来标记待处理信号pending,一个标记被阻塞信号blocked。只要传送了一个类型为k的信号,那么进程的pending的第k位就会设置为1。

这个实现有什么问题?一个信号没有被及时处理,那么后面几个信号就会丢失。对一个进程而言,同一时刻,同种类型的信号最多只能有一个处于待处理状态,多余的会被直接抛弃。

发送信号

在了解发送信号的方式前,需要了解两个概念:进程组和前后台作业

进程组

每个进程都属于且只属于一个进程组,一个进程组由一个正整数标识。

前后台作业

作业这个概念其实就是进程。不过作业是加了限定词的进程:unix shell用作业表示对一条命令行求值而创建的进程。

在任何时刻,至多有一个前台作业和0至多个后台作业。

发送信号的方式

(1)/bin/kill程序

linux> /bin/kill -9 15213 表示发送信号9(SIGKILL)给进程24818

linux> /bin/kill -9 -15213 表示发送信号9(SIGKILL)给进程组24818

(2)键盘发送

Crtl+C会导致内核发送一个SIGINT信号到前台进程组中的每一个进程。

(3)标准库接口

可以在函数中调用kill函数来对目的进程发送信号

1
2
3
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

alarm函数发送SIGALARM信号

1
2
#include <unistd.h>
unsigned int alarm(unsigned int secs);

接收信号

接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。

接收信号的时机:内核把进程从内核模式切换到用户模式时(例如系统调用返回),它会检查进程的未被阻塞的待处理信号的集合(pending & ~blocked),如果集合非空,那么就会选择某个信号k,强制进程处理信号k。

接收信号的默认行为:

  • 进程终止
  • 忽略该信号
  • 通过用户层函数信号处理程序捕获这个信号

修改信号的默认行为

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

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

signum为信号序号,handler为信号处理程序。我们可以使用自定义的信号处理程序,也可以使用预定义的几个处理程序(SIG_IGN进程忽略该信号、SIG_DFL恢复该信号的默认行为)。

用户自定义的信号处理程序要遵从sighandler_t的信号处理原型,接收一个整数入参,无出参。

例子:

1
2
3
4
5
6
7
8
9
10
#include <signal.h>
void handler(int sig){
if((waitpid(-1, NULL, 0)) < 0)
unix_error("waitpid error");
}
int main(){
if(signal(SIGCHLD, handler) == SIG_ERR)
unix_error("signal error");
return 0;
}

这里只要在main函数开始调用一次signal,就相当于从此以后改变了SIGCHLD信号的默认行为,让它去执行handler处理程序。当子进程终止或停止时,内核就会发送一个SIGCHLD信号到父进程中,此时就能让父进程去执行自己的工作,当子进程终止或停止时,发送SIGCHLD信号到父进程,则父进程会调用handler函数来对该子进程进行回收。

阻塞信号

Linux提供阻塞信号的隐式和显示的机制:

  • 隐式阻塞机制:内核默认阻塞当前正在处理信号类型的待处理信号。
  • 显示阻塞机制:应用程序通过sigprocmask函数来显示阻塞和解阻塞选定的信号。
1
2
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

通过how来决定如何改变阻塞的信号集合blocked

  • how=SIG_BLOCK时,blocked = blocked | set

  • how=SIG_UNBLOCK时,blocked = blocked & ~set

  • how=SETMASK时,block = set

编写健壮的信号处理程序

如何编写安全、正确和可移植的信号处理程序?

并发认知

信号也是并发的一个例子,信号处理程序是一个独立的逻辑流(不是进程),与主程序并发运行。比如我们在进程A中执行一个while循环,当该进程受到一个信号时,内核会将控制权转移给该信号的处理程序,所以该信号处理程序是并发执行的,当信号处理程序结束时,再将控制转移给主程序。由于信号处理程序与主程序在同一进程中,所以具有相同的上下文,所以会共享程序中的所有全局变量。

原则

  • 处理程序尽可能简单
  • 处理程序使用异步信号安全的函数

所谓异步信号安全,要么可重入,要么不可中断。

  • 保存和恢复errno
  • 暂时阻塞所有信号,保护对全局数据结构的访问
  • volatile声明全局变量
  • sig_atomic_t 声明全局标志,确保原子操作

正确的信号处理

信号位向量的特性导致同类型信号丢弃现象

处理方法:一次信号处理函数调用尽可能多的处理信号

可移植的信号处理

不同的系统有不同的信号处理语义:

  • signal函数的语义各有不同。
  • 系统调用可以被中断。

要解决这些问题,定义了sigaction函数,它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。

1
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact)

信号同步

案例一:父子进程竞争

场景:子进程结束出发SIGCHLD信号,导致信号处理函数先运行,造成同步错误。

解决方法:在fork之前,阻塞SIGCHLD信号,等到某个特定位置运行完,再解锁信号。

启发:如果想要确保某些code(比如addjob)能在信号handler之前被运行,那么就阻塞该信号,直到该code执行完毕。

缺点:子进程需要额外解锁该信号。

案例二:显式等待某信号

场景:父进程等待子进程执行完毕。

1
2
while(!pid)
;

浪费CPU

1
2
while(!pid)
pause();

死锁风险:在while后收到信号,然后pause,永远收不到信号,无法返回。

1
2
while(!pid)
sleep(1);

sleep无法确定睡眠间隔

最佳实践:

1
2
while(!pid)
sigsuspend(&prev);

解读sigsuspend:

  • 从语义上看,表示对某信号延迟处理,等待一段时间
  • 从代码上来看,sigsuspend(&mask)等价于原子版本的以下三句组合:
1
2
3
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

其中原子性是第一句和第二句,这两者之间的执行不会被中断。

实际使用

在调用sigsuspend前阻塞SIGCHLD信号

在调用sigsuspend时取消阻塞SIGCHLD信号(利用mask),那么SIGCHLD信号都会被等到pause执行完处理,然后又恢复阻塞SIGCHLD信号。

作者

Desirer

发布于

2024-02-17

更新于

2024-02-18

许可协议