关于信号:从它的出生说起吧...

本文详细介绍Linux信号机制,涵盖信号生命周期、产生方式(硬件异常、终端相关、软件事件)、分类(不可靠与可靠)、发送函数,还阐述信号与线程关系、等待信号方法、递送顺序,以及异步信号安全问题和解决办法,指出其适用于不频发异步事件。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

信号的生命周期

信号的产生

硬件异常

终端相关的信号

nohup

setsid

disown

软件事件相关的信号

信号的默认处理函数

信号的分类

传统信号的特点

信号的可靠性

信号的安装

信号的发送

kill

tkill、tgkill

raise

sigqueue 函数

信号与线程的关系

线程之间共享信号处理函数

线程有独立的阻塞信号掩码

私有挂起信号和共有挂起信号

致命信号下,进程组全体退出

等待信号

pause 函数

sigsuspend 函数

sigwait 函数和 sigwaitinfo 函数

通过文件描述符来获取信号

信号递送的顺序

异步信号安全

轻量级信号处理函数

化异步为同步

总结


信号的生命周期

信号的本质是一种进程间的通信。进程之间约定好:如果发生了某件事情 T,就向目标进程发送某特定信号 X,而目标进程看到信号 X,就将意识到 T 事件发生了,目标进程就会执行相应的动作 A。

以配置文件为例,来描述整个过程。很多应用都有配置文件,如果配置文件发生改变,需要通知进程重新加载配置。一般而言,程序会默认采用 SIGHUP 信号来通知目标进程加载配置文件。

首先约定,只要收到 SIGHUP 信号,就执行重新加载配置文件的动作。这个行为称为信号的安装,或者信号处理函数的注册。安装好之后,因为信号是异步事件,不知道何时会发生,所以目标进程依然正常地干自己的事情。某年某月的某个时刻,管理员突然改变了配置文件,想通知这个目标进程,于是就向目标进程发送了信号。他可能在终端执行了 kill -SIGHUP 命令,也可能调用了 C 的 API,总之信号产生了。这时候,Linux 内核收到了产生的信号,然后就在目标进程的进程描述符里记录了一笔:收到信号 SIGHUP 一枚。Linux 内核会在适当的时机,会将信号递送给进程。在内核收到信号,但是还没有递送给目标进程的这一段时间里,信号处于挂起状态,被称为挂起信号也称为未决信号。内核将信号递送给进程,进程就会暂停当前的执行流,转而去执行信号处理函数。这就是一个信号的完整生命周期。

一个典型的信号会按照上面所属的流程来处理,但是实际情况要复杂的多。这些复杂的问题,我们将在以下片段一一提出,并讨论如何去解决它。。

信号的产生

硬件异常

硬件检测到了错误并通知内核,由内核发送相应的信号给相关进程。和硬件异常相关的信号如下:

  • SIGBUS  总线错误,表示发生了内存访问错误
  • SIGFPE  表示发生了算术错误
  • SIGILL  进程尝试执行非法的机器语言指令
  • SIGSEGV  段错误,表示应用程序访问了无效地址

常见的能触发 SIGBUS 信号的场景有:

  1. 变量地址未对齐:很多架构访问数据时有对齐的要求。比如 int 型变量占用 4 个字节,因此架构要求 int 变量的地址必须为 4 字节对齐,否则会触发 SIGBUS 信号
  2. mmap 映射文件:使用 mmap 将文件映射到内存,如果文件大小被其他进程截断,那么在访问文件大小以外的内存时,会触发 SIGBUS 信号

虽然 SIGFPL 的后缀 FPL 是浮点异常的含义,但是该异常并不限于浮点异常,常见的算术错误也会引发 SIGFPL 信号。最常见的就是整数除以 0 的例子。

SIGILL 的含义是非法指令。一般表示进程执行了错误的机器指令。发生这种错误,一般是函数指针遭到破坏,当执行函数指针指向的函数时,就会触发 SIGILL 信号。另外也可能是指令集的演进引起的。比如,很多在新的体系结构里演绎出来的可执行程序,在老的机器上可能就无法运行,在老的机器上运行时,可能会产生 SIGILL 信号。

SIGSEGV 是所有 C 程序员的噩梦。程序员很难避免段错误,常见的情况有:

  1. 访问未初始化的指针或空指针(NULL)
  2. 进程企图在用户态访问内核的地址
  3. 进程尝试去修改只读的内存地址

这四种异常,一般是由程序自身引发的,不是由其他进程发送的信号引发的,并且这些异常都比较致命,以至于进程无法继续下去。所以这些信号产生之后,内核会立刻递送给进程。默认情况下,这四种信号都会使进程终止,并产生 core dump 文件供调试。对于这些信号,进程既不能忽略,也不能阻塞。。

终端相关的信号

对于 Linux 程序员而言,终端操作是免不了的。

终端定义了如下几种信号生成字符:

  • Ctrl + C :产生 SIGINT 信号
  • Ctrl + \ :产生 SIGQUIT 信号
  • Ctrl + Z :产生 SIGTSTP 信号

键入些信号生成字符,相当于向前台进程组发送了对应的信号。

另一个和终端联系比较密切的信号是 SIGHUP 信号。很多人都遇到过这样的问题:使用 ssh 登录到远程的 Linux 服务器,执行比较耗时的操作,却因为网络不稳定或者需要关机回家,ssh 连接被断开,最终导致操作中途被放弃而失败。

之所以会如此,是因为一个控制进程(shell 通常是终端的控制进程)在失去其终端之后,内核会负责向其发送一个 SIGHUP 信号。在登录会话中 shell 通常是终端的控制进程,控制进程收到 SIGHUP 信号时,会引发如下的连锁反应。

shell 收到 SIGHUP 信号后会终止,但是在终止之前,会向 shell 创建的前台进程组和后台进程组发送 SIGHUP 信号,为了防止处于停止状态的进程接受不到 SIGHUP 信号,通常会在发送 SIGHUP 信号之后,发送 SIGCONT 信号,唤醒处于停止状态的信号。前台进程组和后台进程组的进程收到 SIGHUP 信号,默认的行为是终止进程,这也是前面提到的耗时任务会中途失败的原因。

注意,单纯的将命令放入后台执行,并不能摆脱被 SIGHUP 信号追杀的命运。

那么如何让进程在后台稳定的执行而不受终端连接断开的影响呢?可以采用如下方法:

nohup

可以通过如下方式执行命令:

nohup command

标准输入会重定向到 /dev/null,标准输出和标准错误会重定向到 nohup.out,如果无权限写入当前目录下的 nohup.out,则会写入 home 目录下的 nohup.out。 

setsid

使用如下方式执行命令:

setsid command

这种方式和 nohup 的原理不太一样。nohup 仅仅是使启动的进程不在响应 SIGHUP 信号, setsid 则使启动进程完全不属于 shell 所在的会话了,并且其父进程也已经不是 shell 而是 init 进程了。

disown

很多情况下,启动命令时,忘记使用 nohup 或 setsid,还有救吗?

答案是使用作业控制里面的 disown,方法如下:

使用 disown 之后,shell 退出时,就不会向启动进程发送 SIGHUP 信号了。在另一个终端上仍然可以看到进程在运行:

软件事件相关的信号

软件事件触发信号产生的情况也比较多:

  • 子进程退出,内核可能会向父进程发送 SIGCHLD 信号
  • 父进程退出,内核可能会给子进程发送信号
  • 定时器到期,给进程发送信号

与子进程退出向父进程发送信号对应,有时候,进程希望父进程退出时向自己发送信号,从而可以得知父进程的退出事件。Linux 也提供了这种机制。

每一个进程的进程描述符 task_struct 中都存在如下成员变量:

int pdeath_signal; 

如果父进程退出时,子进程希望收到信号,那么子进程可以通过执行如下代码来做到:

prctl(PR_SET_PDEATHSIG,sig);

 父进程退出时,会遍历其子进程,发现有子进程很关系自己的退出,就会像子进程发送子进程希望收到的信号。

很多定时器相关函数,背后都牵扯到信号,比如:

  1. alarm   SIGALRM
  2. ualarm   SIGALRM

信号的默认处理函数

信号产生的源头很多。那么内核将信号递送给进程后,进程会执行什么操作呢?
很多信号尤其是传统信号,都会有默认的信号处理方式。如果我们不改变信号的处理方式,那么进程收到信号后,就会执行默认的操作。

信号的默认处理方式有以下几种:

  1. 显示的忽略信号(ignore):即内核将会丢弃该信号,信号不会对目标进程产生任何影响
  2. 终止进程(terminate):即将进程杀死
  3. 生成核心转储文件并终止进程(core):进程被杀死,并生成核心转储文件。核心转储文件记录了进程死亡现场的信息。用户可以使用核心转储文件来调试,分析进程死亡的原因
  4. 停止进程(stop):将进程的状态设置成 TASK_STOPPED,一旦收到恢复执行的信号,进程还可以继续执行
  5. 恢复进程的执行(continue ):和停止进程相对应,SIGCONT 信号可以使进程恢复执行

根据信号的默认处理方式,可以将传统信号分为 5 派。

当信号的默认处理函数不满足实际的需求时,需要修改信号的处理函数。为信号指定新的处理函数的动作,被称为信号的安装。glibc 提供了 signal 函数和 sigaction 函数来完成信号的安装。signal 函数出现的早,接口比较简单,sigaction 函数提供了精确的控制。

#include <signal.h>
int sigaction(int sig, const struct sigaction *restrict act,
              struct sigaction *restrict oact);

信号的分类

这些信号可以分成两类:

  1. 不可靠信号
  2. 可靠信号

信号值在【1,31】之间的信号,都被称为不可靠信号,信号值在【34,64】之间的信号,都被称为可靠信号。

不可靠信号是从传统 Unix 继承而来的。早期 Unix 信号机制并不完备,在实践过程中暴露了很多弊端,因此把这些早期出现的信号称之为不可靠信号。所谓不可靠,指的是发送的信号,内核不一定能递给进程,信号可能会丢失。这些信号存在已久,在很多应用中被广泛使用,处于兼容性的考虑,不能改变这些信号的行为模式。

注意信号的可靠与否,完全取决于信号的值,而与采用哪种方式安装或发送无关。

不可靠信号和可靠信号的根本差异在于收到信号后,内核的处理方式不同:对于不可靠信号,内核用位图来记录该信号是否处于挂起状态。如果收到某不可靠信号,内核发现已经存在该信号处于未决状态,就会简单的丢弃该信号。因此,发送不可靠信号,信号可能会丢失,即内核递送给目标进程的次数,可能小于信号被发送的次数。对于可靠信号,内核用队列来维护,如果收到可靠信号,内核会将信号挂到相应的队列中,不管该信号是否已经在队列中,因此不会丢失。严格来说,内核也设有上限,挂起信号的个数也不能无限制地增大,因此只能说,在一定范围内,可靠信号不会被丢弃。

传统信号的特点

信号的可靠性

 不可靠信号和可靠信号存在很大的差异:不可靠信号,不能可靠的传递给进程处理,内核可能会丢弃部分信号。会不会丢弃,丢弃多少,取决于信号到了和信号递送给进程的时许。而可靠信号,基本不会被丢弃。

之所以存在这种差异,是因为重复的信号到来时,内核采取了不同的处理方式。从内核收到发送给某进程的信号,到内核将该信号递送给该进程,中间有个时间窗口。在这个时间窗口内,内核会负责记录收到的信号信息,这时的信号被称为挂起信号或未决信号。但是对于可靠信号和不可靠信号,内核采取了不同的记录方式。

内核中负责记录挂起信号的数据结构为 sigpending 结构体:

struct sigpending{
    struct list_head list;
    sigset_t signal;
};

#define _NSIG   64
#define _NSIG_BPW   64
#define _NSIG_WORDS   ( _NSIG / _NSIG_BPW)

typedef struct{
    unsigned long sig[_NSIG_WORDS];
}sigset_t;

在 sigpending 结构体中,sigset_t 类型的成员变量本质上是一个位图,用一个比特位来记录与该位置对应的信号。根据位图可以有效地判断某信号是否已经处于未决状态了。因为共有 64 种不同的信号,因此对于 64 位操作系统,一个无符号的长整形就足以描述所有信号的挂起情况了。

在 sigpending 结构体中,第一个成员变量是个链表头。

内核定义了结构体 sigqueue,代码如下:

struct sigqueue{;
    struct list_head list;
    int flags;
    siginfo_t info;
    struct user_struct *user;
};

该结构体中 info 成员中详细记录了信号的信息。如果内核收到发给某进程的信号,则会分配一个 sigqueue 结构体,并将该结构体挂入 sigpending 中第一个成员变量 list 为表头的链表中。

总之,内核的进程描述符提供了两种数据结构来记录挂起信号:位图和队列。

内核收到不可靠信号时,会检查位图中对应位置是否是 1,如果不是 1,表示尚无该信号处于挂起状态,然后会分配 sigqueue 结构体,并将其挂入链表之中,同时将位图对应位置置为 1。但是如果位图显示已经存在该不可靠信号,那么内核会直接丢弃本次收到的信号。内核的 sigpending 结构体当中,最多只会存在某个不可靠信号的一个 sigqueue 结构体。

内核收到可靠信号时,不管该信号是否已经处于挂起状态,都会为该信号分配一个 sigqueue 结构体,并将 sigqueue 结构体挂入 sigpending 结构体的链表之中,确保了可靠信号不会被丢失。

那么,可靠信号是不是无限制的挂入队列呢?

不是的,实际上内核也做了限制(该限制属于资源限制的范畴),一个进程默认挂起信号的个数是有限的,超过内核的这个限制,可靠信号也会被丢失。

通过以下命令可以查看系统中挂起信号上限值的默认值:

这个挂起信号的上限值是可以修改的,可以用 ulimit -i unlimited 这个命令将进程挂起信号的最大值重设。

信号的安装

Linux 提供了新的信号安装方法:sigaction 函数。和 signal 函数相比,这个函数的优点在于语义明确,可以提供更精确的控制。

sigaction 函数的定义:

#include <signal.h>
int sigaction(int sig, const struct sigaction *restrict act,
              struct sigaction *restrict oact);

struct sigaction{
    void (*sa_handler)(int);
    void (*sa_sigaction)(int,siginfo_t *,void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

sa_mask 就是信号处理函数执行期间的屏蔽信号集。为 SIGINT 信号安装处理函数时,内核会自动将 SIGINT 添加到屏蔽信号集,在 SIGINT 信号处理函数执行期间,SIGINT 信号不会递送给目标进程。如果还需要屏蔽 SIGHUP、SIGUSR1 等其他信号,对 sigaction 函数而言,根本不是问题,只需要如下代码即可做到:

struct sigaction sa;
sa.sa_mask = SIGHUP | SIGUSR1 | SIGINT;

需要注意的是,并不是所有信号都能被屏蔽。对于 SIGKILL 和 SIGSTOP,不可以为他们安装信号处理函数,也不能屏蔽掉这些信号。原因是,系统总要控制某些进程,如果进程自己可以自行设计所有信号的处理函数,那么操作系统可能无法控制这些进程。操作系统是终极 boss,需要杀死某些进程的时候,要能够做得到。

若通过 sigaction 函数强行给 SIGKILL 和 SIGSTOP 信号注册信号处理函数,则会返回 -1,并置 error 为 EINVAL。

在 sigaction 函数中,比较有意思的是 sa_flags。sigaction 函数之所以能够提供更精确的控制,大部分都是该参数的功劳。下面介绍 sa_flags 的含义:

1)SA_NOCLDSTOP

这个标志位只用于 SIGCHLD 信号。父进程可以检测子进程的三种事件:

  1. 子进程终止
  2. 子进程暂停
  3. 子进程恢复

其中 SA_NOCLDSTOP 标志位是用来控制第二种和第三种事件的。即一旦父进程为 SIGCHLD 信号设置了 SA_NOCLDSTOP 标志位,那么子进程停止和子进程恢复这两件事,就无需向父进程发送 SIGCHLD 信号了。

2)SA_NOCLDWAIT

这个标志位也只用于 SIGCHLD 信号,它可控制上面提到的子进程终止时的行为。如果父进程为 SIGCHLD 信号设置了 SA_NOCLDWAIT 标志位,那么子进程退出时,就不会进入僵尸状态,而是直接自行了断。但是子进程还会不会向父进程发送 SIGCHLD 信号呢?这个取决于实现。对于 Linux 而言,仍然会发送 SIGCHLD 信号,这点与上面是不同的。

3)SA_ONESHOT 和 SA_RESETHAND

这两个标志位的本质是一样的,表示信号处理函数是一次性的,信号递送出去之后,信号处理函数便恢复成默认值 SIG_DFL。

4)SA_NODEFER 和 SA_NOMASK

这两个标志位的作用是一样的,在信号处理函数执行期间,不阻塞当前信号。

5)SA_RESTART

这个标志位表示,如果系统调用被信号中断,则不返回错误,而是自动重启系统调用。

6)SA_SIGINFO

这个标志位表示信号发送者会提供额外的信息。在这种情况下,信号处理函数应该为三参数的函数:

 void func(int signo, siginfo_t *info, void *context);

信号的本质是一种进程间通信方式。一个进程向另一个进程发送信号,不仅是 signo,还可以发送更多的信息,而接受进程也能获取到发送进程的 PID、UID 以及发送的额外信息。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void sig_handler(int signo,siginfo_t *info,void *context)
{
	printf("signal:%d\n",signo);
	printf("signal number is:%d\n",info->si_signo);
	printf("pid = %d\n",info->si_pid);  //信号发送者的 PID
	printf("sigval = %d\n",info->si_value.sival_int);  //信号发送进程额外发送的 int 值
    //发送进程和接受进程约定好,发送者使用 sigqueue 发送信号,同时带上 int 型的额外信息,
    //接受进程就能获得发送进程的 PID 及 int 型的额外信息。
}
int main()
{
	printf("pid: %d\n",getpid());

	struct sigaction sa;
	sigemptyset(&sa.sa_mask);
	sa.sa_sigaction = sig_handler;
	sa.sa_flags |= SA_SIGINFO | SA_RESTART;

	if(sigaction(SIGRTMIN+2,&sa,NULL) == -1){
		printf("sigaction error!\n");
		exit(1);
	}

	while(1)
		pause();
	return 0;
}

这个例子中为 36 号信号注册了信号处理函数。因为 sa_flags 带上了 SA_SIGINFO 标志位,所以必须使用三参数的信号处理函数。

信号的发送

kill

#include <signal.h>
int kill(pid_t pid, int sig);

kill 函数的作用是发送信号。kill 函数不仅可以向特定进程发送信号,也可以向特定进程组发送信号。第一个参数 pid 的值决定了 kill 函数的不同含义:

  1. pid > 0 :发送信号给进程 ID 等于 pid 的进程
  2. pid = 0 :发送信号给调用进程所在的进程组里的每一个进程
  3. pid = -1 :有权限向调用进程发送信号的所有进程发出信号,init 进程和进程本身除外
  4. pid < -1 :向进程组 -pid 发送信号

当函数调用成功时,返回 0;当调用失败时,返回 -1,并置 error。常见的 error 值及描述:

  1. EINVAL 无效的信号值
  2. EPERM  该进程没有权限发送信号给目标进程
  3. ESRCH  目标进程或进程组不存在

有一种情况很有意思,调用 kill 函数时,第二个参数 sig 的值为 0。我们都知道没有一个信号的值是 0。在这种情况下,kill 函数其实并不是真正的向目标进程或进程组发送信号,而是用来检测目标进程或进程组是否存在。如果 kill 函数返回 -1,并置 error 为 ESRCH,则可以断定我们关注的进程或进程组并不存在。

tkill、tgkill

Linux 提供了 tkill 和 tgkill 两个系统调用来向某个线程发送信号:

int tkill(int tid, int sig);
int tgkill(int tgid, int tid, int sig);

它们都是内核提供的系统调用,glibc 并没有提供对这两个系统调用的封装,如果要使用这两个系统调用,需要采用 syscall 的方式:

ret = syscall(SYS_tkill,tid,sig);
ret = syscall(SYS_tgkill,tgid,tid,sig);

相比之下,tgkill 接口更加安全。tgkill 系统调用的第一个参数 tgid 为线程组中主线程的线程 ID。它能起到保护作用,防止向不相干的线程发送信号。进程 ID 和线程 ID 这种资源是由内核负责管理的,进程(或者线程)有自己的生命周期,比如向线程 ID 为 1234 的线程发送信号时,很可能线程 1234 早就退出了,而线程 ID 1234 恰好被内核分配给了一个不相干的线程。这种情况下调用 tkill,就会将信号发送到不相干的进程上。为了防止这种情况,于是内核引入了 tgkill 系统调用,含义是向线程组 ID 是 tgid、线程 ID 是 tid 的线程发送信号。

raise

Linux 提供了向进程自身发送信号的接口:

#include <signal.h>
int raise(int sig);

这个接口对于单线程的程序而言,就相当于执行如下语句:

kill(getpid(),sig);

这个接口对于多线程的程序而言,就相当于执行如下语句:

pthread_kill(pthread_self(),sig);

调用成功返回 0,调用失败返回非 0 的值,并置 error。如果 sig 的值是无效的,raise 函数会将 error 置为 EINVAL。

值得注意的是,信号处理函数执行完毕之后,raise 才能返回。

sigqueue 函数

传统信号多用 signal 和 kill 这两个函数搭配,来完成信号处理函数的安装和信号的发送。后来因为 signal 函数表达力有限,控制不够精准,所以引入了 sigaction 函数来负责信号的安装,与其对应的,引入了 sigqueue 函数来完成实时信号的发送。当然,sigqueue 函数也能发送非实时信号。

#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);

sigqueue 函数拥有和 kill 函数相同的语义,可以发送空信号来检测进程是否存在。和 kill 函数不同的地方在于,sigqueue 不能通过将 pid 指定为负值而向整个进程组发送信号。

比较有意思的是函数的第三个参数,它指定了信号的伴随数据,该参数的数据类型为联合体:

union sigval {
    int   sival_int;
    void *sival_ptr;
};

通过指定 sigqueue 函数的第三个参数,可以传递一个 int 型值和指针给目标进程。考虑到每个进程都有自己独立的地址空间,传递一个指针到另一个进程几乎没有任何意义。所以,sigqueue 函数很少传递指针,大多数情况下传递一个整形值。

sigval 联合体的存在,扩展了信号的通信能力。一些简单的消息传递完全可以使用 sigqueue 函数来完成。比如,通信双方事先约定好某些事件为不同的 int 值,通过 sigval 结构体,将事件发送给目标进程。目标进程根据联合体中 int 值来确定出具体的事件,做出相应的响应。但是这种方法传递的消息内容是很有限的,不容易扩展,所以不宜作为常规的通信手段。

这里写一个通过 sigqueue 函数向目标进程发送信号的程序,其中目标进程、要发送的信号、发送信号的次数都可以在该程序中指定:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void Usage(char *str)
{
	printf("usage: %s pid sig\n",str);
	printf("notice:sig >= 1 && sig <= 64\n");
	printf("notice: count >= 1\n");
}
int main(int argc,char *argv[])
{
	pid_t pid;
	int sig;
	int count = 1;
	union sigval sigval_;
	sigval_.sival_int = 666;
	if(argc < 3){
		Usage(argv[0]);
		exit(1);
	}

	pid = atoi(argv[1]);
	sig = atoi(argv[2]);
	if(argc >= 4){
		count = atoi(argv[3]);
	}

	if(sig <= 0 || sig > 64 || count <= 0){
		Usage(argv[0]);
		exit(2);
	}

	int i = 0;
	for(;i < count;++i){
		if(sigqueue(pid,sig,sigval_) != 0){
			printf("sigqueue error!\n");
			exit(3);
		}
	}
	return 0;
}

一般来说 sigqueue 函数的搭档是 sigaction 函数。在使用 sigaction 函数时,只要给成员变量 sa_flags 置上 SA_SIGINFO 标志位,就可以使用三参数的信号处理函数来获取到发送方发来的额外信息(除信号本身之外的信息)。

struct sigaction sa;

sa.sa_flags |= SA_SIGINFO;

三参数的信号处理函数:

void func(int signo, siginfo_t *info, void *context);

siginfo_t 结构体如下:

struct siginfo_t{
    int si_signo;  //信号的值
    int si_errno;
    int si_code;  //信号来源,可以通过这个值来判断信号的来源
    int si_trapno;
    pid_t si_pid;  //信号发送进程的进程 ID
    uid_t si_uid;  //信号发送进程的真实用户 ID
    union sigval si_value;  //sigqueue 函数发送信号时所带的伴随数据
    void *si_addr;
    ... ...
};

si_addr :仅针对硬件产生的信号 SIGBUS、SIGFPE、SIGILL、SIGSEGV 设置该字段,该字段表示无效的内存地址或导致信号产生的指令地址。

三参数信号处理函数的第三个参数是 void* 类型的,其实他是一个 ucontext_t 类型的变量。

这个结构体提供了进程上下文信息,用于描述进程执行信号处理函数之前进程所处的状态。通常情况下信号处理函数很少会用到这个变量。。。

信号与线程的关系

提到线程与信号的关系,必须先介绍下 POSIX 标准,POSIX 标准对多线程情况下的信号地址提出了一些要求:

  1. 信号处理函数必须在多线程进程的所有线程之间共享,但是每个线程要有自己的挂起信号集合和阻塞信号掩码
  2. POSIX 函数 kill/sigqueue 必须面向进程,而不是进程下某个特定的线程
  3. 每个发送给多线程进程的信号仅递送给一个线程,这个线程是由内核从不会阻塞该信号的线程中随意选出来的
  4. 如果发送一个致命信号到多线程进程,那么内核将杀死该进程的所有线程,而不仅仅是接受信号的那个线程

线程之间共享信号处理函数

对于进程下的多个线程来说,信号处理函数是共享的。

在 Linux 内核视线中,同一个线程组里的所有线程共享一个 struct sighand_struct 结构体。该结构体中存在一个数组,数组共 64 项,数组中每个成员都是 k_sigaction 结构体类型,一个 k_sigaction 结构体对应一个信号的信号处理函数。

struct sigaction{
    __sighandler_t sa_handler;
    unsigned long flags;
    __sigrestore_t sa_restorer;
    sigset_t sa_mask;
};

struct k_sigaction{
    struct sigaction sa;
};

struct sighand_struct{
    atomic_t count;
    struct k_sigaction action[_NSIG];
    spinlock_t siglock;
    wait_queue_head_t signalfd_wqh;
};

struct task_struct{
    ...
    struct sighand_struct *sighand;
    ...
};

多线程进程当中,信号处理函数相关数据结构及它们之间的关系: 

同一个进程里多个线程共享信号处理函数

内核中 k_sigaction 结构体的定义和 glibc 种 sigaction 函数种用到的 struct sigaction 结构体定义几乎是一样的。通过 sigaction 函数安装信号处理函数,最终会影响到进程描述符中 sighand 指针指向的 sighand_struct 结构体中 action 成员变量。

在创建线程时,最终会执行内核的 do_fork 函数,由 do_fork 函数走进 copy_sighand 来实现线程组内信号处理函数的共享。创建线程时,CLONE_SIGHAND 标志位是置位的。创建线程组的主线程时,内核会分配 sighand_struct 结构体;创建线程组内其他线程时,直接共享主线程的  sighand_struct 结构体,只需增加引用计数而已。

线程有独立的阻塞信号掩码

每个线程都有自己独立的阻塞信号掩码。那么什么是阻塞信号掩码呢?

进程在执行某些重要操作时,不希望内核寄送某些信号(不是所有信号),阻塞信号掩码就是用来实现该功能的。如果进程将某信号添加进了阻塞信号掩码,纵然内核收到了该信号,甚至该信号在挂起队列中已经存在了相当长的时间,内核不会将信号递送给进程,至到进程解除对该信号的阻塞为止。

为了实现掩码的功能,Linux 提供了一种新的数据结构:信号集。多个信号组成的集合被称为信号集,其数据类型为 sigset_t。在 Linux 实现中,sigset_t 类型是位掩码,每一个比特代表一个信号。

Linux 提供以下两个函数来初始化信号集:

int sigemptyset(sigset_t *set);  //用来初始化一个未包含任何信号的信号集
int sigfillset(sigset_t *set);  //用来初始化一个包含所有信号的信号集

初始化之后,Linux 提供了以下函数来操作信号集:

int sigaddset(sigset_t *set, int signum);  //向信号集中添加一个信号
int sigdelset(sigset_t *set, int signum);  //从信号集中移除一个信号
int sigismember(const sigset_t *set, int signum);  //判断某个信号是否在指定信号集中,如果属于信号集,则返回 1,否则返回 0.出错的时候,返回 -1.

有了信号集,就可以使用信号集来设置进程的阻塞信号掩码了。Linux 提供 sigprocmask 函数来做这件事情:

#include <signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set,  //线程库提供的
              sigset_t *restrict oset);
int sigprocmask(int how, const sigset_t *restrict set,  //出现的较早
              sigset_t *restrict oset);

根据 how 的值,提供了三种用于改变进程的阻塞信号掩码的方式:

  1. SIG_BLOCK :进程的当前信号掩码中增加 set 中的信号
  2. SIG_SETMASK :直接把进程的信号掩码设置成 set 指向的信号集
  3. SIG_UNBLOCK :从线程当前信号掩码中删除 set 中的信号,接触对其的屏蔽

对于多线程的进程而言,每一个线程都有自己的阻塞信号集:

struct task_struct{
    ...
    sigset_t blocked;
    ...
};

私有挂起信号和共有挂起信号

POSIX 标准中有这样的要求:对于多线程进程,kill 和 sigqueue 发送的信号必须面对所有线程,而不是某个线程。而系统调用 tkill 和 tgkill 发送的信号,又必须递送给进程下某个特定的线程。关于这样的要求,内核是如何做到的呢?

前面提到过内核维护有挂起队列,尚未递送给进程的信号可以挂入挂起队列中。有意思的是,内核的进程描述符 task_struct 中,维护了两套 sigpending :

struct task_struct{
    ...
    struct signal_struct *signal;
    struct sighand_struct *sighand;  //信号处理函数相关
    struct sigpending pending;
    ...
};
struct signal_struct{
    ...
    struct sigpending shared_pending;
    ...
};

内核就是靠这两个挂起队列实现了 POSIX 标准要求的。Linux 中线程作为独立的调度实体也有自己的进程描述符。Linux 下既可以向进程发送信号,也可以向进程下的特定线程发送信号。因此进程描述符中需要有两套 sigpending 结构。其中 task_struct 结构体中的 sigpending,记录的是发送给线程的未决信号;而 signal 指针指向的 signal_struct 结构体中的 shared_pending,记录的是发送给进程的未决信号。每个线程都有自己的私有挂起队列(peding),但是进程下所有线程都会共享一个共有的挂起队列(shared_pending)

通过 kill、sigqueue 向进程发送信号也好,通过 tkill、tgkill 向线程发送信号也罢,最终都会殊途同归,在 do_send_sig_info 函数处会师。尽管会师在一起,却还是存在不同。不同的地方在于,到底将信号挂入哪个挂起队列。

在 __send_signal 函数中,通过 group 入参的值来判断需要将信号放入哪个挂起队列(如果需要进队列的话)。如果用户调用的是 kill 或 sigqueue,那么 group 就是 1;如果用户调用的是 tkill 或 tgkill,那么 group 参数就是 0。内核就是以此来区分信号是发给进程的还是发给某个特定线程的。

另一个需要解决的问题是,多线程情况下发送给进程的信号,到底由哪个线程来负责处理。内核会不会一定将信号递送给进程的主线程呢?

答案是不一定。尽管如此,Linux 采取了尽力而为的策略,经量地尊重函数调用者的意愿,如果进程的主线程方便的话,则优先选择主线程来处理信号;如果主线程确实不方便,那就有可能由线程组里的其他线程来负责处理信号。

用户在调用 kill/sigqueue 函数之后,内核最终会走到 __send_signal 函数。在该函数的最后,由 complete_signal 函数负责寻找合适的线程来处理信号。因为主线程 ID 与进程 ID 是一样的,所以该函数会优先查询进程的主线程是否方便处理信号。如果主线程不方便,则会遍历线程组中的其他线程。如果找到了方便处理信号的线程,就调用 signal_wake_up 函数,唤醒该线程去处理信号。

如果线程组内全都不方便处理信号,complete_signal 函数也就立即返回了。内核是通过 wants_signal 函数来判断某个调度实体是否方便处理信号。

glibc 库提供了一个 API 来获取当前线程的阻塞挂起信号

#include <signal.h>
int sigpending(sigset_t *set);

返回的集合中的信号必须同时满足以下两个条件:

  1. 处于挂起状态
  2. 信号属于线程的阻塞信号集

因此,返回的阻塞挂起信号集的计算方式是:

  • 线程共享的挂起信号和线程私有的挂起信号取并集,得到集合 1
  • 对集合 1 和线程的阻塞信号集取交集,就是最终的结果了

致命信号下,进程组全体退出

Linux 为了应对多线程,提供了 exit_group 系统调用,确保多个线程一起退出。对于线程收到致命信号的这种情况,操作也是类似的。可以通过给每个调度实体的 pending 上挂一个 SIGKILL 信号以确保每个线程都会退出。

等待信号

pause 函数

sigsuspend 函数

sigwait 函数和 sigwaitinfo 函数

通过文件描述符来获取信号

信号递送的顺序

当有多个处于挂起状态的信号时,信号递送的顺序又是如何的呢?

信号实质上是一种软中断,中断有优先级,所以信号也有优先级。如果一个进程有多个未决信号,那么对同一个未决的实时信号,内核将按照发送的顺序来递送信号。如果存在多个未决的实时信号,那么值越小的越优先被递送。如果即存在不可靠信号,又存在可靠信号,Linux 系统和大多说遵循 POSIX 标准的操作系统一样,优先递送不可靠信号。

虽然优先递送不可靠信号,但在不可靠信号中,不同信号的优先级又是如何的呢?

我们知道,线程的挂起信号队列有两个:线程私有挂起队列(pending)和整个线程组共享的挂起队列(shared_pending)。选择信号的顺序是优先从私有挂起队列中选择,如果没有找到,则从线程组共享的挂起队列中选择信号递送给线程。当然选择的时候需要考虑线程的阻塞掩码,属于阻塞掩码集中的信号不会被选中。

在挂起队列中(pending 或者 shared_pending),选择信号的原则是这样的:

  1. 出现在阻塞掩码集中的信号不能被选中
  2. 优先选择同步信号,所谓同步信号指的是:{SIGSEGV,SIGBUS,SIGILL,SIGTRAP,SIGFPE,SIGSYS},这 6 中信号都是与硬件相关的信号
  3. 如果没有上面 6 中信号,非实时信号优先;如果存在多种非实时信号,信号值越小优先级越高
  4. 如果没有非实时信号,那么实时信号按照信号值递送,也是信号值越小优先级越高 

异步信号安全

设计信号处理函数是一件很头疼的事情。当内核递送信号给进程时,进程正在执行的指令序列就会被中断,转而处理信号处理函数。待信号处理函数执行完毕返回(如果可以返回的话),则继续执行被中断的正常指令序列。此时,问题就来了,同一个进程中出现了两条执行流,而两条执行流正是信号机制众多问题的根源。

在信号处理函数中很多函数都不能使用,原因就是他们并不是异步信号安全的,强行使用这些不安全的函数,可能会带来很诡异的 bug。

引入多线程后,很多库函数为了保证线程安全,不得不用锁来保护临界区。加锁保护临界区的方法是实现线程安全的一种选择,但是这种方法无法保证异步信号安全。

以 malloc 为例,如果主程序执行流调用 malloc 已经持有了锁,但是尚未完成临界区的操作,这时候被信号中断,转而执行信号处理函数,如果信号处理函数中再次调用 malloc 加锁时,就会发生死锁。

异步信号安全是一个很苛刻的条件,事实上只有非常有限的函数才能保证异步信号安全。可以通过 man 7 signal 的 Async-signal-safe functions 来查看异步信号安全的函数。

一般来说,不安全的函数大致上可以分为以下几种情况:

  • 使用了静态变量,典型的是 strtok、localtime 等函数
  • 使用了 malloc 或 free 的函数
  • 标准 I/O 函数,如 printf

在正常程序流程里工作的很正常的函数,在异步信号的条件下,可能会出现很诡异的 bug。这种 bug 的触发,通常依赖信号到达的时间、进程调度等不可控制的时许条件,很难重现。

那么如何使用信号机制呢?

轻量级信号处理函数

这是一种比较常见的设计方法,就是信号处理函数非常短,非常轻量,基本就是设置标志位,程序的主流程会周期性地检查标志,以此来判断是否收到某信号。如果收到某信号,则执行相应的操作。

一般来讲定义标志的时候,会将标志定义成:

volatile sig_atomic_t flag;

sig_atomic_t 是 C 语言定义标志的一种数据类型,该类型可以保证读写操作的原子性。而 volatile 关键字则是告诉编译器,flag 的值是易变的,每次使用它的时候,都要到 flag 的内存地址去取。之所以这么做是因为编译器会优化,编译器如果发现两次取 flag 的值之间,并没有代码修改过 flag,就有可能将上一次的 flag 值拿来用。而主程序和信号处理函数不在同一个执行流之中,因此编译器几乎总是会做这种优化,这就违背了设计的本意。

化异步为同步

由于信号处理函数的存在,进程会同时存在两条执行流,带来了很多问题,因此操作系统也想了一些办法,就是 sigwait 和 signalfd 机制。

sigwait 设计的本意是同步地等待信号。在执行流中,执行 sigwait 函数会陷入阻塞,至到等待的信号降临。一般来讲,sigwait 用于多线程的程序中,等待信号降临的使命,一般落在主线程身上。具体做法如下:

sigfillset(&set_all);
sigprockmask(SIG_SETMASK,&set_all,NULL);

for(;;)
{
    ret = sigwait(&set_all,&signo);
    //接下来处理收到的信号
}

signalfd 机制提供了另一种思路:

#include <sys/signalfd.h>
int signalfd(int fd, const sigset_t *mask, int flags);

具体步骤是:

  1. 将关心的信号放入集合
  2. 调用 sigprocmask 函数,阻塞关心的信号
  3. 调用 signalfd 函数,返回一个文件描述符

有了文件描述符,就可以使用 select/poll/epoll 等 I/O 多路复用函数来监控它。这样当信号来临时,就可以通过 read 接口来获取到信号的相关信息:

struct signalfd_info signalfd_info;
read(signal_fd,&signalfd_info,sizeof(struct signalfd_info));

总结

Linux 的 signal 机制是一种原始的进程间通信的方式,能够传递的信息有限,很难传递复杂的信息,加上信号处理函数和进程处于两条执行流,存在异步信号安全问题,因此 signal 不适合作为进程间通信的主要手段。但是对于某些不频发的异步事件,有必要使用 signal 来进行进程间的交流。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值