内核进程(四) —— 撤销
内核进程实现
本文章基于 Linux 2.6.11 编写
每个进程都有生命周期,对于用户态进程,当程序从 main()
函数中返回或直接调用 exit()
,由或者进程接收到了不能处理、忽视的信号时,进程就会被终止。当这些进程“死”了后,内核必须能够回收他们的资源,比如内存、打开的文件等等。
内核提供了两种终止进程的方式,系统调用 exit_group()
终止整个线程组,由内核函数 do_exit_group()
实现,标准库函数 exit()
就是以此实现;系统调用 exit()
终止线程组中的单个进程,由内核函数 do_exit()
实现,POSIX
库函数 pthread_exit()
以此实现。下面我们分 线程组终止 和 进程终止 来讨论。
线程组的终止
线程组的终止描述的是内核对 由多个共享内存空间、文件表等资源的轻量级进程(struct task
实体)构成的线程组的回收工作。do_exit_group()
内核函数其实现的入口,下面给出注解。
NORET_TYPE void do_group_exit(int exit_code)
{
// 乐观加锁
if (current->signal->flags & SIGNAL_GROUP_EXIT)
// 已经开始执行进程组退出过程,将退出码作为本进程的退出码
exit_code = current->signal->group_exit_code;
else if (!thread_group_empty(current)) {
// 线程组不为空
// 线程组中信号处理是共享的
struct signal_struct *const sig = current->signal;
if (sig->flags & SIGNAL_GROUP_EXIT)
/*
* 加锁再次判断,确认竞争
*/
exit_code = sig->group_exit_code;
else {
// 保存退出码
sig->flags = SIGNAL_GROUP_EXIT;
sig->group_exit_code = exit_code;
// 杀死其他线程
zap_other_threads(current);
}
}
// 杀死本进程,而不返回
do_exit(exit_code);
}
通过判断线程组中的成员是否已经调用了过 do_group_exit()
,内核使用 SIGNAL_GROUP_EXIT
来进行标记,防止多次杀死同一个线程组中的其他进程,线程组一定会共享 信号处理,所以我们可以将已发起线程组退出退出标志和进程退出码保存在 struct signal
数据结构中。
在内核保存标记和退出码后,调用 zap_other_threads()
来将其线程组成员杀死。
void zap_other_threads(struct task_struct *p)
{
struct task_struct *t;
p->signal->flags = SIGNAL_GROUP_EXIT;
p->signal->group_stop_count = 0;
/*线程组为空,直接返回*/
if (thread_group_empty(p))
return;
/*遍历线程组中的其他成员*/
for (t = next_thread(p); t != p; t = next_thread(t)) {
/*
* 已经处于退出状态
*/
if (t->exit_state)
continue;
/*
* 非首领进程,在退出时不用向父进程发送信号(一般就是 SIGCHLD)
*/
if (t != p->group_leader)
t->exit_signal = -1;
/*通过KILL信号来通知被强制退出的线程*/
sigaddset(&t->pending.signal, SIGKILL);
/*移除未决的其他非 KILL 的停止信号*/
rm_from_queue(SIG_KERNEL_STOP_MASK, &t->pending);
/*唤醒,处理信号,就会退出*/
signal_wake_up(t, 1);
}
}
关于 Unix
信号处理的 内核实现需要大量的篇幅来讲解,这里你仅需要知道,内核通过向 线程组其他成员发送 SIGKILL
信号来杀死他们。 内核将一个 SIGKILL
信号标记在未决(待处理)的信号集合中,并清除其他会引起进程停止被调度的未决信号,然后调用 signal_wake_up()
函数使目标进程能立马响应 SIGKILL
信号。该函数通过发送 CPU
间中断,使正在用户态运行的目标进程强制陷入内核态,在《中断实现》中我们提及过,当进程从中断例程中退出,恢复被中断的用户态上下文时,会检查和处理未决的信号,所以内核选择使用 中断例程为空的 RESCHEDULE_VECTOR
中断来完成这样的需求。这样所有的线程组成员在用户态响应 SIGKILL
,并陷入内核执行 do_exit()
从而被杀死。
进程的终止
进程的终止描述的是内核对 struct task_struct
为单位的进程实体的回收工作,包括内存资源(页表)、IPC对象(System V
信号量)、文件表、文件系统等,要解说这些需要大量的篇幅,并可以另立主题,这里我们仅需要知道 do_exit()
会释放一次对这些资源的引用,如果没有其他路径或进程引用他们,那么就会被当即回收。我们主要讨论在进程在死亡时,对父进程和子进程产生的影响,这会使进程描述符所在的组织关系发生变化,以及进程最后一次调度和进程描述符的回收工作,在展开讲解时,我们将忽略进程跟踪的情况,以及粗略介绍退出时的信号处理,以便引入不必要的复杂性而不能把握整体的流程。
do_exit()
所有进程的退出都是由 do_exit()
实现的,下面给出主*分的源码和注解。
fastcall NORET_TYPE void do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
...
/*标记正在退出*/
tsk->flags |= PF_EXITING;
/*删除定时器*/
del_timer_sync(&tsk->real_timer);
...
/*解除页表和数据页*/
exit_mm(tsk);
/*关闭 System V 信号量*/
exit_sem(tsk);
/*关闭文件描述符*/
__exit_files(tsk);
/*关闭文件系统*/
__exit_fs(tsk);
...
tsk->exit_code = code;
// 通知亲戚进程,调整组织结构
exit_notify(tsk);
BUG_ON(!(current->flags & PF_DEAD));
schedule();
BUG();
...
}
当进程关闭各种资源,并通知 亲属进程后,就进行一次主动调度,这是该进程最后一次运行,对该进程描述符和内存描述符的一次引用会在下一个被调度进程的内核路径 finish_task_switch()
中完成,见《完全公平调度》中的介绍。
exit_notify()
退出的进程可能存在子进程,所以内核必须考虑他们的寄养问题,以保证进程组织结构的完整性,并按照 POSIX
的规定进程退出时,必须以 Unix
信号的方式通知其父进程,这样以便使父进程能够收集其子进程退出信息,比如重新启动一个新的子进程来继续完成相关工作,另外值得一提的是,这种 Posix
约定是可以控制的,如果 struct task_struct
的 exit_signal
字段不为 -1
就会发送 这个字段对应的信号给其父进程,该信号一般都是 SIGCHLD
。exit_notify()
就是完成这些工作。
static void exit_notify(struct task_struct *tsk)
{
int state;
struct task_struct *parent_task, *t;
struct list_head ptrace_dead, *_p, *_n;
/*退出进程的未决信号处理*/
if (signal_pending(tsk) && !(tsk->signal->flags & SIGNAL_GROUP_EXIT)
&& !thread_group_empty(tsk)) {
// 遍历线程组,找到一个没有执行退出,也没有要处理未决信号的线程来接纳
for (t = next_thread(tsk); t != tsk; t = next_thread(t)) {
if (!signal_pending(t) && !(t->flags & PF_EXITING)) {
/*找到一个没有未决信号,且也没有退出线程唤醒*/
recalc_sigpending_tsk(t);
if (signal_pending(t))
signal_wake_up(t, 0);
}
}
}
...
// 找到一个进程去领养该退出进程的所有子进程
forget_original_parent(tsk, &ptrace_dead);
/*处理孤儿进程组*/
parent_task = tsk->real_parent;
if ((process_group(parent_task) != process_group(tsk)) &&
(parent_task->signal->session == tsk->signal->session) &&
will_become_orphaned_pgrp(process_group(tsk), tsk) &&
has_stopped_jobs(process_group(tsk))) {
// 向进程组中所有成员发送信号
__kill_pg_info(SIGHUP, (void *)1, process_group(tsk));
__kill_pg_info(SIGCONT, (void *)1, process_group(tsk));
}
/*通知父进程*/
if (tsk->exit_signal != -1 && thread_group_empty(tsk)) {
// 退出时需要发出信号,且没有其他线程成员了。
int signal = (tsk->parent == tsk->real_parent ?
tsk->exit_signal : SIGCHLD);
/*使用信号通知父进程*/
do_notify_parent(tsk, signal);
}
/*判断进程是直接退出还是变成一个僵死进程等待父进程回收他最后的状态*/
if (tsk->exit_signal == -1 &&
(likely(tsk->ptrace == 0) ||
unlikely(tsk->parent->signal->flags & SIGNAL_GROUP_EXIT))) {
/*直接退出*/
state = EXIT_DEAD;
} else {
/*僵死状态*/
state = EXIT_ZOMBIE;
}
tsk->exit_state = state;
...
/*
* 不需要 wait(),此处解除一次引用,切出后再解除一次引用就释放了
* 原初始值为2
* @see finish_task_switch()
*/
if (state == EXIT_DEAD)
release_task(tsk);
/* 标记 PF_DEAD 使 调度后可以是否一次引用. */
preempt_disable();
tsk->flags |= PF_DEAD;
}
该函数相对复杂,主要完成以下的工作:
-
处理属于该进程的未决
Unix
信号,简单提一下,Unix
信号可以分为两种发送方式,发送给线程组,或发送给线程组中的某个成员,发送给线程组的信号可以由线程组的任一个成员来处理,发送某个线程成员的信号一般都由该成员处理,换句话说信号只打断处理该信号的线程的用户态执行流,转而执行信号处理程序。进程退出时如果发现还有其他线程组成员存活,则将未决信号交付于其中一个成员继承,防止信号丢失。 -
寄养所有子进程,相当于托孤,自己要死了,希望有一个合适的人来照顾自己的孩子,在内核实现上就是给所有子进程指定一个新的父进程。通过
forget_original_parent()
,下面我们会细讲。 -
处理孤儿进程组,因为根据
POSIX
的规定,一个进程退出使所在的进程组变为孤儿进组,如果孤儿进程组包含停止的进程(处于TASK_STOPPED
状态),那么必须向进程组中所有成员进程先后发送SIGHUP
和SIGCOND
信号。这里简单列出判断孤儿进程组函数实现,注意怎么遍历进程组,我们已经在 《进程组织结构》一文中介绍过do_each_task_pid() ...
宏的实现。
static int will_become_orphaned_pgrp(int pgrp, task_t *ignored_task)
{
struct task_struct *p;
int ret = 1;
// 遍历进程组
do_each_task_pid(pgrp, PIDTYPE_PGID, p) {
if (p == ignored_task
|| p->exit_state
|| p->real_parent->pid == 1)
continue;
if (process_group(p->real_parent) != pgrp
&& p->real_parent->signal->session == p->signal->session) {
/* 进程组中存在一个进程,他的父进程不属于这个进程组,但是属于同一个会话,
* 这个进程组就不会成为孤儿进程组 ?
*/
ret = 0;
break;
}
// 如果进程组里的所有进程的父进程都属于这个进程组 或 父进程和子进程处于不同会话
} while_each_task_pid(pgrp, PIDTYPE_PGID, p);
return ret;
}