Linux 多线程的信号处理机制

信号机制是操作系统中非常重要的部分,它可以用于进程/线程间通信、控制进程线程的行为和处理高优的任务,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.

总结

通过前面的分析可以看到,在多线程环境中,哪个线程设置处理函数并不重要,因为都是进程内共享的,重要的是给线程还是进程发送信号,当给线程发送信号时,会在线程独有的信号字段记录收到的信号,该线程会在自己的执行上下文调用信号处理函数,当给进程发送信号时,会在所有线程都共享的字段中记录收到的信号,而这个信号给哪个线程处理是不确定的,操作系统会根据情况选择一个线程并唤醒它,然后在该线程的执行上下文处理信号时,会先判断有没有收到线程级的信号,如果没有的话再判断是否有进程级的信号,然后进行处理。

THE END