> [!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]: 한 프로세스가 다른 프로세스의 실행을 추적하고 제어할 수 있게 해주는 시스템 콜 기능이다.