1信号与线程的关系

POSIX标准对多线程情况下的信号机制提出了一些要求:

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

这些就是POSIX标准提出的要求, Linux也要遵循这些要求, 那它是怎么做到的呢?

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

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

在Linux内核实现中, 同一个线程组里的所有线程都共享一个struct sighand结构体。 该结构体中存在一个action数组, 数组共64项, 每一个成员都是k_sigaction结构体类型, 一个k_sigaction结构体对应一个信号的信号处理函数。
相关数据结构定义如下(这与架构相关, 这里给出的是x86_64位下的定义) :
 

struct sigaction {__sighandler_t sa_handler;
unsigned long sa_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线程与信号_linux

 

static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
struct sighand_struct *sig;
if (clone_flags & CLONE_SIGHAND) {
//如果发现是线程, 则直接将引用计数++, 无须分配
sighand_struct结构
atomic_inc(&current->sighand->count);
return 0;
}
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
rcu_assign_pointer(tsk->sighand, sig);

if (!sig)
return -ENOMEM;
atomic_set(&sig->count, 1);

memcpy(sig->action, current->sighand->action, sizeof(sig->action));
return 0;
}

 

2.2 线程有独立的阻塞信号掩码(信号集)

每个线程都拥有独立的阻塞信号掩码。 在介绍这条性质之前, 首先需要介绍什么是阻塞信号掩码。

就像我们开重要会议时要关闭手机一样, 进程在执行某些重要操作时, 不希望内核递送某些信号, 阻塞信号掩码就是用来实现该功能的。

如果进程将某信号添加进了阻塞信号掩码, 纵然内核收到了该信号, 甚至该信号在挂起队列中已经存在了相当长的时间, 内核也不会将信号递送给进程, 直到进程解除对该信号的阻塞为止。

开会时关闭手机是一种比较极端的例子。 更合理的做法是暂时屏蔽部分人的电话。 对于某些重要的电话, 比如儿子老师的电话、 父母的电话或老板的电话, 是不希望被屏蔽的。 信号也是如此。 进程在执行某些操作的时候, 可能只需要屏蔽一部分信号, 而不是所有信号。

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

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

sigemptyset函数用来初始化一个空的未包含任何信号的信号集, 而sigfillset函数则会初始化一个包含所有信号的信号集。

注意

必须要调用这两个初始化函数中的一个来初始化信号集, 对于声明了sigset_t类型的变量, 不能一厢情愿地假设它是空集合,也不能调用memset函数, 或者用赋值为0的方式来初始化。
初始化信号之后, Linux提供了sigaddset函数向信号集中添加一个信号, 同时还提供了sigdelset函数在信号集中移除一个信号:

int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum)

为了判断某一个信号是否属于信号集, Linux提供了sigismember函数:

int sigismember(const sigset_t *set, int signum);

如果signum属于信号集, 则返回1, 否则返回0。 出错的时候, 返回-1。

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

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

sigprocmask根据how的值, 提供了三种用于改变进程的阻塞信号掩码的方式, 见表6-14。
Linux线程与信号_多线程_02

注意

我们知道SIGKILL信号和SIGSTOP信号不能阻塞, 可是如果调用sigprocmask函数时, 将SIGKILL信号和SIGSTOP信号添加进阻
塞信号集中, 会怎么样?
答案是不怎么样。 sigprocmask函数不会报错, 但是也不会将SIGKILL和SIGSTOP真的添加进阻塞信号集中。
对应的rt_sigprocmask系统调用会执行如下语句, 剔除掉集合中的SIGKILL和SIGSTOP:

sigdelsetmask(&new_set, sigmask(SIGKILL)|sigmask(SIGSTOP));

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

struct task_struct{
sigset_t blocked;
}

sigprocmask函数改变的是调用线程的阻塞信号掩码, 而不是整个进程。 sigprocmask出现得比较早, 它出现在线程尚未引入Linux的时代。 在单线程的时代, 进程的阻塞信号掩码和线程的阻塞掩码是一回事, 但是引入多线程之后, sigprocmask的语义就变成了设置调用线程的阻塞信号掩码。
为了更显式地设置线程的阻塞信号掩码, 线程库提供了pthread_sigmask函数来设置线程的阻塞信号掩码:

#include <signal.h>
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);

事实上pthread_sigmask函数和sigprocmask函数的行为是一样的。
 

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

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;
//...
}

 

Linux线程与信号_信号处理_03

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

图6-4描述的是通过kill、 sigqueue、 tkill和tgkill发送信号后, 内核的相关处理流程。
Linux线程与信号_linux_04

从图6-4中可以看出, 向进程发送信号也好, 向线程发送信号也罢, 最终都殊途同归, 在do_send_sig_info函数处会师。 尽管会师在一处, 却还是存在不同。 不同的地方在于, 到底将信号放入哪个挂起队列。
在__send_signal函数中, 通过group入参的值来判断需要将信号放入哪个挂起队列(如果需要进队列的话) 。

static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
int group, int from_ancestor_ns)
{
//...
pending = group ? &t->signal->shared_pending : &t->pending;
//...
}

如果用户调用的是kill或sigqueue, 那么group就是1; 如果用户调用的是tkill或tgkill, 那么group参数就是0。 内核就是以此来区分该信号是发给进程的还是发给某个特定线程的, 如表6-15所示。
Linux线程与信号_多线程_05

上述情景并不难理解。 多线程的进程就像是一个班级, 进程下的每一个线程就像是班级的成员。 kill和sigqueue函数发送的信号是给进程
的, 就像是优秀班集体的荣誉是颁发给整个班级的; tkill和tgkill发送的信号是给特定线程的, 就像是三好学生的荣誉是颁发给学生个的。

另一个需要解决的问题是, 多线程情况下发送给进程的信号, 到底由哪个线程来负责处理? 这个问题就和高二(五) 班荣获优秀班集体,由谁负责上台领奖一样。

内核是不是一定会将信号递送给进程的主线程?

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

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

signal_wake_up(t, sig == SIGKILL);

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

static inline int wants_signal(int sig, struct task_struct *p)
{
if (sigismember(&p->blocked, sig)) /*位于阻塞信号集, 不方便*/
return 0;
if (p->flags & PF_EXITING) /*正在退出, 不方便*/
return 0;
if (sig == SIGKILL) /*SIGKILL信号, 必须处理*/
return 1;
if (task_is_stopped_or_traced(p)) /*被调试或被暂停, 不方便*/
return 0;
return task_curr(p) || !signal_pending(p);
}

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

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

该函数很容易产生误解, 很多人认为该接口返回的是线程的挂起信号, 即还没有来得及处理的信号, 这种理解其实是错误的。
严格来讲, 返回的信号集中的信号必须同时满足以下两个条件:

  • 处于挂起状态。
  • 信号属于线程的阻塞信号集。

看下内核的do_sigpending函数的内容就不难理解sigpending函数的含义了:
 

spin_lock_irq(&current->sighand->siglock);
sigorsets(&pending, &current->pending.signal,&current->signal->shared_pending.signal);
spin_unlock_irq(&current->sighand->siglock);
sigandsets(&pending, &current->blocked, &pending);
error = -EFAULT;
if (!copy_to_user(set, &pending, sigsetsize))
error = 0;

因此, 返回的挂起阻塞信号集合的计算方式是:
1) 进程共享的挂起信号和线程私有的挂起信号取并集, 得到集合1。
2) 对集合1和线程的阻塞信号集取交集, 以获得最终的结果。
从此处可以看出, sigprocmask函数会影响到sigpendig函数的输出结果。
 

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

关于进程的退出, 前面已经有所提及, Linux为了应对多线程, 提供了exit_group系统调用, 确保多
个线程一起退出。 对于线程收到致命信号的这种情况, 操作是类似的。 可以通过给每个调度实体的
pending上挂上一个SIGKILL信号以确保每个线程都会退出。 此处就不再赘述了。
 

 

 

 

总结:

  • 信号处理函数是进程层面的概念, 或者说是线程组层面的概念, 线程组内所有线程共享对信号的处理函数。
  • 对于发送给进程的信号, 内核会任选一个线程来执行信号处理函数, 执行完后, 会将其从挂起信号队列中去除, 其他进程不会对一个信号重复响应。
  • 可以针对进程中的某个线程发送信号, 那么只有该线程能响应, 执行相应的信号处理函数。
  • 信号掩码是线程层面的概念, 信号处理函数在线程组内是统一的, 但是信号掩码是各自独立可配置的, 各个线程独立配置自己要阻止或放行的信号集合。
  • 挂起信号(内核已经收到, 但尚未递送给线程处理的信号) 既是针对进程的, 又是针对线程的。

内核维护两个挂起信号队列, 一个是进程共享的挂起信号队列, 一个是线程特有的挂起信号队列。 调用函数sigpending返回的是两者的并集。 对于线程而言, 优先递送发给线程自身的信号。

 

2.1 设置线程的信号掩码

前面已提到过, 信号掩码是针对线程的, 每个线程都可以自行设置自己的信号掩码。 如果自己不设置, 就会继承创建者的信号掩码。
NPTL实现了如下接口来设置线程的信号掩码:

#include <signal.h>
int pthread_sigmask(int how, const sigset_t *new, sigset_t *old);

how的值用来指定如何更改信号组:

  • SIG_BLOCK向当前信号掩码中添加new, 其中new表示要阻塞的信号组。
  • SIG_UNBLOCK从当前信号掩码中删除new, 其中new表示要取消阻塞的信号组。
  • SIG_SETMASK将当前信号掩码替换为new, 其中new表示新的信号掩码。

该接口的使用方式和sigprocmask一模一样, 在Linux上, 两个函数的实现是相同的。

说明 SIGCANCEL和SIGSETXID信号被用于NPTL实现, 因此用户不能也不应该改变这两个信号的行为方式。 好在用户不用操心这两个信号, sigprocmask函数和pthread_sigmask函数对这两者都做了特殊处理。
 

2.2 向线程发送信号

前面提到过向线程发送信号的系统调用tkill/tgkill, 无奈glibc并未将它们封装成可以直接调用的函数。 不过, 幸好提供了另外一个函数:

int pthread_kill(pthread_t thread, int sig);

由于pthread_t类型的线程ID只在线程组内是唯一的, 其他进程完全可能存在线程ID相同的线程, 所以pthread_kill只能向同一个进程的线程发送信号。
除了这个接口外, Linux还提供了特有的函数将pthread_kill和sigqueue功能累加在一起:

#define _GNU_SOURCE
#include <pthread.h>
int pthread_sigqueue(pthread_t thread, int sig,
const union sigval value);

这个接口和sigqueue一样, 可以发送携带数据的信号。 当然, 只能发给同一个进程内的线程。
 

2.3 多线程程序对信号的处理

单线程的程序, 对信号的处理已经比较复杂了。 因为信号打断了进程的控制流, 所以信号处理函数只能调用异步信号安全的函数。 而异步信号安全是个很苛刻的条件。
多线程的引入, 加剧了这种复杂度。 因为信号可以发送给进程, 也可以发送给进程内的某一线程。 不同线程还可以设置自己的掩码来实现对信号的屏蔽。 而且, 没有一个线程相关的函数是异步信号安全的, 信号处理函数不能调用任何pthread函数, 也不能通过条件变量来通知其他线程。
正如陈硕在《Linux多线程服务器编程》 中提到的, 在多线程程序中, 使用信号的第一原则就是不要使用信号。

  • 不要主动使用信号作为进程间通信的手段, 收益和引入的风险完全不成比例。
  • 不主动改变异常处理信号的信号处理函数。 用于管道和socket的SIGPIPE可能是例外, 默认语义是终止进程, 很多情况下, 需要忽略该信号。
  • 如果无法避免, 必须要处理信号, 那么就采用sigwaitinfo或signalfd的方式同步处理信号, 减少异步处理带来的风险和引入bug的可能。