七、进程退出和等待的内核实现
Linux引入多线程之后,为了支持进程的所有线程能够整体退出,内核引入了exit_group系统调用。对于进程而言,无论是调用exit()函数、_exit()函数还是在main函数中return,最终都会调用exit_group系统调用。
对于单线程的进程,从do_exit_group直接调用do_exit就退出了。但是对于多线程的进程,如果某一个线程调用了exit_group系统调用,那么该线程在调用do_exit之前,会通过zap_other_threads函数,给每一个兄弟线程挂上一个SIGKILL信号。内核在尝试递送信号给兄弟进程时(通过get_signal_to_deliver函数),会在挂起信号中发现SIGKILL信号。内核会直接调用do_group_exit函数让该线程也退出(如图4-13所示)。这个过程在第3章中已经详细分析过了。
在do_exit函数中,进程会释放几乎所有的资源(文件、共享内存、信号量等)。该进程并不甘心,因为它还有两桩心愿未了:
1.作为父进程,它可能还有子进程,进程退出以后,将来谁为它的子进程收尸”。
2.作为子进程,它需要通知它的父进程来为自己“收尸”。
这两件事情是由exit_notify来负责完成的,具体来说forget_original_parent函数和do_notify_parent函数各自负责一件事,如表4-9所示。
forget_original_parent(),多么“悲伤”的函数名。顾名思义,该函数用来给自己的子进程安排新的父进程。
给自己的子进程安排新的父进程,细分下来,是两件事情:
1)为子进程寻找新的父进程。
2)将子进程的父进程设置为第1)步中找到的新的父亲。
为子进程寻找父进程,是由find_new_reaper()函数完成的。如果退出的进程是多线程进程,则可以将子进程托付给自己的兄弟线程。如果没有这样的线程,就“托孤”给init进程。
1
2 1static void forget_original_parent(struct task_struct *father) { struct task_struct *p, *n, *reaper; LIST_HEAD(dead_children); write_lock_irq(&tasklist_lock); /* * Note that exit_ptrace() and find_new_reaper() might * drop tasklist_lock and reacquire it. */ exit_ptrace(father); reaper = find_new_reaper(father); list_for_each_entry_safe(p, n, &father->children, sibling) { struct task_struct *t = p; do { t->real_parent = reaper; if (t->parent == father) { BUG_ON(t->ptrace); t->parent = t->real_parent; } /*内核提供了机制, 允许父进程退出时向子进程发送信号*/ if (t->pdeath_signal) group_send_sig_info(t->pdeath_signal, SEND_SIG_NOINFO, t); } while_each_thread(p, t); reparent_leader(father, p, &dead_children); } write_unlock_irq(&tasklist_lock); BUG_ON(!list_empty(&father->children)); list_for_each_entry_safe(p, n, &dead_children, sibling) { list_del_init(&p->sibling); release_task(p); } }
2
1
2 1
2
这部分代码比较容易引起困扰的是下面这行,我们都知道,子进程“死”的时候,会向父进程发送信号SIGCHLD,Linux也提供了一种机制,允许父进程“死”的时候向子进程发送信号。
1
2 1if (t->pdeath_signal) group_send_sig_info(t->pdeath_signal, SEND_SIG_NOINFO, t);
2
读者可以通过man prctl,查看PR_SET_PDEATHSIG标志位部分。如果应用程序通过prctl函数设置了父进程“死”时要向子进程发送信号,就会执行到这部分内核代码,以通知其子进程。
接下来是第二桩未了的心愿:
想办法通知父进程为自己“收尸”。对于单线程的程序来说完成这桩心愿比较简单,但是多线程的情况就复杂些。只有线程组的主线程才有资格通知父进程,线程组的其他线程终止的时候,不需要通知父进程,也没必要保留最后的资源并陷入僵尸态,直接调用release_task函数释放所有资源就好。
为什么要这样设计?细细想来,这么做是合理的。父进程创建子进程时,只有子进程的主线程是父进程亲自创建出来的,是父进程的亲生儿子,父进程也只关心它,至于子进程调用pthread_create产生的其他线程,父进程压根就不关心。
由于父进程只认子进程的主线程,所以在线程组中,主线程一定要挺住。在用户层面,可以调用pthread_exit让主线程先“死”,但是在内核态中,主线程的task_struct一定要挺住,哪怕变成僵尸,也不能释放资源。
生命在于“折腾”,如果主线程率先退出了,而其他线程还在正常工作,内核又将如何处理?
1
2
3
4
5
6
7
8
9
10
11 1else if (thread_group_leader(tsk)) {
2 /*线程组组长只有在全部线程都已退出的情况下,
3 *才能调用do_notify_parent通知父进程*/
4 autoreap = thread_group_empty(tsk) && //必须全部退出才会
5 do_notify_parent(tsk, tsk->exit_signal);
6 } else {
7 /*如果是线程组的非组长线程, 可以立即调用release_task,
8 *释放残余的资源, 因为通知父进程这件事和它没有关系*/
9 autoreap = true;
10 }
11
1
2 1 小结:
2
上面的代码给出了答案,如果退出的进程是线程组的主线程,但是线程组中还有其他线程尚未终止(thread_group_empty函数返回false),那么autoreaper就等于false,也就不会调用do_notify_parent向父进程发送信号了。
因为子进程的线程组中有其他线程还活着,因此子进程的主线程退出时不能通知父进程,错过了调用do_notify_parent的机会,那么父进程如何才能知晓子进程已经退出了呢?答案会在最后一个线程退出时揭晓。此答案就藏在内核的release_task函数中:
1
2 1leader = p->group_leader; //不是主线程 自己是最后一个线程 主线程除以僵尸状态 if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) { zap_leader = do_notify_parent(leader, leader->exit_signal);//像父进程发送信号函数 if (zap_leader) leader->exit_state = EXIT_DEAD; }
2
1
2 1
2
当线程组的最后一个线程退出时,如果发现:
1.该线程不是线程组的主线程。
2.线程组的主线程已经退出,且处于僵尸状态。
3.自己是最后一个线程。
同时满足这三个条件的时候,该子进程就需要冒充线程组的组长,即以子进程的主线程的身份来通知父进程。
八、总结
上面讨论了一种比较少见又比较折腾的场景,正常的多线程编程应该不会如此安排。对于多线程的进程,一般情况下会等所有其他线程退出后,主线程才退出。这时,主线程会在exit_notify函数中发现自己是组长,线程组里所有成员均已退出,然后它调用do_notify_parent函数来通知父进程。
无论怎样,子进程都走到了do_notify_parent函数这一步。该函数是完成父子进程之间互动的主要函数。
//子进程的主要线程pcb,退出信号 bool do_notify_parent(struct task_struct *tsk, int sig) { struct siginfo info; unsigned long flags; struct sighand_struct *psig; bool autoreap = false; BUG_ON(sig == -1); /* do_notify_parent_cldstop should have been called instead. */ BUG_ON(task_is_stopped_or_traced(tsk)); BUG_ON(!tsk->ptrace && (tsk->group_leader != tsk || !thread_group_empty(tsk))); if (sig != SIGCHLD) { /* * This is only possible if parent == real_parent. * Check if it has changed security domain. */ if (tsk->parent_exec_id != tsk->parent->self_exec_id) sig = SIGCHLD; } info.si_signo = sig; info.si_errno = 0; rcu_read_lock(); info.si_pid = task_pid_nr_ns(tsk, tsk->parent->nsproxy->pid_ns); info.si_uid = __task_cred(tsk)->uid; rcu_read_unlock(); info.si_utime = cputime_to_clock_t(cputime_add(tsk->utime, tsk->signal->utime)); info.si_stime = cputime_to_clock_t(cputime_add(tsk->stime, tsk->signal->stime)); info.si_status = tsk->exit_code & 0x7f; if (tsk->exit_code & 0x80) info.si_code = CLD_DUMPED; else if (tsk->exit_code & 0x7f) info.si_code = CLD_KILLED; else { info.si_code = CLD_EXITED; info.si_status = tsk->exit_code >> 8; } psig = tsk->parent->sighand; spin_lock_irqsave(&psig->siglock, flags) //是SIGCHLD信号 但父进程的信号处理函数设置为SIG_IGN或者flag设置为SA_NOCLDWAIT位 if (!tsk->ptrace && sig == SIGCHLD &&(psig->action[SIGCHLD-1].sa.sa_handler == SIG_IGN || (psig->action[SIGCHLD-1].sa.sa_flags & SA_NOCLDWAIT))) { autoreap = true;//设置为true,表示父进程不关心自己的退出信息,将会调用release_task函数,释放残余的资源,自行了断,子进程也就不会进入僵尸状态了。 if (psig->action[SIGCHLD-1].sa.sa_handler == SIG_IGN) sig = 0; } /*子进程向父进程发送信号*/ if (valid_signal(sig) && sig) __group_send_sig_info(sig, &info, tsk->parent); /* 子进程尝试唤醒父进程, 如果父进程正在等待其终止 */ __wake_up_parent(tsk, tsk->parent); spin_unlock_irqrestore(&psig->siglock, flags); return autoreap; }
1.父子进程之间的互动有两种方式:
1).子进程向父进程发送信号SIGCHLD。
2).子进程唤醒父进程。
对于这两种方法,我们分别展开讨论。
父子进程互动之SIGCHLD信号:
父进程可能并不知道子进程是何时退出的,如果调用wait函数等待子进程退出,又会导致父进程陷入阻塞,无法执行其他任务。那有没有一种办法,让子进程退出的时候,异步通知到父进程呢?答案是肯定的。当子进程退出时,会向父进程发送SIGCHLD信号。父进程收到该信号,默认行为是置之不理。在这种情况下,子进程就会陷入僵尸状态,而这又会浪费系统资源,该状态会维持到父进程退出,子进程被init进程接管,init进程会等待僵尸进程,使僵尸进程释放资源。如果父进程不太关心子进程的退出事件,听之任之可不是好办法,可以采取以下办法:
a)、父进程调用signal函数或sigaction函数,将SIGCHLD信号的处理函数设置为SIG_IGN。
b)、父进程调用sigaction函数,设置标志位时置上SA_NOCLDWAIT位(如果不关心子进程的暂停和恢复执行,则置上SA_NOCLDSTOP位)
从内核代码来看,如果父进程的SIGCHLD的信号处理函数为SIG_IGN或sa_flags中被置上了SA_NOCLDWAIT位,子进程运行到此处时就知道了,父进程并不关心自己的退出信息,do_notify_parent函数就会返回true。在外层的exit_notify函数发现返回值是true,就会调用release_task函数,释放残余的资源,自行了断,子进程也就不会进入僵尸状态了。
为SIGCHLD写信号处理函数并不简单,原因是SIGCHLD是传统的不可靠信号。信号处理函数执行期间,会将引发调用的信号暂时阻塞(除非显式地指定了SA_NODEFER标志位),在这期间收到的SIGCHLD之类的传统信号,都不会排队。因此,如果在处理SIGCHLD信号时,有多个子进程退出,产生了多个SIGCHLD信号,但父进程只能收到一个。如果在信号处理函数中,只调用一次wait或waitpid,则会造成某些僵尸进程成为漏网之鱼。
正确的写法是,信号处理函数内,带着NOHANG标志位循环调用waitpid。如果返回值大于0,则表示不断等待子进程退出,返回0则表示当前没有僵尸子进程,返回**-1则表示出错,最大的可能就是errno等于ECHLD**,表示所有子进程都已退出。
while(waitpid(-1,&status,WNOHANG) > 0) { /*此处处理返回信息*/ continue; }
信号处理函数中的waitpid可能会失败,从而改变全局的errno的值,当主程序检查errno时,就有可能发生冲突,所以进入信号处理函数前要现保存errno到本地变量,信号处理函数退出前,再恢复errno。
2.父子进程互动之等待队列:
上一种方法可以称之为信号通知。另一种情况是父进程调用wait主动等待。如果父进程调用wait陷入阻塞,那么子进程退出时,又该如何及时唤醒父进程呢?
前面提到了,子进程会调用**__wake_up_parent函数,来及时唤醒父进程。事实上,前提条件是父进程确实在等待子进程的退出。如果父进程并没有调用wait系列函数等待子进程的退出,那么,等待队列为空,子进程的__wake_up_parent**对父进程并无任何影响。
void __wake_up_parent(struct task_struct *p, struct task_struct *parent) { //等待队列头 __wake_up_sync_key(&parent->signal->wait_chldexit, TASK_INTERRUPTIBLE, 1, p); }
父进程的进程描述符的signal结构体中有wait_childexit变量,这个变量是等待队列头。父进程调用wait系列函数时,会创建一个wait_opts结构体,并把该结构体挂入等待队列中。
static long do_wait(struct wait_opts *wo) { struct task_struct *tsk; int retval; trace_sched_process_wait(wo->wo_pid); /*挂入等待队列*/ init_waitqueue_func_entry(&wo->child_wait, child_wait_callback); wo->child_wait.private = current; add_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait); repeat: /**/ wo->notask_error = -ECHILD; if ((wo->wo_type < PIDTYPE_MAX) && (!wo->wo_pid || hlist_empty(&wo->wo_pid->tasks[wo->wo_type]))) goto notask; set_current_state(TASK_INTERRUPTIBLE);//父进程设置自己为此状态 read_lock(&tasklist_lock); tsk = current; do { retval = do_wait_thread(wo, tsk); if (retval) goto end; retval = ptrace_do_wait(wo, tsk); if (retval) goto end; if (wo->wo_flags & __WNOTHREAD) break; } while_each_thread(current, tsk); read_unlock(&tasklist_lock); /*找了一圈, 没有找到满足等待条件的的子进程, 下一步的行为将取决于WNOHANG标志位 *如果将WNOHANG标志位置位, 则表示不等了, 直接退出, *如果没有置位, 则让出CPU, 醒来后继续再找一圈*/ notask: retval = wo->notask_error; if (!retval && !(wo->wo_flags & WNOHANG)) { retval = -ERESTARTSYS; if (!signal_pending(current)) { schedule(); goto repeat; } } end: __set_current_state(TASK_RUNNING);//找到了满足条件的子进程设置为此状态 remove_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait); return retval; } tsk = current;
父进程先把自己设置成TASK_INTERRUPTIBLE状态,然后开始寻找满足等待条件的子进程。如果找到了,则将自己重置成TASK_RUNNING状态,欢乐返回;如果没找到,就要根据WNOHANG标志位来决定等不等待子进程。如果没有WNOHANG标志位,那么,父进程就会让出CPU资源,等待别人将它唤醒。
回到另一头,子进程退出的时候,会调用**__wake_up_parent**,唤醒父进程,父进程醒来以后,回到repeat,再次扫描。这样做,子进程的退出就能及时通知到父进程,从而使父进程的wait系列函数可以及时返回。