深度理解信号:透过系统 API 看「信号」

「信号」是操作系统中重要的进程间通信方式,熟悉信号的处理机制对学习操作系统十分重要。

本文通过对系统相关API的使用来深度理解系统编程的「信号」

信号的基本概念

信号是一种用来提醒进程一个事件已经发生的异步的通知机制,可以由进程发送,也可以由内核发送。

信号分为

  • 标准信号(同时发送多个同一信号时可能发生丢失)
  • 实时信号(通过队列实现信号的稳定处理)

本文主要研究1-31号(非实时信号)来理解信号。

信号的处理流程

信号的处理的实质是维护进程PCB中的特定数据:Block 表、Pending 表和 handler 表。
为了解释上述结构,首先明确以下概念

  • 抵达:信号发送成功后的处理动作
  • 未决:信号待处理的状态(将会在合适时机自动处理)
  • 阻塞:标记信号暂时不应被处理(”人为暂停某个信号的处理“)

一般来讲,信号产生后首先会进入未决状态。在合适的时机才会执行对应的默认方法(抵达)。
当一个信号进入阻塞态后,在阻塞状态被修改前永远不会执行对应的方法。

信号从产生到抵达总是伴随着 用户态与内核态的相互转换(高权限的任务由内核完成)


一般情况下的未决(没有被阻塞),正是表示信号产生「但内核还未转换到用户态」时的状态。

Block & Pending & handler

上述状态的改变体现在 PCB 中时,标记以 0、1 表示,如下图

上图说明了信号典型的三种情况:

  • 对于9号信号(第一行)
    • 产生后进入了未决状态(pending)。
    • 从内核态转为用户态时,信号会抵达,执行默认方法(SIG_DEF)
  • 对于2号信号(第二行)
    • 产生后由于处于阻塞态,在状态改变前永远不会被抵达。
    • 如果不在解除阻塞前修改 handler 表,抵达时将会执行忽略方法(不进行任何处理)。
  • 对于3号信号(第三行)
    • pending 标志为为 0 ,说明信号从未产生过,当信号产生时将被阻塞。
    • 如果不在解除阻塞前修改 handler 表,抵达时将会执行用户自定义函数。

1-13信号可以通过 kill -l 命令查询

从上述例子也可以明确:信号可选的处理方式有三种

  • 默认方法
  • 忽略
  • 用户自定义

信号 API

阻塞 ctrl C

我们常用的 ctrl c 组合键即是一个产生信号的方法,它可以用来提前结束正在运行的程序。
ctrl c 对应2号信号 SIGINT ,我们可以通过下列程序实现屏蔽该信号。

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
//代码接口解释见下文,下同
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void PrintSig(sigset_t *set)//打印信号集
{
for(int = 0; i < 32; i++)
{
if(sigisember(set,i))
{
putchar('1');
}
else
{
putchar('0');
}
putchar('\n');
}
}

int main(void)
{
sigset_s s, p;
sigemptyset(&s);//初始化信号集

sigaddset(&s, SIGINT);//添加信号到信号集
sigprocmask(SIG_BLOCK, &s, NULL);//添加到进程的阻塞集
while(1)
{
sigpending(&p);//读取未决信号集
printSig(&p);//打印未决信号集
sleep(1);
}
return 0;
}

上述代码中

  • 变量 sigset_t 表示信号集,用来表示信号的阻塞/未决的有效状态
    • 作为阻塞信号集时,也称作当前进程的信号屏蔽字
  • 信号集操作函数
    • sigemptyset(sigset_t *set);初始化set指向的信号集(清零对应bit)
    • sigaddset(sigset_t *set, int signo);添加指定信号到该信号集
    • sigismember(const sigset_t *set, int signo);顾名思义,判断是否信号是否存在于信号集
  • int sigprocmask(int how, const sigset_t *set, sigset_t *oset);读取或更改信号屏蔽字
    • 成功返回0,失败返回-1
    • 该函数根据参数分为三种情况
      • 仅oset为非空时,读取信号屏蔽字到oset
      • 仅set为非空时,参考how的值,根据set更改进程的信号屏蔽字
      • set、oset都不为空时,先拷贝set到oset,再参考how根据set更改进程的信号屏蔽字
    • how 有3个取值
      • SIG_BLOCK 表示添加set中的信号到进程的信号屏蔽字
      • SIG_UNBLOCK 表示从进程的的信号屏蔽字解除阻塞set中的信号
      • SIG_SETMASK 表示修改信号屏蔽字为set中的值
  • sigpending(sigset_t *set);读取当前进程的未决信号集到set

捕捉信号并自定义处理函数

signal

在信号抵达时调用用户自定义的函数,叫做捕捉信号。
捕捉信号的流程见本文图1,值得强调的是:自定义函数与main函数不在一个堆栈
捕捉信号使用signal(int signo, sighandler_t handler);前者是一个信号,后者是函数指针

1
signal(SIGALRM, fuc);

sigaction

我们更推荐使用接口 sigaction自定义处理动作

  • sigaction(int signo, const struct sigaction *act, sturct sigaction *oact);
    • 成功返回0,失败返回-1
    • 根据参数分为两种情况
      • 仅act非空时,根据act修改信号的处理动作
      • 仅oact非空时,拷贝信号原本的处理动作到oact
      • 都非空时,处理逻辑类似sigprocmask

对于参数struct sigaction,unix下的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct  sigaction {
union __sigaction_u __sigaction_u; /* signal handler */
sigset_t sa_mask; /* signal mask to apply */
int sa_flags; /* see signal options below */
};

union __sigaction_u {
void (*__sa_handler)(int);
void (*__sa_sigaction)(int, siginfo_t *,
void *);
};

#define sa_handler __sigaction_u.__sa_handler
#define sa_sigaction __sigaction_u.__sa_sigaction

其中,在非实时信号下,sa_flags值为0

signal对比的代码参考

1
2
3
4
5
6
struct sigacton act;

act.sa_handler = fuc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, &oact);

此外,我们更推荐使用sigaction是由于

  • sigaction可以备份之前的信息;signal不可以
  • sigaction可以处理实时信号;signal不可以

模拟sleep

sleep是系统提供的“暂停进程”的接口函数,通过模拟sleep,进一步理解信号捕捉

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
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void fuc(int signo)//如果不自定义处理函数,SIGALRM信号会使进程终止
{}

unsigned int _sleep(unsigned int seconds)
{
struct sigaction act,oact;
unsigned int ret = 0;

//依次初始化act内成员变量
act.sa_handler = fuc();
sigemptyset(&act);
act.sa_flags = 0;

sigaction(SIGALRM, &act, &oact);//注册信号处理函数
alarm(seconds);//设定闹钟
pause();//挂起
ret = alarm(0);//收到信号,清空闹钟
sigaction(SIGALRM, &oact, NULL);//回复该信号的默认处理函数
return ret;
}

int main(void)
{
printf("ready to set a alarm for 5 seconds");
_sleep(5);
printf("5 seconds passed");
return 0;
}
  • unsigned int alarm(unsigned int seconds);
    • 使内核在seconds秒后向当前进程发送SIGALRM信号,默认处理动作是终止当前进程
    • 返回值为当前闹钟余下的seconds
    • 若参数second为0,表示取消之前的闹钟,返回值为之前闹钟余下的秒
  • int pause(void);
    • 调用进程挂起,直到有信号抵达。
    • 返回值分三种情况
      • 信号处理动作是终止进程,则pause无返回的机会
      • 信号处理动作是忽略,则进程保持挂起,pause不返回
      • 信号处理动作是捕捉,则调用处理函数后pause返回-1,errno值为EINTR(“被信号中断”)

竞态条件与抵达间隙

上述_sleep函数存在一个Bug:
由于可能存在的异步事件(出现更高优先级的进程),在alarm函数执行后进程被更高优先级的进程取代,导致pause不一定在seconds秒内被调用(调用时闹钟已经超时,SIGALRM已经被处理)

上述Bug的本质原因是alarmpause执行之间被意外挂起,读者应立即联想到原子操作。

原子操作:要么一次做完所有动作,要么不做

系统提供了一个API:

  • int sigsuspend(const sigset_t *sigmask);
    • 执行信号处理函数后返回-1,errno为EINTR
    • 通过信号集参数替代当前进程屏蔽字,然后挂起等待。函数返回时,信号屏蔽字恢复

由此,有一个典型的解决方案是:
在SIGALRM信号产生前(alarm执行前)屏蔽该信号
pause执行前解除屏蔽,并使得pause和与解除屏蔽成为一个原子操作(sigsuspend
改进后的代码如下(部分)

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
unsigned int _sleep(unsigned int seconds)
{
struct sigacton act, oact;
sigset_t mask, omask, suspmask;
unsigned int ret = 0;

//信号捕捉
act.sa_handler = fuc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, &oact);

//添加到进程的信号屏蔽字
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
sigprocmask(SIG_BLOCK, &mask, &omask);

alarm(seconds);

suspmask = omask;
sigdelset(&suspmask, SIGALRM);//拷贝一个信号集,不包含SIGALRM
sigsuspend(&suspmask);//临时解除屏蔽并挂起

ret = alarm(0);
sigaction(SIGALRM, &oact, NULL);//恢复默认处理函数
sigprocmask(SIG_BLOCK, &omask, NULL);//取消阻塞信号
}

利用SIGCHLD信号清理僵尸进程

僵尸进程及其危害

  • 每个进程退出的时候,内核将释放该进程所有的资源,包括打开的文件,占用的内存等
    退出后的进程,内核仍然为其保留一定的信息(包括进程号、退出状态、运行时间等

  • 后果:上述信息直到父进程通过wait / waitpid来取时才释放,如果没有调用,那么保留的那段信息就不会释放,其进程号就会一直被占用

  • 危害:系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程

一般通过两种方式清理僵尸进程,但都不能最大化利用系统资源

  • 轮询方式
    • 父进程处理自己工作的同时,不断地询问子进程是否结束
  • 阻塞父进程
    • 父进程阻塞自己,直到子进程结束

利用信号,可以有更好的解决办法

子进程退出时会向父进程发送信号SIGCHLD,默认处理动作是忽略,可以通过信号完成指令执行。

在Linux下,还可以使用sigaction将SIGCHLD处理动作设定为SIG_IGN,fork出的子进程会在终止时自动清理,不会产生僵尸进程。
系统默认的忽略和用户自定义的忽略一般没有区别,但这是一个特例。