大家好,我是程栩,一个专注于性能的大厂程序员,分享包括但不限于计算机体系结构、性能优化、云原生的知识。
引
天下没有不散的宴席,有进程的创建就会有进程的消亡。那么内核是如何处理进程自身的消亡的,又是如何处理它的子进程、父进程的呢?让我们来结合《Linux内核设计与实现》以及Linux v6.3版本进行学习与了解。
进程终结的原因
一般来说,进程的结束是尤其自身引起的。当进程调用exit
的时候,就出触发进程的结束操作;而对于一些不会显式exit
的程序,其可能隐式的进行退出。例如C语言编译器可能会在mian
函数末尾加上exit
函数来中介进程。
当然,进程也可能因为收到某些信号被强制结束,例如我们可以通过kill -9
来关闭进程。
进程终结全过程
进程在调用exit
后,最后会通过内核中的do_exit
函数来进行终结。
接下来我们基于代码进行讲解:
代码语言:javascript复制// kernel/exit.c L924
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
这里就是函数的入口,请注意这里使用tsk
指针指向了当前的进程。
WARN_ON(irqs_disabled());
synchronize_group_exit(tsk, code);
WARN_ON(tsk->plug);
WARN_ON
是负责向内核输出警告信息的函数,在这里先关闭了中断,之后调用synchronize_group_exit
来确保SIGNAL_GROUP_EXIT
信号一定在进程调用exit
的时候被设置了。该函数用于同步当前进程所在进程组的退出状态。因为一个进程组中的所有进程都共享同一个信号处理器(sighand_struct)和信号结构体(signal_struct),因此在进程退出时需要同步整个进程组的退出状态。具体来说,该函数会对信号处理器进行加锁,然后将当前进程所在进程组的 quick_threads 计数器减 1。如果 quick_threads 计数器变为 0,且当前进程组的 SIGNAL_GROUP_EXIT 标志位未被设置,则将该标志位设置,并将进程组的退出码、停止计数器等信息保存到信号结构体中。最后解锁信号处理器。synchronize_group_exit
的实现如下:
// kernel/exit.c L789
static void synchronize_group_exit(struct task_struct *tsk, long code)
{
struct sighand_struct *sighand = tsk->sighand;
struct signal_struct *signal = tsk->signal;
spin_lock_irq(&sighand->siglock);
signal->quick_threads--;
if ((signal->quick_threads == 0) &&
!(signal->flags & SIGNAL_GROUP_EXIT)) {
signal->flags = SIGNAL_GROUP_EXIT;
signal->group_exit_code = code;
signal->group_stop_count = 0;
}
spin_unlock_irq(&sighand->siglock);
}
接着检查plug
变量是否为空,这里的plug
和内核的plug/unplug
机制有关,可以暂时不用去深究。
代码语言:javascript复制❝关于
WARN_ON(tsk->plug)
,patch的解释是:blk_needs_flush_plug fails to account for the cb_list, which needs flushing as well. Remove it and just check if there is a plug instead of poking into the internals of the plug structure. ❞
kcov_task_exit(tsk);
kmsan_task_exit(tsk);
coredump_task_exit(tsk);
ptrace_event(PTRACE_EVENT_EXIT, code);
validate_creds_for_do_exit(tsk);
io_uring_files_cancel();
接着内核分别调用了kcov_task_exit
、kmsan_task_exit
和coredump_task_exit
来通知kcov
和kmsan
进程的退出。并且通过ptrace_event
关闭掉一些tracehooks
。接着通过validate_creds_for_do_exit
来检验进程的cred
结构体是否有效,该结构体是与进程的安全相关,并通过io_uring_files_cancel
取消已经提交的io_uring
请求。
exit_signals(tsk); /* sets PF_EXITING */
发送SIGCHLD
信号给父进程,并设置内核的标志成员为PF_EXITING
。
/* sync mm's RSS info before statistics gathering */
if (tsk->mm)
sync_mm_rss(tsk->mm);
acct_update_integrals(tsk);
调用acct_update_integrals
来输出内核的记账信息。
group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
/*
* If the last thread of global init has exited, panic
* immediately to get a useable coredump.
*/
if (unlikely(is_global_init(tsk)))
panic("Attempted to kill init! exitcode=0xxn",
tsk->signal->group_exit_code ?: (int)code);
#ifdef CONFIG_POSIX_TIMERS
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk);
#endif
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
这里首先通过atomic_dec_and_test
来让tsk->signal->live
信号原子减一,如果减完以后值为0则返回真。而当真的时候,会调用exit_itimers
释放掉进程的计时器相关内容,因为此时已经没有进程了。
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
audit_free(tsk);
tsk->exit_code = code;
taskstats_exit(tsk, group_dead);
这里首先调用acct_collect
来收集进程的系统资源使用情况,接着如果进程组已经退出的话,就调用tty_audit_exit
来更新当前进程的审计状态,接着调用audit_free
来释放审计相关资源,并设置任务的状态码为传入的code
,并通过taskstats_exit
来更新当前进程的任务统计信息,并告知用户进程空间。 接下来就到了释放资源的时候了。
exit_mm();
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
exit_sem(tsk);
exit_shm(tsk);
exit_files(tsk);
exit_fs(tsk);
// 解除和终端的关联
if (group_dead)
disassociate_ctty(1);
exit_task_namespaces(tsk);
exit_task_work(tsk);
exit_thread(tsk);
/*
* Flush inherited counters to the parent - before the parent
* gets woken up by child-exit notifications.
*
* because of cgroup mode, must be called before cgroup_exit()
*/
perf_event_exit_task(tsk);
sched_autogroup_exit_task(tsk);
cgroup_exit(tsk);
这里分别通过调用exit_xx
函数释放了诸如内存、文件、文件系统、线程、工作队列、perf_event事件、自动调度组、cgroup等资源。并且如果进程退出(group_dead
),则调用disassociate_ctty
解除与终端的关联。 至此,进程的相关资源都已经被释放的差不多了,接下来就要做一些收尾的操作。
/*
* FIXME: do that only when needed, using sched_exit tracepoint
*/
// 刷新当前进程的硬件断点信息
flush_ptrace_hw_breakpoint(tsk);
// 开启一个RCU临界区
exit_tasks_rcu_start();
// 通知父进程已经退出,给子进程寻找新的养父,并把进程的状态设置为僵尸状态
exit_notify(tsk, group_dead);
// 向/proc文件系统发出进程退出的事件通知
proc_exit_connector(tsk);
// 释放当前进程的内存策略资源
mpol_put_task_policy(tsk);
#ifdef CONFIG_FUTEX
if (unlikely(current->pi_state_cache))
kfree(current->pi_state_cache);
#endif
/*
* Make sure we are holding no locks:
*/
// 确保进程并没有锁,否则会出问题
debug_check_no_locks_held();
// 释放io上下文
if (tsk->io_context)
exit_io_context(tsk);
// 释放管道资源
if (tsk->splice_pipe)
free_pipe_info(tsk->splice_pipe);
// 释放进程的任务页资源
if (tsk->task_frag.page)
put_page(tsk->task_frag.page);
// 验证当前进程的安全凭证cred是否合法
validate_creds_for_do_exit(tsk);
// 更新当前进程的栈资源统计信息
exit_task_stack_account(tsk);
// 检查栈调用是否合法
check_stack_usage();
// 禁止抢占
preempt_disable();
// 如果进程有脏页的话,就把脏页加到CPU变量中,以后处理
if (tsk->nr_dirtied)
__this_cpu_add(dirty_throttle_leaks, tsk->nr_dirtied);
// 退出RCU临界区
exit_rcu();
// 释放RCU临界区
exit_tasks_rcu_finish();
// 释放当前进程的锁依赖资源
lockdep_free_task(tsk);
// 通知内核当前进程已结束
do_task_dead();
}
至此,一个进程就已经终结了,但是注意这个进程只是作为一个僵尸进程存在,并没有真正的消亡。它的实体task_struct
也即进程描述符仍然存在,需要等待其父进程调用wait
来收集它,这个进程才算是真正的消亡了。
小结
总结一下,除去安全等防御性编程外,进程的exit
大概做了以下的三件事:
- 释放资源
- 通知其他组件该进程已经结束,将子进程等资源托付给其他进程
这其中自然释放资源是占很大的比重的,可以看到我们释放了数十种进程的资源,这个函数才结束。这也不枉费进程的task_struct
有着那么多的成员变量,可谓是拖家带口。
小结