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

492 阅读6分钟

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

天下没有不散的宴席,有进程的创建就会有进程的消亡。那么内核是如何处理进程自身的消亡的,又是如何处理它的子进程、父进程的呢?让我们来结合《Linux内核设计与实现》以及Linux v6.3版本进行学习与了解。

进程终结的原因

一般来说,进程的结束是尤其自身引起的。当进程调用exit的时候,就出触发进程的结束操作;而对于一些不会显式exit的程序,其可能隐式的进行退出。例如C语言编译器可能会在mian函数末尾加上exit函数来中介进程。

当然,进程也可能因为收到某些信号被强制结束,例如我们可以通过kill -9来关闭进程。

进程终结全过程

进程在调用exit后,最后会通过内核中的do_exit函数来进行终结。

接下来我们基于代码进行讲解:

// 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机制有关,可以暂时不用去深究。

关于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_exitkmsan_task_exitcoredump_task_exit来通知kcovkmsan进程的退出。并且通过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=0x%08x\n",
				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有着那么多的成员变量,可谓是拖家带口。

小结