信号-linux
https://www.linuxjournal.com/article/3985
每个信号在 signal.h
头文件中通过宏进行定义,实际是在 signal.h
中定义,对于编号以及信号名的映射关系可以通过 kill -l
命令查看。
其中,[1, 31]
是普通信号,[34, 64]
是实时信号,前者是从 UNIX 系统继承过来的信号,不支持排队可能会导致信号丢失, 比如发送多次相同的信号, 进程只能收到一次,其信号值小于 SIGRTMIN
。
后来 Linux 改进了信号机制,增加了 32 种新的信号,这些信号都是可靠信号,支持排队,主要位于 [SIGRTMIN, SIGRTMAX]
区间,通常用于用户使用。
对于实时信号,可以使用 sigqueue
发送信号。
对于信号,通常有如下的几种处理方式:
- 忽略。大部分信号都可以通过这种方式处理,不过
SIGKILL
和SIGSTOP
两个信号有特殊用处,不能被忽略。 - 默认动作。大多数信号的系统默认动作终止该进程。
- 捕捉信号。也就是在收到信号时,执行一些用户自定义的函数。
信号处理过程
进程收到一个信号后不会被立即处理,而是在恰当时机进行处理!一般是在中断返回的时候,或者内核态返回用户态的时候 (这个情况出现的比较多)。
也就是说,信号不一定会被立即处理,操作系统不会为了处理一个信号而把当前正在运行的进程挂起,因为这样的话资源消耗太大了,如果不是紧急信号,是不会立即处理的,操作系统多选择在内核态切换回用户态的时候处理信号。
因为进程有可能在睡眠的时候收到信号,操作系统肯定不愿意切换当前正在运行的进程,于是就得把信号储存在进程唯一的 PCB(task_struct) 当中
信号触发
一般信号的触发大致可以分为如下的几类:
- 在终端通过组合按键触发,终端驱动程序发送信号给前台进程。例如
Ctrl-C(SIGINT)
、Ctrl-\(SIGQUIT)
、Ctrl-Z(SIGTSTP)
。 - 硬件异常产生信号,由硬件检测到并通知内核并由内核向当前进程发送适当的信号。例如除 0 导致 CPU 产生异常,内核将该异常解释为
SIGFPE
信号发送给进程;访问非法内存地址导致 MMU 产生异常,内核将该异常解释为SIGSEGV
信号发送给进程。 - 进程通过
kill(2)
发送信号,或者调用kill(1)
命令发送,默认发送SIGTERM
信号,该信号的默认处理动作是终止进程。 - 通过
raise(3)
给自己进程发送信号,其中raise(sig)
等价于kill(getpid(), sig)
。 - 通过
killpg(2)
给进程组发送信号,使用killpg(pgrp, sig)
等价于kill(-pgrp, sig)
。 - 利用
sigqueue
给进程发送信号,支持排队,可以附带信息。 - 当内核检测到某种软件条件发生时也可以通过信号通知进程。例如闹钟超时产生
SIGALRM
信号;向读端已关闭的管道写数据产生SIGPIPE
信号;子进程退出发送SIGCHILD
信号。
当 CPU 正在执行某个进程时,通过终端驱动程序发送了一个 SIGINT 信号给该进程,该信号会记录在对应进程 PCB 中,则该进程的用户空间代码暂停执行,CPU 从用户态切换到内核态处理信号。
从内核态回到用户态之前,会先处理 PCB 中记录的信号,发现有一个 SIGINT
信号待处理,而这个信号的默认处理动作是终止进程,所以直接终止进程而不再返回它的用户空间代码执行。
函数调用
通过 raise()
可以给当前进程发送指定的信号;kill()
函数向指定进程发送信号;而 abort()
函数使当前进程接收到 SIGABRT
信号,其函数声明如下:
#include<signal.h>
int kill(pid_t pid,int signo);
int raise(int signo);
#include<stdlib.h>
void abort(void);
类似于 exit()
函数,abort()
函数总是会成功的,所以没有返回值。
信号阻塞
信号在内核中的表示大致分为如下几类:
- 信号递达 (delivery) 实际执行信号处理信号的动作。
- 信号未决 (pending) 信号从产生到抵达之间的状态,信号产生了但是未处理。
- 忽略,抵达之后的一种动作。
- 阻塞 (block) 收到信号不立即处理,被阻塞的信号将保持未决状态,直到进程解除对此信号的阻塞,才执行抵达动作。
每个信号都由两个标志位分别表示阻塞和未决,以及一个函数指针表示信号的处理动作。
在上图的例子中,其状态信息解释如下:
SIGHUP
未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT
信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT
信号未产生过,一旦产生SIGQUIT
信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
信号产生但是不立即处理,前提条件是要把它保存在 pending 表中,表明信号已经产生。
信号集操作函数
信号集用来描述信号的集合,每个信号占用一位,总共 64 位,Linux 所支持的所有信号可以全部或部分的出现在信号集中,主要与信号阻塞相关函数配合使用。
执行信号的处理动作称为信号递达 (Delivery),信号从产生到递达之间的状态,称为信号未决 (Pending),进程可以选择阻塞 (Block) 某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
每个进程都有一个用来描述哪些信号递送到进程时将被阻塞的信号集,该信号集中的所有信号在递送到进程后都将被阻塞,信号在内核中的表示可以看作是这样的:
如下是常见的信号集的操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set); /* 所有信号的对应位清0 */
int sigfillset(sigset_t *set); /* 设置所有的信号,包括系统支持的所有信号 */
int sigaddset(sigset_t *set, int signo); /* 在该信号集中添加有效信号 */
int sigdelset(sigset_t *set, int signo); /* 在该信号集中删除有效信号 */
int sigismember(const sigset_t *set, int signo); /* 用于判断一个信号集的有效信号中是否包含某种信号 */
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
调用 sigprocmask()
函数可以读取或更改进程的信号屏蔽字:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
其中how:
SIG_BLOCK 信号屏蔽字是其当前信号屏蔽字和set指向信号集的并集,set包含了希望阻塞的信号
SIG_UNBLOCK 信号屏蔽字是其当前信号屏蔽字和set所指向信号集补集的交集,set包含了希望解除阻塞的信号
SIG_SETMASK 信号屏蔽字将被set指向的信号集的值代替
一个进程的信号屏蔽字规定了当前阻塞而不能递送给该进程的信号集,如果调用该函数解除了对当前若干个未决信号的阻塞,则在该函数返回前,至少将其中一个信号递达。
内核处理
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为信号捕捉。由于信号处理函数的代码是在用户空间的,处理过程比较复杂。
也就是说,处理信号最好的时机是程序从内核态切换到用户态时。
多线程
在多线程环境下,产生的信号是传递给整个进程的,会随机选择一个线程发送。
多进程的信号一般是异步处理,在信号处理函数中会有很多的约束,例如 errno 是线程安全但是非信号安全、不能调用 malloc()
、free()
等函数、使用全局变量时增加 volatile
以防不恰当优化等。
信号同步处理
在 POSIX.1 规范定义了 sigwait()
、 sigwaitinfo()
和 pthread_sigmask()
等接口,可以实现在专用的线程中以同步方式处理信号。
Signal VS. Sigaction
实际上,上述的 signal()
是最早的函数,现在大多系统,包括 Linux 都用 sigaction()
重新实现了 signal()
,其区别如下:
signal()
注册的回调函数,会在调用前先清除掉,所以需要在回调函数中重新注册;而sigaction()
函数如果要删除需要显示调用。signal()
处理不能阻塞信号,而sigaction()
则可以阻塞指定的信号。
这也就意味着,signal()
函数可能会丢失信号。
如下是两个函数的声明。
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
#include <signal.h>
struct sigaction {
void (*sa_handler)(int); /* 信号处理方式 */
void (*sa_sigaction)(int, siginfo_t *, void *); /* 实时信号的处理方式 */
sigset_t sa_mask; /* 额外屏蔽的信号 */
int sa_flags;
void (*sa_restorer)(void);
};
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
对于 sigaction()
函数,如果 act
非空,则会根据 act
结构体中的信号处理函数来修改该信号的处理动作;如果 oldact
非空则会通过该变量将信号原来的处理动作返回。
其中,sa_handler
变量用于指定信号的处理函数,有三种方式:
- SIG_IGN 忽略信号;
- SIG_DFL 执行系统默认动作;
- 赋值为函数指针表示用自定义函数捕捉信号。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
常用程序
实时信号 VS. 非实时信号
简单来说,就是通过测试程序,发现非实时信号不排队,而实时信号支持排队不会丢失。
SIGKILL VS. SIGSTOP
这两个信号比较特殊,无法在程序中进行屏蔽,用于一些特殊的用途。
SIGKILL
也就是直接的 kill -9
操作,为 root 提供了一种使进程强制终止方法,此时将会有操作系统直接回收该进程占用的资源,对于一些保存状态的应用就可能会导致异常。
SIGSTOP
对于前台运行的程序,可以通过 Ctrl-Z
终止程序,切换到后台,此时进程处于 TASK_STOPPED
状态,ps
命令显示处于 T
状态。如果要恢复运行,应该使用 fg JOB-ID
恢复运行,如果直接发送 SIGCONT
将会使进程退出。
可以参考 WikiPedia SIGSTOP 中的介绍,抄录如下:
When SIGSTOP is sent to a process, the usual behaviour is to pause that process in its
current state. The process will only resume execution if it is sent the SIGCONT signal.
SIGSTOP and SIGCONT are used for job control in the Unix shell, among other purposes.
SIGSTOP cannot be caught or ignored.
也就是说,这个信号是用于 Shell 的任务管理,不能被用户屏蔽。其中常用的是 rsync 的同步任务,例如要清理一些空间,可以暂停运行,清理完成后重新启动运行。
# kill -s STOP `pidof rsync`
# kill -s CONT `pidof rsync`
如下是启动一个 sleep
进程,可以看下如何停止、继续执行进程,如下示例中会启动一个前台进程,并通过发送信号进行停止、启动操作。
$ sleep 1000
$ kill -STOP <PID>
$ kill -CONT <PID>
当停止后,通过 ps aux
查看进程状态处于 T
也就是暂停状态。
注意,通过 -CONT
重新启动后会进入到后台运行,如果需要可以通过 fg <JOB-ID>
重新恢复到前台运行。