Linux内核内幕:深入解析进程的结束过程

2023-11-01 17:03:52 浏览数 (2)

大家好,我是程栩,一个专注于性能的大厂程序员,分享包括但不限于计算机体系结构、性能优化、云原生的知识。

天下没有不散的宴席,有进程的创建就会有进程的消亡。那么内核是如何处理进程自身的消亡的,又是如何处理它的子进程、父进程的呢?让我们来结合《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指针指向了当前的进程。

代码语言:javascript复制
 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的实现如下:

代码语言:javascript复制
// 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机制有关,可以暂时不用去深究。

❝关于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. ❞

代码语言:javascript复制
 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_exitkmsan_task_exitcoredump_task_exit来通知kcovkmsan进程的退出。并且通过ptrace_event关闭掉一些tracehooks。接着通过validate_creds_for_do_exit来检验进程的cred结构体是否有效,该结构体是与进程的安全相关,并通过io_uring_files_cancel取消已经提交的io_uring请求。

代码语言:javascript复制
 exit_signals(tsk);  /* sets PF_EXITING */

发送SIGCHLD信号给父进程,并设置内核的标志成员为PF_EXITING

代码语言:javascript复制
 /* sync mm's RSS info before statistics gathering */
 if (tsk->mm)
  sync_mm_rss(tsk->mm);
 acct_update_integrals(tsk);

调用acct_update_integrals来输出内核的记账信息。

代码语言:javascript复制
 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释放掉进程的计时器相关内容,因为此时已经没有进程了。

代码语言:javascript复制
 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来更新当前进程的任务统计信息,并告知用户进程空间。 接下来就到了释放资源的时候了。

代码语言:javascript复制
 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解除与终端的关联。 至此,进程的相关资源都已经被释放的差不多了,接下来就要做一些收尾的操作。

代码语言:javascript复制
 /*
  * 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有着那么多的成员变量,可谓是拖家带口。

小结

0 人点赞