> [!abstract] Introduction
> 프로세스가 종료될 때 일어나는 일들을 살펴본다. [[Process|프로세스]]가 실행을 마치거나 강제로 종료되면, 커널은 할당된 자원을 회수하고 부모 프로세스에 종료 상태를 알린다. 이 과정에서 좀비 프로세스와 고아 프로세스 같은 특수한 상황이 발생할 수 있으며, 리눅스는 이들을 효율적으로 처리하기 위한 메커니즘을 갖추고 있다.
## 프로세스 종료 과정
프로세스가 모든 일을 마치고 종료될 때, 부모 프로세스는 상태 값을 받을 수 있고 자식 프로세스에게 할당됐던 자원들이 운영체제에 의해 해제된다. [[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()` 함수를 호출한다. 프로세스가 IPC[^ipc-define] 세마포어[^semaphore-define]를 얻기 위해 대기하고 있었다면, 이 시점에서 대기 상태가 해제된다.
6. `exit_files()` 및 `exit_fs()` 함수를 통해 관련 파일 서술자 및 파일 시스템의 참조 횟수를 줄인다. 만약 참조 횟수가 0이 된다면 해당 객체를 사용하는 프로세스가 없다는 뜻이므로 해당 자원을 반환한다.
7. 태스크의 종료 코드를 [[Process Control Block|task_struct]]의 `exit_code` 멤버에 저장한다. 종료 코드는 `exit()` 함수에서 지정한 값, 또는 커널의 종료 방식에 의해 결정되며, 그 값은 부모 프로세스가 사용할 수 있다.
8. `exit_notify()` 함수를 호출해 부모 프로세스에 시그널을 보내고 해당 프로세스가 속한 스레드군의 다른 스레드 또는 `init` 프로세스를 자식 프로세스의 새로운 부모로 설정한다. [[Process Control Block|task_struct]] 구조체의 `exit_state` 멤버에 태스크 종료 상태를 `EXIT_ZOMBIE`로 설정한다.
9. `schedule()` 함수를 호출해 새로운 프로세스로 전환한다. 이제 이 프로세스는 스케줄링의 대상이 아니므로 이 코드가 종료되는 태스크가 실행하는 마지막 코드가 된다.
```C
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` 구조체, 그리고 [[Process Control Block|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` 구조체가 들어있던 페이지를 반환하고, [[Process Control Block|task_struct]] 구조체가 들어있던 슬랩 캐시를 반환한다.
```C
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`에 아래와 같이 정의되어 있다.
```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()` 함수에서 이루어진다.
```C
/*
* 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()` 함수를 호출한다. 이 함수를 통해 추적[^ptrace-define] 기능을 사용하는 자식 프로세스에 대해서도 마찬가지로 부모 프로세스를 다시 지정해준다. 이 함수는 `kernel/ptrace.c`에 정의되어 있으며, 그 내용은 아래와 같다.
```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`에 정의되어 있으며, 그 내용은 아래와 같다.
```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`
[^ipc-define]: Inter-Process Communication의 약자로, 프로세스 간 통신을 위한 메커니즘을 의미한다.
[^semaphore-define]: 공유 자원에 대한 접근을 제어하기 위한 동기화 프리미티브로, 음이 아닌 정수와 대기/신호 연산으로 구성된다.
[^ptrace-define]: 한 프로세스가 다른 프로세스의 실행을 추적하고 제어할 수 있게 해주는 시스템 콜 기능이다.