信号机制是操作系统中非常重要的部分,它可以用于进程/线程间通信、控制进程线程的行为和处理高优的任务,GDB、CPU Profile 采集、Go 抢占式调度都依赖信号机制。信号的大致原理不是很复杂,在单进程单线程环境中,信号机制类似一个订阅发布模式,用户注册信号处理函数,收到信号时,操作系统执行对应的函数,但是在多线程环境下,里面的逻辑就变得复杂了,比如说我们是否可以单独给线程发送信号,应该在哪个线程中注册信号处理函数,给进程发送信号时哪个线程会处理等等。本文从 Linux 源码角度分析信号的实现原理。
进程的信号原理
首先从早期的内核代码(1.2.13)看一下信号的实现,因为早期的代码易于我们理解原理。我们知道 Linux 不区分进程线程,统一使用 task_struct 来表示,task_struct 中有几个字段和信号机制相关。
复制
unsigned long signal; // 当前收到的信号,每一 bit 对应一个信号,比如 0b10 对应信号 2
unsigned long blocked; // 屏蔽这些信号,即不处理这些信号
/*
struct sigaction {
__sighandler_t sa_handler; // 处理函数
sigset_t sa_mask;
unsigned long sa_flags;
void (*sa_restorer)(void);
};
*/
struct sigaction sigaction[32]; // 信号对应的处理函数,和信号的值一一对应,比如信号 1 对应数组 第一个元素1.2.3.4.5.6.7.8.9.10.11.
了解了和信号相关的数据结构后,接着从注册信号、发送信号、处理信号几个方面分析信号的实现。
注册信号
复制
asmlinkage int sys_sigaction(int signum, const struct sigaction * action,
struct sigaction * oldaction)
{
struct sigaction new_sa, *p;
// current 表示当前调用进程,p 指向 signum 对应的处理函数
p = signum - 1 + current->sigaction;
if (action) {
// 复制内存
memcpy_fromfs(&new_sa, action, sizeof(struct sigaction));
}
// 返回旧的处理函数
if (oldaction) {
int err = verify_area(VERIFY_WRITE, oldaction, sizeof(*oldaction));
if (err)
return err;
memcpy_tofs(oldaction, p, sizeof(struct sigaction));
}
// 设置新的处理函数
if (action) {
*p = new_sa;
check_pending(signum);
}
return0;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.
注册信号就是在 task_struct 的 sigaction 中记录信号对应的处理函数。
发送信号
我们应该都试过用 kill 命令给某个进程发送信息,我们也可以通过操作系统底层提供的 kill 系统调用给进程发送信息。
复制
asmlinkage int sys_kill(int pid,int sig)
{
int err, retval = 0, count = 0;
// 如果没有传pid,则给该进程所在组所有进程发该信号
if (!pid)
return(kill_pg(current->pgrp,sig,0));
// 如果pid等于-1,则给除了自己和0进程外的所有进程发该信号
if (pid == -1) {
struct task_struct * p;
for_each_task(p) {
if (p->pid > 1 && p != current) {
++count;
if ((err = send_sig(sig,p,0)) != -EPERM)
retval = err;
}
}
return(count ? retval : -ESRCH);
}
// 如果pid等于除-1外的负数,则取绝对值后,给该进程组发该信号
if (pid < 0)
return(kill_pg(-pid,sig,0));
/* Normal kill */
// 否则给某个进程发该信号
return(kill_proc(pid,sig,0));
}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.
sys_kill 支持多种场景,这里我们只关注给指定进程发送的部分。
复制
int kill_proc(int pid, int sig, int priv)
{
struct task_struct *p;
// 遍历进程列表,找到对应的进程,然后发送信号
for_each_task(p) {
if (p && p->pid == pid)
return send_sig(sig,p,priv);
}
return(-ESRCH);
}
int send_sig(unsigned long sig,struct task_struct * p,int priv)
{
// 设置对应的位为 1
p->signal |= 1 << (sig-1);
return0;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.
发送信号的实现很简单,就是在 task_struct 的 signal 字段置对应的为 1,表示收到了该信息。从这里可以看到重复发生信号可能会发生覆盖,最终只会处理一次,不过现在的内核版本已经支持记录每一个收到的信号了。
处理信号
刚才看到,发送信号时只是打了个标记,并没有处理该信号,也就是执行用户注册的函数。那么什么时候才会处理呢?处理的时机有几个,比如中断处理后、系统调用结束后。下面是系统调用后处理信号的逻辑。
复制
_system_call:
pushl %eax # save orig_eax
SAVE_ALL
// 执行系统调用
movl _sys_call_table(,%eax,4),%eax
call *%eax
movl %eax,EAX(%esp) # save the return value
movl errno(%ebx),%edx
negl %edx
je ret_from_sys_call
ret_from_sys_call:
// 当天进程结构体赋值到 eax
movl _current,%eax
// 判断是否有信号并且没有被屏蔽
movl blocked(%eax),%ecx
movl %ecx,%ebx
notl %ecx
andl signal(%eax),%ecx
// 非 0 说明有信号需要处理
jne signal_return
signal_return:
movl %esp,%ecx
pushl %ecx
pushl %ebx
// 处理信号
call _do_signal
popl %ebx
popl %ebx1.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.
接着看一些 do_signal 的实现。
复制
asmlinkage int do_signal(unsigned long oldmask, struct pt_regs * regs)
{
// mask 等于 blocked 取反,表示没有被屏蔽的信号
unsignedlong mask = ~current->blocked;
unsignedlong handler_signal = 0;
unsignedlong *frame = NULL;
unsignedlong eip = 0;
unsignedlong signr;
struct sigaction * sa;
// 收集需要处理的信号
while ((signr = current->signal & mask)) {
// 获取
__asm__("bsf %3,%1\n\t"
"btrl %1,%0"
:"=m" (current->signal),"=r" (signr)
:"0" (current->signal), "1" (signr));
signr++;
// 哪些信息需要处理
handler_signal |= 1 << (signr-1);
// 执行当前信号时需要屏蔽的信息,取反再与得到最终需要处理的信息
mask &= ~sa->sa_mask;
}
// 当前的指令地址
eip = regs->eip;
frame = (unsignedlong *) regs->esp;
signr = 1;
sa = current->sigaction;
// 逐个信号处理
for (mask = 1 ; mask ; sa++,signr++,mask += mask) {
// 构造栈内存布局
setup_frame(sa,&frame,eip,regs,signr,oldmask);
// do_signal 执行完毕后执行的执行
eip = (unsignedlong) sa->sa_handler;
current->blocked |= sa->sa_mask;
oldmask |= sa->sa_mask;
}
// 设置信息的栈地址和指令
regs->esp = (unsignedlong) frame;
regs->eip = eip; /* "return" to the first handler */
return1;
}
void setup_frame(struct sigaction * sa,
unsigned long ** fp, // 当前的栈地址
unsigned long eip,
struct pt_regs * regs,
int signr,
unsigned long oldmask)
{
unsignedlong * frame;
frame = *fp;
// ...
put_fs_long(signr, frame+1);
put_fs_long(eip, frame+16);// 执行完处理函数后的回跳地址
put_fs_long(regs->cs, frame+17);
put_fs_long(regs->eflags, frame+18);
put_fs_long(regs->esp, frame+19);
put_fs_long(regs->ss, frame+20);
put_fs_long(0x0000b858, CODE(0)); /* popl %eax ; movl $,%eax */
put_fs_long(0x80cd0000, CODE(4)); /* int $0x80 */
// 恢复现场,回到正常流程。
put_fs_long(__NR_sigreturn, CODE(2));
}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.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.
信号处理的过程涉及到的东西比较复杂,如果大家对函数调用时栈的布局了解的话应该会比较好理解,大概就是在当前的栈上进行内存布局,让所有的处理函数连成一条执行链,然后 do_signal 执行完后会从第一个执行函数开始执行,一直执行到最后一个,最终恢复现场回到正常执行流程。Go 的抢占式调度同样是用了类似的原理,就是在信号处理函数里修改栈内存注入自定义的函数,信号处理完成后,执行自定义的函数实现抢占。
多线程的信号原理
通过刚才的介绍,大概了解了信号处理的过程和原理。但是多线程中的情况有一点不一样,在 Linux 中,可以给进程或线程发送信号,线程有自己的接收信号和信号屏蔽字,但是进程内的所有线程共享信号处理函数,另外线程还会共享进程收到的信号。
图片
下面通过内核源码看一下实现(2.6.11.1),该版本代码中和信号相关的字段如下。
复制
/*
struct signal_struct {
// 进程级的信号,多个线程共享
struct sigpending shared_pending;
}
*/
struct signal_struct *signal; // 进程级信号
struct sighand_struct *sighand; // 进程级信号处理函数
sigset_t blocked, real_blocked; // 线程级信号屏蔽字
/*
// 同一个信号可能收到多次,在 list 中排队,通过 signal 表示收到了什么信号
struct sigpending {
struct list_head list; // 收到的所有信号
sigset_t signal; // 收到了哪些信号
};
*/
struct sigpending pending; // 线程级收到的信号
unsigned long sas_ss_sp; // 信号处理函数的栈
size_t sas_ss_size;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.
创建线程
当通过 clone 创建线程时,会对上面的字段进行处理。
复制
asmlinkage int sys_clone(struct pt_regs regs)
{
return do_fork(...);
}
long do_fork(...)
{
struct task_struct *p;
// 复制内容
p = copy_process(clone_flags, stack_start, regs, stack_size, parent_tidptr, child_tidptr, pid);
return pid;
}
static task_t *copy_process(...)
{
int retval;
struct task_struct *p = NULL;
// 创建线程必须设置 CLONE_SIGHAN,共享信号处理函数
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);
// 获取一个新的 task_struct 结构体,内容复制当前进程的
p = dup_task_struct(current);
// 初始化信号相关字段
clear_tsk_thread_flag(p, TIF_SIGPENDING);
init_sigpending(&p->pending);
/*
// 引用计数加一
if (clone_flags & (CLONE_SIGHAND | CLONE_THREAD)) {
atomic_inc(¤t->sighand->count);
return 0;
}
*/
copy_sighand(clone_flags, p));
/*
// 引用计数加一
if (clone_flags & CLONE_THREAD) {
atomic_inc(¤t->signal->count);
atomic_inc(¤t->signal->live);
return 0;
}
*/
copy_signal(clone_flags, p));
}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.37.38.39.40.41.42.43.44.45.46.47.
可以每个线程都有自己接收的信号(p->pending 字段),但是进程级的数据结构只是引用计数加一,也就是说它们是多个线程共享的。
发送信号
接着看给线程发送信号时的过程。
复制
// tgid 为线程组 id,即进程 id,pid 为线程 id,即 tid
asmlinkage long sys_tgkill(int tgid, int pid, int sig)
{
struct siginfo info;
int error;
struct task_struct *p;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_TKILL;
info.si_pid = current->tgid;
info.si_uid = current->uid;
// 根据线程 id 找到对应的 task_struct
p = find_task_by_pid(pid);
error = -ESRCH;
// 只能给同进程的线程发
if (p && (p->tgid == tgid)) {
if (sig && p->sighand) {
error = specific_send_sig_info(sig, &info, p);
}
}
return error;
}
static int specific_send_sig_info(int sig, struct siginfo *info, struct task_struct *t)
{
int ret = 0;
ret = send_signal(sig, info, t, &t->pending);
return ret;
}
static int send_signal(int sig, struct siginfo *info, struct task_struct *t,
struct sigpending *signals)
{
struct sigqueue * q = NULL;
int ret = 0;
// 分配一个 struct sigqueue,表示一个信号
q = __sigqueue_alloc(t, GFP_ATOMIC);
if (q) {
// 插入 task_struct 结构体 pending 字段的队列,即线程级的信号
list_add_tail(&q->list, &signals->list);
// ...
}
// 设置 bitmap,表示收到该信号
sigaddset(&signals->signal, sig);
}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.37.38.39.40.41.42.43.44.45.46.
给线程发送信号,最后就是在 task_struct 的 pending 字段记录置位并把信号信息加入队列中。接下来,再看下通过 sys_kill 给进程发送信号的流程。
复制
asmlinkage long sys_kill(int pid, int sig)
{
struct siginfo info;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info.si_pid = current->tgid;
info.si_uid = current->uid;
return kill_something_info(sig, &info, pid);
}
static int kill_something_info(int sig, struct siginfo *info, int pid)
{
return kill_proc_info(sig, info, pid);
}
int kill_proc_info(int sig, struct siginfo *info, pid_t pid)
{
int error;
struct task_struct *p;
read_lock(&tasklist_lock);
// 通过 pid 找到进程结构体
p = find_task_by_pid(pid);
error = -ESRCH;
if (p)
error = group_send_sig_info(sig, info, p);
read_unlock(&tasklist_lock);
return error;
}
int group_send_sig_info(int sig, struct siginfo *info, struct task_struct *p)
{
unsignedlong flags;
int ret;
if (!ret && sig && p->sighand) {
ret = __group_send_sig_info(sig, info, p);
}
return ret;
}
staticint __group_send_sig_info(int sig, struct siginfo *info, struct task_struct *p)
{
int ret = 0;
ret = send_signal(sig, info, p, &p->signal->shared_pending);
__group_complete_signal(sig, p);
return0;
}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.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.
给进程发送信号的流程和线程的类似,最终都是调 send_signal,但是有一些区别,给线程发送时 send_signal 的最后一个参数是 p->pending,给进程发送时参数是 p->signal->shared_pending,所以分别是在不同的字段记录了接收到的信号,另外还有一个核心的逻辑在__group_complete_signal 中。
复制
// p 没有屏蔽该信号,p 不是退出状态,p 是当前 task_struct 或没有待处理的信号
#define wants_signal(sig, p, mask) \
(!sigismember(&(p)->blocked, sig) \
&& !((p)->state & mask) \
&& !((p)->flags & PF_EXITING) \
&& (task_curr(p) || !signal_pending(p)))
staticvoid __group_complete_signal(int sig, struct task_struct *p)
{
unsignedint mask;
struct task_struct *t;
// p 适合处理该信号则给 p
if (wants_signal(sig, p, mask))
t = p;
elseif (thread_group_empty(p))
/*
* There is just one thread and it does not need to be woken.
* It will dequeue unblocked signals before it runs again.
*/
return;
else {
/*
* Otherwise try to find a suitable thread.
*/
t = p->signal->curr_target;
if (t == NULL)
/* restart balancing at this thread */
t = p->signal->curr_target = p;
// 遍历进程下的线程看哪个适合处理
while (!wants_signal(sig, t, mask)) {
t = next_thread(t);
if (t == p->signal->curr_target)
/*
* No thread needs to be woken.
* Any eligible threads will see
* the signal in the queue soon.
*/
return;
}
p->signal->curr_target = t;
}
// 唤醒选择的线程处理信号
signal_wake_up(t, ....);
return;
}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.37.38.39.40.41.42.43.44.45.46.47.
给线程发送信号时,给哪个线程发就在哪个线程的上下文执行信号处理函数,但是给进程发信号时,Linux 会根据情况选择一个适合处理该信号的线程,然后唤醒它处理。
处理信号
最后再看下信号处理的过程。
复制
void do_notify_resume(struct pt_regs *regs, sigset_t *oldset,
__u32 thread_info_flags)
{
if (thread_info_flags & _TIF_SIGPENDING)
do_signal(regs,oldset);
}
int fastcall do_signal(struct pt_regs *regs, sigset_t *oldset)
{
siginfo_t info;
int signr;
struct k_sigaction ka;
// 获取一个信号
signr = get_signal_to_deliver(&info, &ka, regs, NULL);
if (signr > 0) {
// 处理信号
handle_signal(signr, &info, &ka, oldset, regs);
return1;
}
return0;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.
处理信号的过程和前面的介绍类似,修改栈内存布局,注入信号处理函数,执行完 do_notify_resume 后开始执行信号处理函数,最终返回再返回正常流程。这里主要看一下获取信号的流程。
复制
int get_signal_to_deliver(siginfo_t *info, struct k_sigaction *return_ka,
struct pt_regs *regs, void *cookie)
{
sigset_t *mask = ¤t->blocked;
int signr = 0;
relock:
spin_lock_irq(¤t->sighand->siglock);
for (;;) {
struct k_sigaction *ka;
// 获取一个信号
signr = dequeue_signal(current, mask, info);
// 设置信号处理函数
ka = ¤t->sighand->action[signr-1];
}
return signr;
}
int dequeue_signal(struct task_struct *tsk, sigset_t *mask, siginfo_t *info)
{
// 先从线程自己的信号字段获取
int signr = __dequeue_signal(&tsk->pending, mask, info);
// 没有再从进程的信号获取
if (!signr)
signr = __dequeue_signal(&tsk->signal->shared_pending,
mask, info);
return signr;
}
staticint __dequeue_signal(struct sigpending *pending, sigset_t *mask,
siginfo_t *info)
{
int sig = 0;
// 获取下一个待处理的信息
sig = next_signal(pending, mask);
if (sig) {
// 处理了该信号,修改相关的数据结构
if (!collect_signal(sig, pending, info))
sig = 0;
}
recalc_sigpending();
return sig;
}
static inline int collect_signal(int sig, struct sigpending *list, siginfo_t *info)
{
struct sigqueue *q, *first = NULL;
int still_pending = 0;
// 从信号队列中获取一个信号,并判断是否还有该信号需要处理,因为一类信号可能会收到多个
list_for_each_entry(q, &list->list, list) {
if (q->info.si_signo == sig) {
if (first) {
still_pending = 1;
break;
}
first = q;
}
}
if (first) {
// 移出队列
list_del_init(&first->list);
copy_siginfo(info, &first->info);
__sigqueue_free(first);
// 如果没有该类信号则把信号 bitmap 置 0
if (!still_pending)
sigdelset(&list->signal, sig);
}
return1;
}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.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.
总结
通过前面的分析可以看到,在多线程环境中,哪个线程设置处理函数并不重要,因为都是进程内共享的,重要的是给线程还是进程发送信号,当给线程发送信号时,会在线程独有的信号字段记录收到的信号,该线程会在自己的执行上下文调用信号处理函数,当给进程发送信号时,会在所有线程都共享的字段中记录收到的信号,而这个信号给哪个线程处理是不确定的,操作系统会根据情况选择一个线程并唤醒它,然后在该线程的执行上下文处理信号时,会先判断有没有收到线程级的信号,如果没有的话再判断是否有进程级的信号,然后进行处理。