Introduction

프로세스가 종료될 때 일어나는 일들을 살펴본다. 프로세스가 실행을 마치거나 강제로 종료되면, 커널은 할당된 자원을 회수하고 부모 프로세스에 종료 상태를 알린다. 이 과정에서 좀비 프로세스와 고아 프로세스 같은 특수한 상황이 발생할 수 있으며, 리눅스는 이들을 효율적으로 처리하기 위한 메커니즘을 갖추고 있다.

프로세스 종료 과정

프로세스가 모든 일을 마치고 종료될 때, 부모 프로세스는 상태 값을 받을 수 있고 자식 프로세스에게 할당됐던 자원들이 운영체제에 의해 해제된다. fork()로 생성된 자식 프로세스는 시스템 호출이나 다른 코드에 의해 강제로 종료될 수 있는데, 이때 운영체제에 따라 어떤 프로세스가 종료되면 해당 프로세스의 자식 프로세스가 모두 멈추거나 Cascading Termination, 모든 프로세스의 부모인 최초의 프로세스를 부모로 다시 정하기도 한다.

프로세스가 종료되면, 커널은 프로세스가 가지고 있던 자원을 반납하고 부모 프로세스에 자식 프로세스가 종료된다는 소식을 알려준다. 일반적으로 이 과정은 자발적으로 일어나지만, 처리할 수도 없고 무시할 수도 없는 시그널이나 예외를 만나는 상황에서는 프로세스가 비자발적으로 종료되기도 한다.

리눅스에서 프로세스 종료는 kernel/exit.c에 정의된 do_exit() 함수를 통해 진행된다. 이 함수에서 일어나는 일을 요약하면 다음과 같다.

  1. task_struct 구조체의 flags 항목에 PF_EXITING 플래그를 설정한다.
  2. del_timer_sync() 함수를 호출해 커널 타이머를 제거한다. 이 함수가 반환되면, 대기 중인 타이머와 실행 중인 타이머가 없다는 것이 보장된다.
  3. BSD 방식의 프로세스 정보 기록 기능을 사용한다면 acct_update_integrals() 함수를 호출해 관련 정보를 기록한다.
  4. exit_mm() 함수를 호출해 해당 프로세스가 가지고 있는 mm_struct를 반환한다. 다른 프로세스에서 이 주소 공간을 사용하지 않는다면 커널은 해당 자원을 해제한다.
  5. exit_sem() 함수를 호출한다. 프로세스가 IPC1 세마포어2를 얻기 위해 대기하고 있었다면, 이 시점에서 대기 상태가 해제된다.
  6. exit_files()exit_fs() 함수를 통해 관련 파일 서술자 및 파일 시스템의 참조 횟수를 줄인다. 만약 참조 횟수가 0이 된다면 해당 객체를 사용하는 프로세스가 없다는 뜻이므로 해당 자원을 반환한다.
  7. 태스크의 종료 코드를 task_structexit_code 멤버에 저장한다. 종료 코드는 exit() 함수에서 지정한 값, 또는 커널의 종료 방식에 의해 결정되며, 그 값은 부모 프로세스가 사용할 수 있다.
  8. exit_notify() 함수를 호출해 부모 프로세스에 시그널을 보내고 해당 프로세스가 속한 스레드군의 다른 스레드 또는 init 프로세스를 자식 프로세스의 새로운 부모로 설정한다. task_struct 구조체의 exit_state 멤버에 태스크 종료 상태를 EXIT_ZOMBIE로 설정한다.
  9. schedule() 함수를 호출해 새로운 프로세스로 전환한다. 이제 이 프로세스는 스케줄링의 대상이 아니므로 이 코드가 종료되는 태스크가 실행하는 마지막 코드가 된다.
void __noreturn do_exit(long code)
{
	struct task_struct *tsk = current;
	int group_dead;

	WARN_ON(irqs_disabled());
	WARN_ON(tsk->plug);

	kcov_task_exit(tsk);
	kmsan_task_exit(tsk);

	synchronize_group_exit(tsk, code);
	ptrace_event(PTRACE_EVENT_EXIT, code);
	user_events_exit(tsk);

	io_uring_files_cancel();
	exit_signals(tsk);  /* sets PF_EXITING */

	seccomp_filter_release(tsk);

	acct_update_integrals(tsk);
	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);
	}
	acct_collect(code, group_dead);
	if (group_dead)
		tty_audit_exit();
	audit_free(tsk);

	tsk->exit_code = code;
	taskstats_exit(tsk, group_dead);
	unwind_deferred_task_exit(tsk);
	trace_sched_process_exit(tsk, group_dead);

	/*
	 * Since sampling can touch ->mm, make sure to stop everything before we
	 * tear it down.
	 *
	 * Also flushes inherited counters to the parent - before the parent
	 * gets woken up by child-exit notifications.
	 */
	perf_event_exit_task(tsk);

	exit_mm();

	if (group_dead)
		acct_process();

	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);

	sched_autogroup_exit_task(tsk);
	cgroup_exit(tsk);

	/*
	 * FIXME: do that only when needed, using sched_exit tracepoint
	 */
	flush_ptrace_hw_breakpoint(tsk);

	exit_tasks_rcu_start();
	exit_notify(tsk, group_dead);
	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();

	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);

	exit_task_stack_account(tsk);

	check_stack_usage();
	preempt_disable();
	if (tsk->nr_dirtied)
		__this_cpu_add(dirty_throttle_leaks, tsk->nr_dirtied);
	exit_rcu();
	exit_tasks_rcu_finish();

	lockdep_free_task(tsk);
	do_task_dead();
}

do_exit() 함수가 완료된 이 시점에서 태스크는 더 이상 실행 가능하지 않으며 가지고 있는 메모리라고는 커널 스택, thread_info 구조체, 그리고 task_struct 구조체가 전부다. 이제 부모 프로세스가 그 안에 담긴 정보를 처리하거나 커널이 더이상 그 정보가 필요 없다고 알려주면 프로세스가 차지하고 있던 나머지 메모리도 반환돼 시스템에서 다른 프로세스에 제공할 수 있는 잉여 메모리로 되돌아간다.

좀비 프로세스

실제 프로세스는 종료했지만 부모 프로세스가 그 상태를 확인하지 못했을 때(예를 들어 POSIX API를 사용하는 환경에서 wait() 함수를 호출하기 전에 자식 프로세스가 먼저 종료됨) 이 프로세스를 좀비 프로세스 zombie process라고 부른다.

실제로는 종료된 프로세스지만, 이들을 관찰하는 부모 프로세스의 입장에서는 아직 종료된 프로세스가 아니다. do_exit() 함수가 완료되고 나면 프로세스는 좀비 프로세스가 되는데, 이러한 좀비 프로세스는 뭔가 조치를 취하지 않으면 영원히 시스템 자원을 잡아먹으며 성능을 저하시킨다. 이때는 프로세스 서술자를 따로 제거해주면 되는데, release_task() 함수가 이 작업을 진행해준다. 여기서 일어나는 일은 대략 다음과 같다.

  1. __exit_signal() 함수를 호출하고, 이 함수는 __unhash_process() 함수를 호출하며, 이어서 detach_pid() 함수에서 해당 프로세스를 pidhash와 태스크 리스트에서 제거한다.
  2. __exit_signal() 함수는 종료된 프로세스가 사용하던 남은 자원을 반환하고, 통계값과 기타 정보를 기록한다.
  3. 해당 태스크가 스레드군의 마지막 스레드였다면 대표 스레드가 좀비가 된 것이므로, release_task() 함수는 대표 스레드의 부모 프로세스에 이 사실을 알린다.
  4. release_task() 함수는 put_task_struct() 함수를 호출해 프로세스의 커널 스택 및 thread_info 구조체가 들어있던 페이지를 반환하고, task_struct 구조체가 들어있던 슬랩 캐시를 반환한다.
void release_task(struct task_struct *p)
{
	struct release_task_post post;
	struct task_struct *leader;
	struct pid *thread_pid;
	int zap_leader;
repeat:
	memset(&post, 0, sizeof(post));

	/* don't need to get the RCU readlock here - the process is dead and
	 * can't be modifying its own credentials. But shut RCU-lockdep up */
	rcu_read_lock();
	dec_rlimit_ucounts(task_ucounts(p), UCOUNT_RLIMIT_NPROC, 1);
	rcu_read_unlock();

	pidfs_exit(p);
	cgroup_release(p);

	/* Retrieve @thread_pid before __unhash_process() may set it to NULL. */
	thread_pid = task_pid(p);

	write_lock_irq(&tasklist_lock);
	ptrace_release_task(p);
	__exit_signal(&post, p);

	/*
	 * If we are the last non-leader member of the thread
	 * group, and the leader is zombie, then notify the
	 * group leader's parent process. (if it wants notification.)
	 */
	zap_leader = 0;
	leader = p->group_leader;
	if (leader != p && thread_group_empty(leader)
			&& leader->exit_state == EXIT_ZOMBIE) {
		/* for pidfs_exit() and do_notify_parent() */
		if (leader->signal->flags & SIGNAL_GROUP_EXIT)
			leader->exit_code = leader->signal->group_exit_code;
		/*
		 * If we were the last child thread and the leader has
		 * exited already, and the leader's parent ignores SIGCHLD,
		 * then we are the one who should release the leader.
		 */
		zap_leader = do_notify_parent(leader, leader->exit_signal);
		if (zap_leader)
			leader->exit_state = EXIT_DEAD;
	}

	write_unlock_irq(&tasklist_lock);
	/* @thread_pid can't go away until free_pids() below */
	proc_flush_pid(thread_pid);
	add_device_randomness(&p->se.sum_exec_runtime,
			      sizeof(p->se.sum_exec_runtime));
	free_pids(post.pids);
	release_thread(p);
	/*
	 * This task was already removed from the process/thread/pid lists
	 * and lock_task_sighand(p) can't succeed. Nobody else can touch
	 * ->pending or, if group dead, signal->shared_pending. We can call
	 * flush_sigqueue() lockless.
	 */
	flush_sigqueue(&p->pending);
	if (thread_group_leader(p))
		flush_sigqueue(&p->signal->shared_pending);

	put_task_struct_rcu_user(p);

	p = leader;
	if (unlikely(zap_leader))
		goto repeat;
}

release_task() 함수가 종료되면 프로세스 서술자와 해당 프로세스와 연관된 모든 자원이 해제된다.

고아 프로세스

반대로 부모 프로세스가 종료를 위한 시스템 호출을 사용하지 못하고 먼저 종료된 경우, 자식 프로세스는 고아 프로세스 orphan process가 되어 홀로 남겨진다. 이때 부모를 새로 지정하지 않으면 그 프로세스는 시스템 메모리를 계속 낭비하게 된다.

이 문제를 해결하기 위해 리눅스는 다음과 같은 전략을 사용한다. 종료 후 상태에 대한 정보를 넘길 부모 프로세스가 없는 상황을 해결하기 위해 해당 프로세스가 속한 스레드군의 다른 프로세스를 부모 프로세스로 지정하거나, 그것이 불가능할 때에는 init 프로세스를 이들의 새로운 부모 프로세스로 지정한다.

do_exit() 함수는 exit_notify() 함수를 호출하고, 이 함수에서 forget_original_parent() 함수를 호출하고, 그 함수에서 find_new_reaper() 함수를 호출한다. 바로 그곳에서 부모 프로세스를 재지정한다. find_new_reaper() 함수는 kernel/exit.c에 아래와 같이 정의되어 있다.

/*
 * When we die, we re-parent all our children, and try to:
 * 1. give them to another thread in our thread group, if such a member exists
 * 2. give it to the first ancestor process which prctl'd itself as a
 *    child_subreaper for its children (like a service manager)
 * 3. give it to the init process (PID 1) in our pid namespace
 */
static struct task_struct *find_new_reaper(struct task_struct *father,
				   struct task_struct *child_reaper)
{
	struct task_struct *thread, *reaper;

	thread = find_alive_thread(father);
	if (thread)
		return thread;

	if (father->signal->has_child_subreaper) {
		unsigned int ns_level = task_pid(father)->level;
		/*
		 * Find the first ->is_child_subreaper ancestor in our pid_ns.
		 * We can't check reaper != child_reaper to ensure we do not
		 * cross the namespaces, the exiting parent could be injected
		 * by setns() + fork().
		 * We check pid->level, this is slightly more efficient than
		 * task_active_pid_ns(reaper) != task_active_pid_ns(father).
		 */
		for (reaper = father->real_parent;
		     task_pid(reaper)->level == ns_level;
		     reaper = reaper->real_parent) {
			if (reaper == &init_task)
				break;
			if (!reaper->signal->is_child_subreaper)
				continue;
			thread = find_alive_thread(reaper);
			if (thread)
				return thread;
		}
	}

	return child_reaper;
}

find_new_reaper() 함수가 정상적으로 끝나면 적당한 프로세스가 새 부모 프로세스로 지정된다. 다음으로 해야 할 일은 같은 부모를 가졌던 모든 프로세스의 부모 프로세스를 재지정하는 일이다. 이 작업은 같은 파일에 정의된 forget_original_parent() 함수에서 이루어진다.

/*
 * Make init inherit all the child processes
 */
static void forget_original_parent(struct task_struct *father,
				struct list_head *dead)
{
	struct task_struct *p, *t, *reaper;

	if (unlikely(!list_empty(&father->ptraced)))
		exit_ptrace(father, dead);

	/* Can drop and reacquire tasklist_lock */
	reaper = find_child_reaper(father, dead);
	if (list_empty(&father->children))
		return;

	reaper = find_new_reaper(father, reaper);
	list_for_each_entry(p, &father->children, sibling) {
		for_each_thread(p, t) {
			RCU_INIT_POINTER(t->real_parent, reaper);
			BUG_ON((!t->ptrace) != (rcu_access_pointer(t->parent) == father));
			if (likely(!t->ptrace))
				t->parent = t->real_parent;
			if (t->pdeath_signal)
				group_send_sig_info(t->pdeath_signal,
						    SEND_SIG_NOINFO, t,
						    PIDTYPE_TGID);
		}
		/*
		 * If this is a threaded reparent there is no need to
		 * notify anyone anything has happened.
		 */
		if (!same_thread_group(reaper, father))
			reparent_leader(father, p, dead);
	}
	list_splice_tail_init(&father->children, &reaper->children);
}

forget_original_parent() 함수 내부에서는 exit_ptrace() 함수를 호출한다. 이 함수를 통해 추적3 기능을 사용하는 자식 프로세스에 대해서도 마찬가지로 부모 프로세스를 다시 지정해준다. 이 함수는 kernel/ptrace.c에 정의되어 있으며, 그 내용은 아래와 같다.

/*
 * Detach all tasks we were using ptrace on. Called with tasklist held
 * for writing.
 */
void exit_ptrace(struct task_struct *tracer, struct list_head *dead)
{
	struct task_struct *p, *n;

	list_for_each_entry_safe(p, n, &tracer->ptraced, ptrace_entry) {
		if (unlikely(p->ptrace & PT_EXITKILL))
			send_sig_info(SIGKILL, SEND_SIG_PRIV, p);

		if (__ptrace_detach(tracer, p))
			list_add(&p->ptrace_entry, dead);
	}
}

forget_original_parent() 함수가 끝나면, exit_notify() 함수에서는 프로세스 그룹 전체가 종료되는 상황일 경우 kill_orphaned_pgrp()를 통해 고아가 된 프로세스 그룹에 시그널을 보낸다. 그 이후 만약 자동으로 프로세스를 정리해야 한다면(autoreap == true), 프로세스 상태를 EXIT_DEAD로 바꾸고, dead 리스트에 추가한다. 만약 멀티스레드 환경에서 실행된 exec() 등으로 그룹 리더를 기다리는 상황이면, 대기 중인 태스크를 깨운다. exit_notify() 함수는 kernel/exit.c에 정의되어 있으며, 그 내용은 아래와 같다.

/*
 * Send signals to all our closest relatives so that they know
 * to properly mourn us..
 */
static void exit_notify(struct task_struct *tsk, int group_dead)
{
	bool autoreap;
	struct task_struct *p, *n;
	LIST_HEAD(dead);

	write_lock_irq(&tasklist_lock);
	forget_original_parent(tsk, &dead);

	if (group_dead)
		kill_orphaned_pgrp(tsk->group_leader, NULL);

	tsk->exit_state = EXIT_ZOMBIE;

	if (unlikely(tsk->ptrace)) {
		int sig = thread_group_leader(tsk) &&
				thread_group_empty(tsk) &&
				!ptrace_reparented(tsk) ?
			tsk->exit_signal : SIGCHLD;
		autoreap = do_notify_parent(tsk, sig);
	} else if (thread_group_leader(tsk)) {
		autoreap = thread_group_empty(tsk) &&
			do_notify_parent(tsk, tsk->exit_signal);
	} else {
		autoreap = true;
		/* untraced sub-thread */
		do_notify_pidfd(tsk);
	}

	if (autoreap) {
		tsk->exit_state = EXIT_DEAD;
		list_add(&tsk->ptrace_entry, &dead);
	}

	/* mt-exec, de_thread() is waiting for group leader */
	if (unlikely(tsk->signal->notify_count < 0))
		wake_up_process(tsk->signal->group_exec_task);
	write_unlock_irq(&tasklist_lock);

	list_for_each_entry_safe(p, n, &dead, ptrace_entry) {
		list_del_init(&p->ptrace_entry);
		release_task(p);
	}
}

마지막으로 락을 해제하고, release_task() 함수를 통해 dead 리스트에 있는 모든 태스크를 순회하며 정리한다. 이 과정이 끝나면 고아 프로세스를 종료하기 위한 모든 절차가 완료된다.


출처

  • Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). Operating System Concepts (10th ed.). John Wiley & Sons.
  • Amini, K. (2019). Extreme C: Taking you to the limit in Concurrency, OOP, and the most advanced capabilities of C (1st ed.). Packt Publishing.
  • Arpaci-Dusseau, R., & Arpaci-Dusseau, A. (2023). Operating Systems: Three Easy Pieces (1.10). Arpaci-Dusseau Books.
  • Bryant, R. E., & O'Hallaron, D. R. (2016). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.
  • Kerrisk, M. (2018). The Linux Programming Interface (9th printing). No Starch Press.
  • Markstedter, M. (2023). Blue Fox: Arm Assembly Internals and Reverse Engineering (1st ed.). Wiley.
  • Love, R. (2010). Linux Kernel Development (3rd ed.). Addison-Wesley Professional.
  • Linux kernel source code: kernel/exit.c, kernel/ptrace.c

Footnotes

  1. Inter-Process Communication의 약자로, 프로세스 간 통신을 위한 메커니즘을 의미한다.

  2. 공유 자원에 대한 접근을 제어하기 위한 동기화 프리미티브로, 음이 아닌 정수와 대기/신호 연산으로 구성된다.

  3. 한 프로세스가 다른 프로세스의 실행을 추적하고 제어할 수 있게 해주는 시스템 콜 기능이다.