프로세스 생성과 종료
fork로 프로세스를 복제하고 exec로 프로그램을 바꾸며 wait로 종료 상태를 거두는 UNIX의 프로세스 생명 주기를 코드로 따라갑니다. 두 호출을 나눈 설계가 셸의 리다이렉션을 가능하게 하는 이유, Python multiprocessing의 시작 방식, 좀비와 고아 프로세스가 생기는 원인과 예방법을 살펴봅니다.
터미널에서 ls를 입력하면 ls 프로세스가 생겼다가 출력을 마치고 사라집니다. 이 프로세스는 누가 만들었을까요? 운영체제에서 새 프로세스는 대부분 이미 있는 다른 프로세스가 만듭니다. 이 경우에는 셸이 만들었습니다. 그래서 커널이 직접 만드는 첫 사용자 프로세스를 빼면, 모든 프로세스에는 자신을 만든 부모(parent)가 있고, 부모와 자식(child)의 관계를 따라가면 프로세스들이 하나의 나무 모양을 이룹니다.
Linux에서는 pstree -p 명령으로 이 나무를 PID와 함께 볼 수 있습니다. systemd를 쓰는 시스템이라면 맨 위에 systemd(1)이 있고, SSH로 접속해 편집기를 열었다면 그 아래 어딘가에 sshd, 그 아래에 bash, 다시 그 아래에 vim이 이어지는 가지를 볼 수 있습니다. 이 가지는 SSH 서버가 접속을 받아 셸을 띄우고, 사용자가 그 셸에서 편집기를 실행했다는 이력을 그대로 보여 줍니다. 이 글에서는 UNIX 계열 운영체제가 프로세스를 만들고 끝내는 방법을 코드로 따라가 봅니다.
fork: 지금 프로세스를 복제한다
UNIX에서 새 프로세스를 만드는 기본 호출은 fork()입니다. fork()를 부르면 커널은 부르는 프로세스를 거의 그대로 복제해 자식 프로세스를 만듭니다. 메모리의 내용, 열린 파일 디스크립터, 환경 변수, 신호 처리 설정이 모두 자식에게 복사되고, 자식은 부모가 fork()를 부른 바로 그 지점에서 실행을 이어 갑니다.
fork()의 가장 독특한 점은 한 번 불렀는데 두 번 반환된다는 것입니다. 부모에게는 새로 만든 자식의 PID를, 자식에게는 0을 돌려줍니다. 두 프로세스는 같은 코드를 실행하고 있으므로, 이 반환값을 보고 자기가 부모인지 자식인지 구분해 서로 다른 일을 합니다. 자식을 만들지 못하면 부모에게 -1을 돌려주고 자식은 생기지 않습니다.
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
printf("fork 전: PID=%d\n", (int) getpid());
fflush(stdout);
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("자식: PID=%d, 부모 PID=%d\n", (int) getpid(), (int) getppid());
} else {
printf("부모: PID=%d, 자식 PID=%d\n", (int) getpid(), (int) pid);
waitpid(pid, NULL, 0);
}
return 0;
}이 프로그램은 "fork 전" 줄을 한 번, 그 뒤의 줄을 부모와 자식에서 한 번씩 출력합니다. PID 값은 실행할 때마다 다르고, 부모와 자식 가운데 누구의 줄이 먼저 나올지도 정해져 있지 않습니다. fork() 뒤에는 두 프로세스가 각자 스케줄링되기 때문입니다. 부모가 waitpid로 자식을 기다리게 한 것은, 부모가 먼저 끝나 버리면 자식이 getppid()로 읽는 부모 PID가 원래 부모가 아니라 뒤에서 볼 입양한 프로세스의 PID로 바뀔 수 있기 때문입니다.
fork() 앞의 fflush(stdout)에도 이유가 있습니다. 1장에서 보았듯이 printf는 출력을 라이브러리의 버퍼에 모아 두는데, 출력을 파일로 리다이렉트하면 줄바꿈이 있어도 버퍼를 바로 비우지 않습니다. 그 상태에서 fork()하면 아직 나가지 않은 "fork 전" 줄이 버퍼째 자식에게 복사되어, 나중에 부모와 자식이 각자 버퍼를 비울 때 같은 줄이 두 번 기록됩니다. fork() 전에 버퍼를 비우면 이 문제가 생기지 않습니다.
복사와 공유를 구분하기
부모와 자식은 처음에 같은 메모리 내용을 가지지만, 그 뒤로는 서로의 변수를 바꾸지 못합니다. 부모가 전역 변수를 고쳐도 자식의 같은 변수는 그대로입니다. 메모리가 복사되었기 때문입니다. 반면 파일 디스크립터는 조금 다릅니다. 자식은 부모의 디스크립터 표를 복사받지만, 각 디스크립터가 가리키는 열린 파일(open file description)은 부모와 함께 씁니다. 그래서 부모와 자식이 같은 파일에 번갈아 쓰면 파일 안의 쓰기 위치를 공유하므로 서로의 내용을 덮어쓰지 않고 이어서 씁니다. mmap으로 MAP_SHARED 매핑을 만들어 둔 메모리도 복사되지 않고 공유됩니다.
복사되지 않는 것도 있습니다. 자식은 새 PID를 받고, 부모가 걸어 둔 fcntl 레코드 잠금이나 타이머는 물려받지 않습니다. 또 부모에 스레드가 여러 개 있어도 자식에는 fork()를 부른 스레드 하나만 있습니다. 전체 목록은 fork(2)에 있습니다.
메모리를 정말 다 복사할까: 쓰기 시 복사
"메모리를 통째로 복사한다"고 하면 1GB를 쓰는 프로세스가 fork()할 때마다 1GB를 복사해야 할 것 같습니다. 실제로는 그렇지 않습니다. fork() 직후 커널은 부모와 자식이 같은 물리 페이지를 함께 쓰도록 페이지 테이블만 복사하고, 그 페이지들을 양쪽 모두에서 읽기 전용으로 표시합니다. 둘 가운데 하나가 어떤 페이지에 쓰려고 하면 페이지 폴트가 일어나고, 그때 커널이 그 페이지 하나만 복사해 쓰는 쪽에 새 사본을 줍니다. 이 기법을 쓰기 시 복사(copy-on-write, COW)라고 합니다.
쓰기 시 복사 덕분에 fork()의 비용은 메모리 크기보다 페이지 테이블의 크기에 가깝습니다. 특히 자식이 fork() 직후 다음에 볼 exec로 다른 프로그램을 실행한다면, 복제한 메모리를 거의 쓰지 않고 버리므로 실제로 복사되는 페이지는 아주 적습니다. 이 과정은 9장에서 페이지 폴트와 함께 자세히 봅니다.
exec: 프로그램을 바꾼다
fork()만으로는 같은 프로그램의 사본만 늘어납니다. 다른 프로그램을 실행하려면 exec 계열 함수를 씁니다. exec는 지금 프로세스의 코드, 데이터, 힙, 스택을 모두 버리고, 지정한 실행 파일을 새로 적재해 그 프로그램의 처음부터 실행합니다. 프로세스를 새로 만드는 것이 아니라 지금 프로세스의 내용물을 바꾸는 것이므로 PID는 그대로입니다. 열린 파일 디스크립터도 기본적으로 그대로 남는데, FD_CLOEXEC 표시가 붙은 디스크립터만 exec할 때 닫힙니다.
exec가 성공하면 원래 프로그램으로 돌아오지 않습니다. 돌아갈 코드 자체가 사라졌기 때문입니다. 그래서 exec 다음 줄이 실행되었다면 그것은 exec가 실패했다는 뜻이며, 그 자리에는 오류를 처리하는 코드를 둡니다. 커널에 들어가는 시스템 콜은 execve 하나이고, execlp, execvp 같은 함수는 인자를 넘기는 방식이나 실행 파일을 PATH에서 찾는지에 따라 이름이 다른 라이브러리 함수입니다.
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
execlp("ls", "ls", "-la", (char *) NULL);
perror("execlp"); /* exec가 실패했을 때만 여기에 온다. */
_exit(127);
}
int status;
pid_t waited;
do {
waited = waitpid(pid, &status, 0);
} while (waited == -1 && errno == EINTR);
if (waited == -1) {
perror("waitpid");
return EXIT_FAILURE;
}
if (WIFEXITED(status))
printf("자식이 정상 종료, 종료 코드 %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("자식이 신호 %d로 종료\n", WTERMSIG(status));
return EXIT_SUCCESS;
}자식은 execlp로 ls -la가 되어 디렉터리 목록을 출력하고 끝나며, 부모는 waitpid로 기다렸다가 종료 상태를 해석해 출력합니다. ls가 성공하면 마지막 줄은 "자식이 정상 종료, 종료 코드 0"이 됩니다. ls를 찾을 수 없는 환경이라면 자식에서 execlp가 실패해 오류 메시지를 출력하고 종료 코드 127로 끝나며, 127은 셸이 "명령을 찾을 수 없음"에 쓰는 관례적인 값입니다.
fork와 exec를 나눈 이유
다른 프로그램을 실행하는 데 왜 호출을 두 번이나 해야 할까요? 바로 두 호출 사이에 자식이 자기 환경을 손볼 수 있기 때문입니다. fork() 뒤 exec 전의 자식은 부모의 코드를 그대로 실행하므로, 새 프로그램이 시작되기 전에 파일 디스크립터를 바꾸거나, 작업 디렉터리를 옮기거나, 환경 변수나 신호 설정을 조정할 수 있습니다. 새 프로그램은 바뀐 환경을 그대로 물려받습니다.
셸의 리다이렉션이 이렇게 동작합니다. ls > out.txt를 입력하면 셸은 fork()한 뒤, 자식에서 out.txt를 열고 dup2()로 그 디스크립터를 표준 출력인 1번 자리에 복사한 다음 ls를 exec합니다. ls는 평소처럼 1번 디스크립터에 쓸 뿐인데, 그 출력이 파일로 들어갑니다. 파이프(|)와 백그라운드 실행(&)도 같은 방식으로 만들어집니다. 다만 직접 등록한 신호 처리 함수는 exec하면 기본 동작으로 돌아갑니다. 처리 함수의 코드가 새 프로그램에는 없기 때문입니다.
셸에서 명령 하나를 실행하는 흐름은 다음과 같습니다.
- 셸이
fork()로 자식을 만듭니다. - 자식은 리다이렉션 같은 준비를 마친 뒤 exec로 명령의 프로그램이 됩니다.
- 프로그램이 일을 마치고 종료합니다.
- 셸은
waitpid()로 자식의 종료 상태를 거둡니다. - 셸이 다음 프롬프트를 보여 줍니다.
cd나 export처럼 셸 자신의 상태를 바꾸는 내장 명령은 이 흐름을 거치지 않고 셸 안에서 바로 처리됩니다.
다른 운영체제와 다른 방법
Windows는 이와 다른 방식을 씁니다. CreateProcess 함수 하나로 새 프로세스를 만들면서 실행할 프로그램을 함께 지정하고, 작업 디렉터리나 환경 변수, 표준 입출력의 연결, 핸들을 물려줄지 같은 준비 사항은 모두 이 함수의 인자로 넘깁니다. 자식에서 두 호출 사이에 코드를 실행하는 대신, 필요한 설정을 인자로 미리 정하는 셈입니다.
POSIX에도 비슷한 방식의 posix_spawn()이 있습니다. 메모리가 큰 프로세스는 페이지 테이블을 복사하는 비용도 커지므로, 곧바로 다른 프로그램을 실행할 것이라면 복제 없이 실행하는 편이 낫기 때문입니다. glibc는 posix_spawn()을 부모의 메모리를 복사하지 않는 방식으로 구현합니다.
Python에서 프로세스 만들기
Python의 multiprocessing 모듈은 새 프로세스에서 함수를 실행하는 방법을 제공합니다. 다음 코드는 자식 프로세스가 전역 변수를 바꾸어도 부모의 변수는 바뀌지 않는다는 것을 보여 줍니다.
import multiprocessing as mp
counter = 10
def child():
global counter
counter += 4
print("자식의 counter:", counter)
if __name__ == "__main__":
p = mp.Process(target=child)
p.start()
p.join()
print("자식의 종료 코드:", p.exitcode)
print("부모의 counter:", counter)자식의 counter: 14
자식의 종료 코드: 0
부모의 counter: 10p.start()가 자식 프로세스를 만들어 child를 실행하고, p.join()은 C의 waitpid()처럼 자식이 끝날 때까지 기다립니다. 자식은 자기 주소 공간의 counter를 14로 바꾸었지만, 부모의 counter는 여전히 10입니다. 두 프로세스는 메모리를 공유하지 않으므로, 결과를 부모에게 전하려면 다음 글에서 다룰 프로세스 간 통신이 필요합니다. p.exitcode는 자식의 종료 코드이며, 함수가 예외 없이 끝나면 0입니다.
if __name__ == "__main__": 부분은 빼면 안 됩니다. multiprocessing이 자식을 만드는 방법은 세 가지인데, 부모를 그대로 복제하는 fork, 새 Python 인터프리터를 띄우는 spawn, 미리 띄워 둔 서버 프로세스에서 복제하는 forkserver입니다. spawn과 forkserver는 자식이 이 파일을 모듈로 다시 읽어 들이므로, 이 조건이 없으면 자식이 또 자식을 만들려고 합니다. 기본 방식은 플랫폼마다 다릅니다. Windows는 spawn만 쓰고, macOS는 Python 3.8부터 spawn이 기본이며, Linux 같은 나머지 POSIX 플랫폼은 Python 3.14부터 forkserver가 기본입니다. 특정 방식이 필요하면 multiprocessing.get_context("spawn")처럼 정해 쓸 수 있으며, 자세한 내용은 multiprocessing 문서에 있습니다.
fork 방식이 기본에서 물러난 데에는 이유가 있습니다. 스레드가 여러 개인 프로세스를 fork()하면 자식에는 fork()를 부른 스레드만 남는데, 그 순간 다른 스레드가 잠금을 쥐고 있었다면 자식에서는 그 잠금을 풀어 줄 스레드가 없습니다. 자식이 같은 잠금을 얻으려 하면 영원히 기다리게 됩니다. 라이브러리가 몰래 스레드를 만드는 경우도 많으므로, 프로그램을 쓴 사람도 모르는 사이에 이런 일이 생길 수 있습니다.
Python에서 계산이 많은 작업에 스레드 대신 프로세스를 쓰는 이유도 알아 둘 만합니다. 일반적인 CPython에는 전역 인터프리터 잠금(global interpreter lock, GIL)이 있어서, 한 인터프리터 안의 여러 스레드가 Python 코드를 동시에 실행하지 못합니다. 프로세스마다 인터프리터와 GIL이 따로 있으므로, 여러 프로세스로 나누면 계산을 여러 CPU 코어에서 동시에 실행할 수 있습니다. GIL이 없는 free-threaded 빌드도 있지만 아직 일반적인 배포판의 기본은 아니며, 이 내용은 4장에서 다룹니다.
wait: 자식의 종료 상태를 거둔다
자식 프로세스가 끝나면 커널은 자식이 쓰던 메모리와 열린 파일 같은 자원을 회수하지만, 자식이 어떻게 끝났는지를 담은 종료 상태(exit status)는 부모가 가져갈 때까지 보관합니다. 부모는 wait()나 waitpid()로 이 정보를 가져가며, 이를 자식을 거둔다(reap)고 합니다. waitpid()가 채워 준 status 값은 정수 하나에 여러 정보를 담고 있으므로, 다음 매크로로 풀어 읽습니다.
| 매크로 | 뜻 |
|---|---|
WIFEXITED(status) | 자식이 exit()나 main의 반환으로 정상 종료했는가. 그렇다면 WEXITSTATUS(status)가 종료 코드 |
WIFSIGNALED(status) | 자식이 신호를 받아 종료했는가. 그렇다면 WTERMSIG(status)가 신호 번호 |
WIFSTOPPED(status) | 자식이 멈추었는가. WUNTRACED 옵션을 주었거나 디버거가 추적하는 경우에 의미가 있다 |
종료 코드는 0이 성공, 0이 아닌 값이 실패라는 관례를 따릅니다. 셸에서는 echo $?로 직전 명령의 종료 코드를 볼 수 있고, CI 스크립트가 명령의 성공과 실패를 판단하는 것도 이 값입니다. 셸은 신호로 끝난 명령의 종료 코드를 128에 신호 번호를 더해 보여 주므로, SIGKILL(9)로 끝난 명령은 137로 나타납니다. 종료 상태를 해석하는 매크로는 waitpid(2)에, 셸이 128에 신호 번호를 더하는 규칙은 Bash 매뉴얼의 종료 상태 설명에 있습니다.
좀비와 고아
부모와 자식은 서로 다른 시점에 끝날 수 있으므로, 둘 사이에는 두 가지 어긋남이 생깁니다. 자식이 먼저 끝났는데 부모가 아직 거두지 않은 경우와, 자식이 아직 살아 있는데 부모가 먼저 끝난 경우입니다. 아래 단계 개념도는 자식이 만들어져 exec하고, 끝난 뒤 부모에게 거두어지기까지의 과정을 보여 줍니다.
fork 이후 부모와 자식의 실행 순서
새 자식 PID와 두 실행 흐름이 생긴다. 부모가 자식 종료 상태를 수거한다. 각 단계의 값과 전환 이유를 비교합니다.
프로세스 생명주기 · 생성과 수거
fork는 새 PID를 만들고, exec는 그 PID의 프로그램을 바꿉니다
성공한 fork·exec와 종료한 자식을 waitpid로 수거하는 예시입니다. exit와 수거 사이에는 종료 기록이 남으며 부모·자식의 실행 순서는 보장되지 않습니다.
성공한 fork·exec와 종료한 자식의 waitpid 수거를 단순화했습니다. 부모가 waitpid를 먼저 호출할 수도, 자식이 먼저 종료할 수도 있습니다. exit와 수거 사이에는 종료 기록이 남습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 fork 성공부터 waitpid 수거까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | fork 이후 부모와 자식의 실행 순서 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| fork 성공 새 자식 PID와 두 실행 흐름이 생긴다. |
|
|
| 자식 exec 같은 PID의 프로그램을 바꾼다. |
|
|
| 자식 종료 실행 자원은 종료되고 상태 기록이 남는다. |
|
|
| waitpid 수거 부모가 자식 종료 상태를 수거한다. |
|
|
fork 이후 부모와 자식의 실행 순서
새 자식 PID와 두 실행 흐름이 생긴다. 부모가 자식 종료 상태를 수거한다. 각 단계의 값과 전환 이유를 비교합니다.
프로세스 생명주기 · 생성과 수거
fork는 새 PID를 만들고, exec는 그 PID의 프로그램을 바꿉니다
성공한 fork·exec와 종료한 자식을 waitpid로 수거하는 예시입니다. exit와 수거 사이에는 종료 기록이 남으며 부모·자식의 실행 순서는 보장되지 않습니다.
성공한 fork·exec와 종료한 자식의 waitpid 수거를 단순화했습니다. 부모가 waitpid를 먼저 호출할 수도, 자식이 먼저 종료할 수도 있습니다. exit와 수거 사이에는 종료 기록이 남습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 fork 성공부터 waitpid 수거까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | fork 이후 부모와 자식의 실행 순서 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| fork 성공 새 자식 PID와 두 실행 흐름이 생긴다. |
|
|
| 자식 exec 같은 PID의 프로그램을 바꾼다. |
|
|
| 자식 종료 실행 자원은 종료되고 상태 기록이 남는다. |
|
|
| waitpid 수거 부모가 자식 종료 상태를 수거한다. |
|
|
fork()가 성공하면 실행 흐름이 둘이 되고, 자식이 exec해도 PID는 그대로입니다. 자식이 끝나면 실행에 쓰던 자원은 회수되지만 종료 상태의 기록은 남고, 부모가 waitpid()로 그 기록을 거두어야 비로소 자식이 완전히 사라집니다. 그 사이에 부모와 자식 가운데 누가 먼저 실행될지는 정해져 있지 않습니다.
좀비 프로세스
자식이 끝났지만 부모가 아직 거두지 않은 상태의 프로세스를 좀비(zombie) 프로세스라고 합니다. 실행은 이미 끝났으므로 코드도 메모리도 없지만, 종료 상태를 담은 프로세스 표의 항목이 남아 있고 PID도 계속 차지합니다. ps에서는 상태가 Z로, 명령 이름 뒤에 <defunct>가 붙어 보입니다.
좀비 몇 개는 메모리를 거의 쓰지 않아 문제가 되지 않습니다. 하지만 요청마다 자식을 만들면서 거두지 않는 서버를 오래 실행하면 좀비가 계속 쌓이고, 결국 쓸 수 있는 PID가 바닥나 새 프로세스를 만들 수 없게 됩니다. 이런 상황을 막는 방법은 세 가지입니다.
- 부모가 알맞은 시점에
wait()나waitpid()를 부릅니다. - 자식이 끝날 때 부모에게 오는
SIGCHLD신호의 처리 함수에서waitpid()를 부릅니다. SIGCHLD를 무시(SIG_IGN)하도록 설정해 커널이 끝난 자식을 바로 정리하게 합니다. 이 경우 부모는 자식의 종료 상태를 받을 수 없습니다.
다음 프로그램은 둘째 방법을 보여 줍니다. 자식 셋을 만들고, 자식이 끝날 때마다 오는 SIGCHLD의 처리 함수에서 끝난 자식을 거둡니다.
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
static volatile sig_atomic_t reaped = 0;
static void on_sigchld(int sig)
{
(void) sig;
int saved_errno = errno; /* 처리 함수가 errno를 바꾸지 않게 보관한다. */
while (waitpid(-1, NULL, WNOHANG) > 0)
reaped++;
errno = saved_errno;
}
int main(void)
{
struct sigaction sa;
sa.sa_handler = on_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0)
_exit(0); /* 자식은 바로 끝난다. */
}
while (reaped < 3)
sleep(1); /* 신호가 오면 일찍 깨어난다. */
printf("거둔 자식 수: %d\n", (int) reaped);
return 0;
}처리 함수가 waitpid()를 한 번만 부르지 않고 반복해서 부르는 데에는 이유가 있습니다. 자식 여럿이 거의 동시에 끝나면 SIGCHLD가 여러 번 쌓이지 않고 한 번만 전달될 수 있으므로, 처리 함수가 한 번 불릴 때 끝난 자식을 모두 거두어야 합니다. WNOHANG은 끝난 자식이 더 없으면 기다리지 말고 바로 0을 돌려주라는 옵션입니다. 처리 함수 안에서는 printf 같은 함수를 부르지 않고, 신호 처리 함수에서 안전하게 쓸 수 있는 waitpid()와 sig_atomic_t 변수만 다룹니다. SA_RESTART는 신호 때문에 중단된 시스템 콜을 자동으로 다시 시작하게 하고, SA_NOCLDSTOP은 자식이 멈추었을 때는 SIGCHLD를 보내지 말라는 설정입니다. 세 자식을 모두 거두면 메인 루프가 끝나고 거둔 수를 출력합니다.
고아 프로세스
부모가 자식보다 먼저 끝나면, 남은 자식을 고아(orphan) 프로세스라고 합니다. 고아가 부모 없이 남는 것은 아닙니다. 커널은 고아를 그 PID 네임스페이스의 init 프로세스(PID 1)에게 입양시키고, 입양한 프로세스가 나중에 그 자식의 종료 상태를 거둡니다. Linux에서는 prctl(PR_SET_CHILD_SUBREAPER)로 자신을 하위 거두미(subreaper)로 지정한 프로세스가 있다면, init 대신 가장 가까운 하위 거두미가 고아를 입양합니다. systemd의 사용자 세션 관리자나 컨테이너 런타임이 이 기능을 씁니다.
고아 자체는 오류가 아닙니다. 서버 프로그램이 일부러 부모와 떨어져 백그라운드에서 실행되도록 만들 때도 이 성질을 이용합니다. 문제는 의도하지 않은 고아가 계속 실행되면서 자원을 쓰는 경우와, 입양한 프로세스가 자식을 거두지 않는 경우입니다. 앞 장에서 본 것처럼, 컨테이너의 PID 1이 자식을 거두는 코드가 없는 일반 애플리케이션이면 입양된 고아가 끝난 뒤 좀비로 쌓입니다. 이것이 컨테이너에서 tini 같은 작은 init을 쓰는 이유입니다.
좀비와 그 부모 찾기
좀비를 없애려면 좀비 자신이 아니라 그 부모를 봐야 합니다. 좀비는 이미 끝난 프로세스라 kill로 다시 끝낼 수 없고, 부모가 거두거나 부모가 끝나 init에게 입양된 뒤 거두어져야 사라지기 때문입니다.
# 상태가 Z로 시작하는 프로세스와 그 부모의 PID
ps -eo pid,ppid,stat,cmd | awk 'NR == 1 || $3 ~ /^Z/'
# 상태 글자별 프로세스 수
ps -eo stat= | awk '{ n[substr($1, 1, 1)]++ } END { for (s in n) print s, n[s] }'첫 명령은 좀비 프로세스의 PID와 부모 PID(PPID)를 보여 주므로, 그 PPID의 프로세스가 자식을 제대로 거두지 않는 프로그램이라고 짐작할 수 있습니다. 좀비가 잠깐 나타났다 사라지는 것은 정상이므로, 한 번 보이는 것보다 시간이 지나도 남아 있거나 늘어나는지를 봐야 합니다. 또 PPID가 1이라고 해서 원래 고아였다고 단정할 수는 없습니다. 처음부터 init이 띄운 서비스일 수도 있기 때문입니다.
더 깊이: 다시 쓰이는 PID와 pidfd
좀비가 PID를 계속 차지한다는 것은 거꾸로 말하면, 자식이 거두어지는 순간부터 그 번호를 다른 프로세스가 받을 수 있다는 뜻입니다. 그래서 PID 번호만 들고 kill()로 신호를 보내는 프로그램에는 틈이 생깁니다. 보내려던 프로세스가 이미 끝나 거두어졌고 그 PID를 새 프로세스가 받았다면, 신호가 엉뚱한 프로세스에 전달됩니다. pidfd_send_signal(2) 매뉴얼은 이 호출이 필요한 이유로 바로 이 경쟁 상황을 듭니다.
Linux는 이 문제를 풀려고 프로세스를 번호 대신 파일 디스크립터로 가리키는 PID 파일 디스크립터(pidfd)를 도입했습니다. Linux 5.1에 pidfd_send_signal()이, 5.2에 자식을 만들면서 pidfd를 함께 받는 clone()의 CLONE_PIDFD 플래그가, 5.3에 이미 있는 프로세스의 pidfd를 얻는 pidfd_open()이, 5.4에 pidfd로 자식을 기다리는 waitid()의 P_PIDFD가 추가되었습니다. pidfd는 특정 프로세스 하나를 가리키므로, 그 프로세스가 끝난 뒤에 신호를 보내면 다른 프로세스에 전달되는 대신 ESRCH 오류가 납니다.
pidfd는 일반 파일 디스크립터처럼 poll()이나 epoll에 넣을 수도 있습니다. 가리키는 프로세스가 끝나 좀비가 되면 디스크립터가 읽을 수 있는 상태로 바뀌므로, 이벤트 루프는 소켓을 기다리던 것과 같은 방법으로 자식의 종료도 함께 기다릴 수 있습니다.
정리
- UNIX에서 새 프로세스는
fork()로 만들어집니다.fork()는 부모에게 자식의 PID를, 자식에게 0을 돌려주며, 메모리는 쓰기 시 복사로 필요할 때만 실제로 복사됩니다. - exec는 PID를 유지한 채 프로세스의 프로그램을 바꾸며, 성공하면 돌아오지 않습니다. 열린 파일 디스크립터는
FD_CLOEXEC가 붙은 것만 닫힙니다. fork()와 exec를 나눈 덕분에 자식은 새 프로그램을 실행하기 전에 리다이렉션 같은 준비를 할 수 있습니다. Windows의CreateProcess와 POSIX의posix_spawn()은 준비 사항을 인자로 넘기는 방식입니다.- 부모는
waitpid()로 자식의 종료 상태를 거두고, 매크로로 정상 종료인지 신호로 끝났는지 구분합니다. - 끝났지만 거두어지지 않은 자식은 좀비로 남아 PID를 차지하고, 부모가 먼저 끝난 자식은 고아가 되어 init이나 하위 거두미에게 입양됩니다.
연습 문제
1. 하나의 자식을 끝까지 따라가기
PID가 600인 부모가 fork()에 성공해 PID 605인 자식을 만들었습니다. fork() 직전 전역 변수 x의 값은 10이었고, 그 뒤 부모는 x에 1을, 자식은 4를 더했습니다. 공유 매핑은 없습니다. 두 프로세스의 x 값, 자식이 exec에 성공하기 전과 후의 PID, 자식이 끝난 뒤 부모가 부른 waitpid()가 성공했을 때의 반환값을 각각 답하세요. 또 자식이 아직 살아 있는데 부모가 먼저 끝났다면 그 자식을 좀비라고 부를 수 있을까요?
해설 보기
부모의 x는 11, 자식의 x는 14입니다. 두 프로세스는 각자의 주소 공간에서 자기 사본을 바꾸므로, 두 덧셈이 합쳐진 15가 되는 일은 없습니다. 자식의 PID는 exec 전과 후 모두 605입니다. exec는 프로세스를 새로 만드는 것이 아니라 프로그램만 바꾸기 때문입니다. 성공한 waitpid()는 거둔 자식의 PID인 605를 돌려줍니다. 부모가 먼저 끝나고 자식이 살아 있다면 그 자식은 고아입니다. 좀비는 이미 끝났는데 거두어지지 않은 프로세스이므로, 아직 실행 중인 고아와는 다릅니다. 이 고아는 init이나 하위 거두미에게 입양되며, 나중에 끝나면 입양한 프로세스가 거둡니다.
2. 파일로 보내면 두 번 찍히는 줄
다음 프로그램을 터미널에서 실행하면 "start"가 한 번 출력되지만, ./a.out > out.txt로 실행하면 out.txt에 "start"가 두 번 기록됩니다. 이유를 설명하고 고치는 방법을 제시하세요.
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
printf("start\n");
pid_t pid = fork();
if (pid > 0)
waitpid(pid, NULL, 0);
return 0;
}해설 보기
표준 출력이 터미널이면 줄 단위로 버퍼링되므로, printf("start\n")의 줄바꿈에서 버퍼가 비워져 fork() 전에 "start"가 이미 터미널로 나갑니다. 파일로 리다이렉트하면 블록 단위로 버퍼링되므로, fork() 시점에 "start\n"이 아직 라이브러리 버퍼에 남아 있습니다. fork()는 이 버퍼가 들어 있는 메모리까지 자식에게 복사하므로, 부모와 자식이 각자 끝날 때 자기 버퍼를 비우면서 "start"가 두 번 기록됩니다. fork() 직전에 fflush(stdout)을 부르면 버퍼가 빈 상태로 복제되므로 한 번만 기록됩니다. 자식이 exec하지 않고 끝나는 경우라면 exit() 대신 _exit()를 써서 자식이 버퍼를 비우지 않게 하는 방법도 있습니다.
3. 늘어나는 좀비
요청마다 fork()로 자식을 만들어 처리하는 서버가 있습니다. 며칠 운영했더니 ps에 상태가 Z인 프로세스가 수천 개 보이고, 최근에는 "fork: Resource temporarily unavailable" 오류가 나기 시작했습니다. 무엇이 문제인지, 어떻게 확인하고 고칠 수 있는지 설명하세요.
해설 보기
서버가 끝난 자식을 거두지 않아 좀비가 쌓인 것으로 보입니다. 좀비는 메모리를 거의 쓰지 않지만 PID와 프로세스 표의 항목을 계속 차지하므로, 쌓이다 보면 사용자당 프로세스 수 한도나 시스템의 PID 한도에 걸려 새 fork()가 실패합니다. ps -eo pid,ppid,stat,cmd로 좀비들의 PPID를 확인해 그것이 서버의 PID인지 보면 원인을 확인할 수 있습니다. 좀비는 kill로 없앨 수 없으므로, 서버 코드에서 SIGCHLD 처리 함수로 waitpid(-1, NULL, WNOHANG)을 반복해 부르거나, 자식의 종료 상태가 필요 없다면 SIGCHLD를 SIG_IGN으로 설정해 고칩니다. 당장 좀비를 치워야 한다면 서버 프로세스를 다시 시작합니다. 부모가 끝나면 좀비들이 init에게 입양되어 거두어지기 때문입니다.
다음 글 프로세스 간 통신(IPC)에서는 메모리를 공유하지 않는 프로세스들이 파이프, 공유 메모리, 소켓, 신호로 데이터를 주고받는 방법을 살펴봅니다.