ps aux로 프로세스 목록을 보거나, 좀비(zombie) 프로세스가 쌓여서 시스템이 이상하게 동작하는 걸 겪어본 적이 있다면 “프로세스가 어떻게 뜨고, 어떻게 관리되고, 어떻게 사라지는가”를 커널 관점에서 이해할 필요가 생긴다. 유저 공간에서는 fork()/exec() 몇 줄로 끝나는 일이지만, 그 이면에서 커널은 프로세스마다 task_struct라는 거대한 구조체를 만들고, 상태를 전이시키고, 종료 후 자원을 회수하는 일련의 절차를 정교하게 처리한다. 컨테이너의 네임스페이스/cgroup 격리도 결국 이 프로세스 관리 메커니즘 위에서 동작하는 것이라, 리눅스 시스템 프로그래밍이나 커널 디버깅을 하려면 한 번은 짚고 넘어가야 하는 기초다. 이 글에서는 task_struct와 태스크 리스트 구조, 프로세스 상태 전이, fork()/exec()를 통한 프로세스 생성, 스레드 구현 방식, 종료와 좀비 프로세스까지 리눅스 커널의 프로세스 관리를 정리한다.
프로세스 서술자(task_struct)와 태스크 리스트
커널은 시스템에 존재하는 모든 프로세스를 struct task_struct라는 프로세스 서술자(process descriptor)로 표현한다. 이 구조체 하나에 PID, 부모/자식 관계, 메모리 매핑 정보, 열린 파일 목록, 시그널 처리 상태, 스케줄링 정보 등 프로세스와 관련된 거의 모든 정보가 담긴다. 커널 소스에서는 include/linux/sched.h에 정의되어 있으며, 필드 수가 매우 많아 커널 버전이 올라갈 때마다 구조가 조금씩 바뀐다.
모든 task_struct는 원형 이중 연결 리스트(circular doubly linked list)인 태스크 리스트로 연결된다. 각 프로세스는 이 리스트를 순회해 다른 프로세스 정보에 접근할 수 있다. 현재 실행 중인 프로세스의 서술자에 빠르게 접근하기 위해 커널은 current라는 매크로를 제공하는데, x86-64에서는 별도 포인터를 저장하지 않고 커널 스택 포인터 연산으로 task_struct 주소를 즉시 계산해낸다. 이는 매번 전역 변수를 참조하는 것보다 빠르기 때문에 채택된 설계다.
유저 공간에서는 /proc/<pid>/status를 통해 이 task_struct의 일부 값을 들여다볼 수 있다.
$ cat /proc/self/status | head -8
Name: cat
Umask: 0022
State: R (running)
Tgid: 12345
Ngid: 0
Pid: 12345
PPid: 12100
TracerPid: 0
프로세스 상태 전이
프로세스는 생애 동안 여러 상태를 오간다. 커널이 정의하는 주요 상태는 다음과 같다.
| 상태 | 의미 |
|---|---|
| TASK_RUNNING | 실행 중이거나 실행 큐(run queue)에서 CPU를 기다리는 중 |
| TASK_INTERRUPTIBLE | 대기(sleep) 중이며, 조건 충족뿐 아니라 시그널로도 깨어남 |
| TASK_UNINTERRUPTIBLE | 대기 중이며 시그널을 무시함(디스크 I/O 대기 등에서 흔함) |
| __TASK_STOPPED | SIGSTOP 등으로 실행이 정지된 상태 |
| EXIT_ZOMBIE | 프로세스는 종료됐지만 부모가 아직 종료 상태를 회수하지 않은 상태 |
TASK_UNINTERRUPTIBLE 상태가 오래 지속되는 프로세스는 ps 출력에서 D state로 보이는데, 디스크 I/O나 NFS 마운트 문제로 이 상태에 오래 머무는 프로세스는 시그널로도 죽일 수 없어 디버깅 시 애를 먹이는 대표적인 사례다. ps -eo pid,stat,cmd로 프로세스별 상태 문자를 확인할 수 있다.
fork()와 exec() — 프로세스 생성
대부분의 운영체제는 새 프로세스를 만들 때 “생성”과 “실행할 프로그램 적재”를 한 번에 처리하지만, 유닉스 계열은 이 둘을 fork()와 exec() 두 단계로 분리한 것이 특징이다. fork()는 호출한 프로세스(부모)의 거의 완전한 복제본(자식)을 만들고, 자식이 이후 exec() 계열 함수를 호출하면 그 시점에 프로세스의 메모리 공간이 새 실행 파일로 교체된다.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
/* 자식 프로세스: 새 프로그램으로 교체 */
execlp("ls", "ls", "-l", NULL);
perror("execlp failed"); /* exec가 성공하면 이 줄은 실행되지 않음 */
_exit(1);
} else if (pid > 0) {
/* 부모 프로세스: 자식 종료를 기다림 */
int status;
waitpid(pid, &status, 0);
printf("child exited with status %d\n", WEXITSTATUS(status));
} else {
perror("fork failed");
}
return 0;
}커널 내부에서 fork()는 do_fork()(최근 커널에서는 kernel_clone())를 거쳐 copy_process()를 호출한다. 이 함수가 새 task_struct를 할당하고, 부모의 값을 복사하고, 새 PID를 부여하고, 부모와 자식 사이에 어떤 자원을 공유할지 결정한다. 부모 프로세스 전체를 그대로 물리 메모리에 복제하면 비용이 크기 때문에, 실제로는 메모리 페이지를 공유하되 쓰기가 발생하는 시점에만 복사하는 Copy-on-Write(COW) 기법을 쓴다. 그래서 fork() 직후 exec()를 바로 호출하는 흔한 패턴에서는 대부분의 페이지가 아예 복사되지 않고 끝난다.
스레드는 어떻게 구현되나
리눅스 커널에는 스레드만을 위한 별도의 자료구조가 없다. 스레드는 그냥 부모와 주소 공간(address space), 파일 디스크립터 테이블 등 자원을 공유하는 task_struct일 뿐이다. 이 공유 여부는 프로세스 생성 시 clone()에 전달하는 플래그로 결정된다.
/* pthread_create 내부적으로 사용하는 것과 유사한 clone 플래그 조합 */
clone(fn, stack, CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD, arg);CLONE_VM은 주소 공간을, CLONE_FILES는 열린 파일 테이블을, CLONE_SIGHAND는 시그널 핸들러를 공유하게 한다. 반대로 일반 fork()는 이 플래그들을 지정하지 않아 자식이 독립된 주소 공간을 갖는 별도 프로세스가 된다. 즉 “프로세스”와 “스레드”는 커널 스케줄러 입장에서 완전히 동등한 스케줄링 단위이고, 차이는 얼마나 많은 자원을 공유하느냐일 뿐이다.
프로세스 종료와 좀비 프로세스
프로세스가 종료되면 do_exit()가 호출되어 열린 파일을 닫고, 보유하던 메모리를 해제하고, 자식 프로세스들을 새 부모(보통 init 또는 가장 가까운 subreaper)에게 넘긴다. 하지만 task_struct 자체는 이 시점에 바로 사라지지 않는다. 부모 프로세스가 wait()/waitpid()로 자식의 종료 상태(exit code)를 수거해갈 때까지, 커널은 종료 상태만 남긴 최소한의 task_struct를 EXIT_ZOMBIE 상태로 유지한다.
부모가 wait()를 호출하지 않고 계속 살아있으면 이 좀비 프로세스는 회수되지 않고 쌓인다. 좀비는 메모리나 CPU를 거의 쓰지 않지만 PID 하나를 계속 점유하므로, 대량으로 쌓이면 새 프로세스를 만들 수 없는 상황(PID 고갈)까지 갈 수 있다. ps aux에서 STAT 컬럼이 Z로 표시되는 프로세스가 이것이다.
# 좀비 프로세스 찾기
$ ps aux | awk '$8 ~ /Z/ { print }'
# 좀비의 부모 PID 확인 후, 그 부모 프로세스가 wait()를 제대로
# 호출하도록 고치거나 재시작하는 것이 근본적인 해결책이다
주의사항
- 좀비 프로세스는
kill -9로 죽일 수 없다. 이미 죽은 프로세스이기 때문이며, 해결하려면 부모 프로세스가wait()를 호출하게 만들거나 부모 자체를 재시작해야 한다. TASK_UNINTERRUPTIBLE(D)상태가 길게 지속되면 대개 디스크나 네트워크 파일시스템(NFS) I/O 문제이지 애플리케이션 버그가 아닌 경우가 많다.strace로 시그널을 보내도 반응이 없다면 이 상태를 의심해야 한다.- COW 덕분에
fork()자체는 저렴하지만, 자식이 부모의 큰 메모리 영역에 쓰기를 대량으로 수행하면 그 시점에 페이지 복사 비용이 한꺼번에 발생할 수 있다. 대용량 데이터를 다루는 프로세스를fork()할 때는 이 지연을 염두에 둬야 한다. - 스레드와 프로세스가 커널 수준에서 동일한 스케줄링 단위라는 점 때문에,
ps -eLf처럼 LWP(경량 프로세스) 단위로 보면 스레드 하나하나가 별도 PID(정확히는 TID)를 가진 것으로 보인다. 애플리케이션 디버깅 시 PID와 TID를 혼동하지 않도록 주의한다.
마무리
task_struct 구조체 하나가 프로세스의 거의 모든 정보를 담고, 태스크 리스트로 연결되며, fork()/exec()의 두 단계 생성 모델과 COW로 효율적으로 복제되고, 종료 후에는 부모가 상태를 수거할 때까지 좀비로 남는다는 흐름을 이해해두면 ps, /proc, strace 같은 도구가 보여주는 정보를 훨씬 더 명확하게 해석할 수 있다. 특히 좀비 프로세스나 D state 프로세스처럼 실무에서 자주 마주치는 이상 증상은 대부분 이 프로세스 생명주기 모델을 알아야 원인을 정확히 짚을 수 있다.