「信号」是操作系统中重要的进程间通信方式,熟悉信号的处理机制对学习操作系统十分重要。
本文通过对系统相关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 | //代码接口解释见下文,下同 |
上述代码中
- 变量 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 | struct sigaction { |
其中,在非实时信号下,sa_flags值为0
与signal
对比的代码参考
1 | struct sigacton act; |
此外,我们更推荐使用sigaction
是由于
sigaction
可以备份之前的信息;signal
不可以sigaction
可以处理实时信号;signal
不可以
模拟sleep
sleep是系统提供的“暂停进程”的接口函数,通过模拟sleep,进一步理解信号捕捉
1 |
|
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的本质原因是alarm
与pause
执行之间被意外挂起,读者应立即联想到原子操作。
原子操作:要么一次做完所有动作,要么不做
系统提供了一个API:
int sigsuspend(const sigset_t *sigmask);
- 执行信号处理函数后返回-1,errno为EINTR
- 通过信号集参数替代当前进程屏蔽字,然后挂起等待。函数返回时,信号屏蔽字恢复
由此,有一个典型的解决方案是:
在SIGALRM信号产生前(alarm
执行前)屏蔽该信号
在pause
执行前解除屏蔽,并使得pause
和与解除屏蔽成为一个原子操作(sigsuspend
)
改进后的代码如下(部分)
1 | unsigned int _sleep(unsigned int seconds) |
利用SIGCHLD信号清理僵尸进程
僵尸进程及其危害
每个进程退出的时候,内核将释放该进程所有的资源,包括打开的文件,占用的内存等
退出后的进程,内核仍然为其保留一定的信息(包括进程号、退出状态、运行时间等后果:上述信息直到父进程通过wait / waitpid来取时才释放,如果没有调用,那么保留的那段信息就不会释放,其进程号就会一直被占用
- 危害:系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程
一般通过两种方式清理僵尸进程,但都不能最大化利用系统资源
- 轮询方式
- 父进程处理自己工作的同时,不断地询问子进程是否结束
- 阻塞父进程
- 父进程阻塞自己,直到子进程结束
利用信号,可以有更好的解决办法
子进程退出时会向父进程发送信号SIGCHLD
,默认处理动作是忽略,可以通过信号完成指令执行。
在Linux下,还可以使用sigaction将SIGCHLD处理动作设定为SIG_IGN,fork出的子进程会在终止时自动清理,不会产生僵尸进程。
系统默认的忽略和用户自定义的忽略一般没有区别,但这是一个特例。