QUESTION
child가 종료됐는데도 parent가 wait해야 하는 이유는 무엇인가?
child가 exit하면 실행 자원 대부분은 정리되지만 parent가 읽어야 할 exit status와 resource usage는 task의 최소 정보로 남는다. parent가 wait 계열 호출로 이를 소비해야 PID와 task slot이 완전히 회수된다.
숫자 PID는 시간이 지나면 재사용된다. pidfd는 특정 process instance를 가리키는 file descriptor라 poll 가능한 종료 알림과 signal 전송에서 PID lookup 경쟁을 줄인다.
STRUCTURE
구조 그림
Running child
- task_struct
- mm/files
- PID 5301
Zombie record
- EXIT_ZOMBIE
- exit_code=42
- rusage 보존
pidfd
- struct pid ref
- poll readable
- waitid(P_PIDFD)
After wait
- status 소비
- release_task
- PID 재사용 가능
child 실행이 끝나도 exit status는 parent가 wait로 소비할 때까지 남는다. pidfd는 숫자 PID가 재사용돼도 같은 process instance를 가리킨다.
CALL PATH
호출 흐름
종료와 회수는 같은 사건이 아니다. process가 더 이상 실행되지 않는 시점과 parent가 종료 정보를 소비하는 시점을 따로 기록한다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| kernel/exit.c | do_exit(), exit_notify() | 종료 자원 정리와 zombie 상태 설정 |
| kernel/exit.c | do_wait(), wait_task_zombie() | parent가 종료 정보를 소비하는 경로 |
| kernel/pid.c | pidfd_open(), pidfd_send_signal() | PID 대신 stable handle을 얻고 사용하는 경로 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 pidfd_wait.c -o pidfd_wait01#define _GNU_SOURCE
02#include <poll.h>
03#include <stdio.h>
04#include <stdlib.h>
05#include <sys/syscall.h>
06#include <sys/wait.h>
07#include <unistd.h>
08
09int main(void)
10{
11 pid_t pid = fork();
12 if (pid < 0)
13 return 1;
14 if (pid == 0)
15 _exit(42);
16
17 int pfd = (int)syscall(SYS_pidfd_open, pid, 0);
18 if (pfd < 0) {
19 perror("pidfd_open");
20 return 1;
21 }
22
23 struct pollfd event = { .fd = pfd, .events = POLLIN };
24 if (poll(&event, 1, -1) < 0)
25 return 1;
26
27 siginfo_t info;
28 if (waitid(P_PIDFD, (id_t)pfd, &info, WEXITED) < 0)
29 return 1;
30 printf("pid=%ld status=%d\n", (long)info.si_pid, info.si_status);
31 close(pfd);
32 return 0;
33}
CODE NOTES
코드 조각별 설명
_exit(42)child의 exit status 하위 8 bit에 42를 기록한다. stdio 상태를 상속받은 child라서 exit() 대신 _exit()를 쓴다.
SYS_pidfd_openglibc wrapper 가용성과 무관하게 raw syscall을 호출하는 예다. 성공한 fd는 이 process instance를 참조한다.
.events = POLLINpidfd는 process가 종료되면 readable event를 제공해 epoll 기반 supervisor에도 합칠 수 있다.
waitid(P_PIDFD숫자 PID를 다시 lookup하지 않고 같은 pidfd가 가리키는 child의 상태를 회수한다.
close(pfd)wait와 pidfd close는 별개다. wait가 child status를 회수하고 close가 pid object에 대한 fd 참조를 놓는다.
DETAILS
세부 동작
SIGCHLD는 알림이고 wait는 회수다
SIGCHLD handler가 실행됐다고 child가 자동 회수되는 것은 아니다. handler에서는 waitpid(-1, ..., WNOHANG)를 더 이상 대상이 없을 때까지 반복하거나 signalfd/event loop에서 같은 작업을 한다.
SIGCHLD를 SIG_IGN으로 명시하거나 SA_NOCLDWAIT를 사용하면 Linux는 zombie를 남기지 않는 정책을 적용할 수 있다.
PID lookup에는 시간 차이가 있다
kill(pid), stat(/proc/pid), waitpid 사이에 target이 종료되고 같은 숫자가 다른 process에 배정될 수 있다. namespace가 끼면 보이는 PID도 context마다 다르다.
pidfd_open을 process 발견 직후 수행하거나 clone3의 CLONE_PIDFD로 생성과 handle 획득을 원자적으로 묶는다.
subreaper와 init의 역할
parent가 먼저 종료한 orphan은 가장 가까운 child subreaper 또는 PID namespace init으로 reparent된다. supervisor는 PR_SET_CHILD_SUBREAPER로 손자 process의 종료도 회수할 수 있다.
container의 PID 1이 wait loop를 구현하지 않으면 zombie가 누적되는 이유다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
exit status | do_exit에서 기록되고 wait가 소비할 때까지 보존된다 | exit_code, si_code, rusage |
struct pid | PID 번호와 task 참조를 연결하며 pidfd가 수명을 연장한다 | namespace별 upid, refcount |
pidfd | pidfd_open/clone3에서 생기고 close 때 fd 참조가 사라진다 | poll readiness, target identity |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| zombie 누적 | parent가 wait하지 않음 | ps state Z, /proc/PID/status |
| kill이 엉뚱한 process에 전달될 위험 | PID 재사용 사이의 lookup | pidfd_send_signal 사용 검토 |
| ECHILD | 호출 process의 child가 아니거나 이미 회수 | parent 관계와 wait option 확인 |
LAB
직접 확인
- child exit 뒤 wait 전에 sleep을 넣고 ps -o pid,ppid,state,cmd로 Z 상태를 확인한다.
- waitid에 WNOWAIT를 추가해 상태를 읽되 회수하지 않고 두 번째 wait에서 다시 확인한다.
- 여러 child pidfd를 epoll에 등록해 종료 순서와 생성 순서가 다를 때 처리 방식을 확인한다.
./pidfd_waitstrace -f -e trace=clone,pidfd_open,poll,waitid,exit_group ./pidfd_waitPRIMARY REFERENCES