Linux 프로세스·스케줄러 심화 — 태스크 상태 머신, CFS·런큐, NUMA와 프로덕션 튜닝
이 글의 핵심
리눅스에서 “프로세스”는 스케줄링 단위인 태스크(task_struct)의 집합이고, CPU는 CFS 등 스케줄링 클래스 규칙에 따라 런큐에서 고릅니다. 상태 머신·스케줄러·cgroup·NUMA를 한데 엮어야 지연·기아·스틸 시간 같은 운영 증상을 원인까지 설명할 수 있습니다.
들어가며 — Linux 시리즈 #05
이 글은 Linux 시리즈 다섯 번째로, 좀비·고아와 실패한 수거부터 짚고, 그다음에 사용자 공간의 “프로세스”와 커널 태스크(task)·스케줄러·cgroup·관측을 잇습니다. 이유는 단순합니다. 문서·튜토리얼이 흔히 fork와 R/S/D를 먼저 깔고 가지만, 현장에서 밤을 새게 하는 건 PID 소진, Z 누적, PID 1이 자식을 안 거두는 케이스가 많기 때문입니다.
이후 (1) 프로세스 개요와 PID, (2) fork/exec, (3) 부모–자식·init/systemd와 제가 systemd를 대하는 방식, (4) ps의 R/S/D/T/Z를 표 없이 상태 전이 흐름으로 읽는 법, (5) 종료·시그널·IPC, (6) nice/CFS, (7) ps/top·/proc, (8) kill 계열, (9) 데몬·서비스, (10) 실전 스크립트와 cgroup, (11) 디버깅 일지로 남는 트러블슈팅 흐름까지 이어갑니다. 메모리·페이지 폴트 심화는 Linux 메모리·가상 메모리 심화를 함께 보시면 됩니다.
1. 좀비·고아를 맨 앞에 — “좀비가 20만 개 생겼을 때”
프로덕션 격정: 좀비가 20만 개쯤 쌓였다면
한 번은(제목만 들어도 끔찍한) 짧은 수명의 워커를 fork로 뿌리는 서비스가 있었는데, 부모 쪽이 SIGCHLD를 “그냥 두거나” 잘못 처리한 채, 자식이 끊임없이 exit만 하고 부모는 wait을 사실상 안 부르는 경로로 굴러갔습니다. 모니터링은 “CPU는 한가하다” 쪽이었고, ps로 보면 Z가 줄줄 늘었죠. 그러다 PID가 바닥나기 직전이 되어서야, 혹은 프로세스/태스크 한도에 먼저 닿는 그림으로 터집니다. 그다음엔 fork가 실패하고, “왜 갑자기?”라는 전화가 옵니다.
정리하자면 좀비는 ‘귀여운’ 메타포가 아니라, 회수 누락이 만든 커널 자원의 누적입니다. 소수는 흔하지만, 누적되면 pid_max·운영자의 정신·장애 보고서가 같이 갈가리 찢깁니다. 저는 이 장면을 본 뒤로 아키텍처 리뷰에서 “fork로 워커”가 나오면, 먼저 누가 언제 wait하는지를 먹어 들어갑니다.
좀비: 원인과 왜 “문제”인가
자식이 종료하면 커널은 종료 코드·리소스 일부를 부모가 wait/waitpid로 가져갈 때까지 유지합니다. 그 전까지 ps는 Z 를 붙입니다. 원인은 한 줄로 말해 부모가 회수의 책임을 안 지는 설계/버그입니다.
처리: wait, waitpid, SIGCHLD
부모는 자식이 끊길 때마다 wait 계열로 정리하거나, 시그널 핸들러에서 waitpid(-1, NULL, WNOHANG) 루프로 “한 번에 여러 자식”을 훑습니다.
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>
static void reap_children(int signo) {
(void)signo;
while (waitpid(-1, NULL, WNOHANG) > 0) {
/* 자식 여러 명을 한 시그널 핸들러에서 정리 */
}
}
int main(void) {
struct sigaction sa;
sa.sa_handler = reap_children;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
/* ... fork 자식들 ... */
return 0;
}
현장의 문장으로 말하면: “부모는 자식의 시체를 치우는 사람이다.” C 라이브러리와 시그널의 세부(비동기 시그널 안전 등)는 뒤 시그널 절에서 다시 짚습니다. 운영 관점에선, 애드혹 셸 백그라운드보다 서비스 매니저가 재시작·로그·한도를 잡는 편이 이런 누락을 줄이는 데 유리한 경우가 많습니다.
고아(orphan)와 PID 1, 컨테이너
부모가 먼저 죽으면 자식은 고아가 되고, 새 부모(전통적으로 PID 1, 요즘은 대부분 systemd) 밑으로 붙습니다. PID 1은 죽은 자식을 wait으로 수거할 수 있어야 하고, 컨테이너에서 앱이 곧이곧대로 PID 1이면, SIGCHLD·회수를 앱이 담아내지 못해 Z가 쌓이는 사례가 꾸줏꾸줏 나옵니다. 그때 tini·dumb-init을 끼우거나, 애플리케이션이 책임지고 자식을 거두는 설계로 돌리는 쪽이 낫습니다.
2. 프로세스 개요: 무엇이며 PID와 생명주기는 어떻게 되는가
프로세스란
프로세스는 실행 중인 프로그램의 인스턴스로, 가상 주소 공간, 열린 파일 디스크립터, 시그널 마스크, 자원 한도 등을 커널이 붙인 컨테이너 객체에 가깝습니다. 커널은 동시에 수천~수만 개의 프로세스를 유지하며, 각각에 대해 스케줄링·회계·보안 문맥을 관리합니다.
스레드가 여럿인 다중 스레드 프로세스에서는, 사용자 입장의 “한 프로세스”가 커널에선 thread_group으로 묶인 여러 task_struct로 표현됩니다. 따라서 “프로세스 단위”와 “스레드(태스크) 단위”를 혼동하면 CPU 사용률·락·스케줄 지연 분석이 엇나갈 수 있습니다.
PID와 PID 네임스페이스
PID는 정수 식별자이고, 부팅 직후 PID 1이 먼저 있고, 이후 자식이 붙습니다. PID 네임스페이스(컨테이너) 안에서는 같은 숫자가 호스트와 다른 엔티티를 가리킬 수 있으니, 사고 나면 “어느 네임스페이스의 PID인가”를 같이 봅니다.
생명주기(요약) — 뒤로 이어지는 이야기
-
생성:
fork/exec또는clone. -
실행: 스케줄러에 의해 런큐·CPU를 오감.
-
대기/차단: I/O·락 등으로
S/D쪽. -
종료:
exit뒤 부모가wait할 때까지Z일 수 있음(위 1절). -
정리: 부모가
wait하거나, 고아·PID 1 경로로 최종 정리.
3. 프로세스 생성: fork()와 exec() 계열
fork: 주소 공간 복제와 CoW
fork()는 호출한 프로세스를 복제해 자식을 만듭니다. 전통적으로는 복제 비용이 컸으나, Copy-on-Write(CoW) 로 실제 물리 페이지 복제를 늦춥니다. 자식은 fork 반환값이 0, 부모는 자식 PID를 받습니다.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
/* 자식 */
_exit(0); /* 라이브러리 정리 없이 즉시 종료할 때 */
}
/* 부모 */
wait(NULL);
return 0;
}
실무 관점: 짧게 끝나는 자식을 대량으로 띄우면 PID 소진·fork 폭증이 납니다. 1절의 “20만”은 드문 숫자지만, 원리는 같습니다 — “짧은 자식을 실컷 만들면, 누가 치울지”가 설계에 없을 때 터집니다.
exec 계열: 같은 PID, 새 프로그램
fork 직후 execve를 호출하면 PID는 유지되고 코드·데이터가 갈아끼워집니다. exec가 성공하면 그 뒤 코드는 실행되지 않습니다.
vfork, clone
vfork는 역사적이고, clone은 무엇을 공유할지 플래그로 옵니다. 컨테이너·스레드에 많이 쓰입니다.
4. 프로세스 계층: 부모–자식, init과 systemd (그리고 제 입장)
부모–자식 트리, 세션, 프로세스 그룹
모든 프로세스는 PPID 를 가지고, pstree나 /proc/PID/status의 Ppid로 추적합니다. 세션·프로세스 그룹은 SIGINT·잡 제어·파이프라인과 엮여 있고, 데몬화 때 터미널에서 떼는 이야기로 이어집니다.
PID 1과 systemd — “사랑과 미움”
systemd는 사랑받는 사람도, 욕 먹는 사람도 둘 다 꽤 많습니다. 저의 선은 이렇습니다. 서버·배포판의 기본 init으로서 유닛 파일 하나에 의존성·재시작·리소스 한도·로그를 모으는 경험은, 쉘 스크립트·크론·수작업으로 같은 것을 10년 유지해본 사람에게는 너무나 실질적입니다. 반면 디버깅이 어렵다, 마법 같다, 유닛이 너무 많다는 비판도 이해합니다.
그래서 제 실무 룰은 냉정합니다. “systemd vs 그전이 뭐가 낫냐” 가 아니라, “PID 1·서비스 경계·자식 수거·시그널”을 유닗으로 명시했는가를 본다는 것입니다. PID 1이 좀비를 거두는 이유는 철학이 아니라 책임입니다. 1절의 좀비 이야기는 “부모·PID1·회수”의 삼각형이 흔들리면 같이 흔들립니다.
5. 프로세스 상태: R, S, D, T, Z (표가 아닌 “내러티브”)
ps의 STAT 한 글자는, 커널 TASK_* 를 요리한 라벨입니다. 표로 외우지 말고, “태스크가 무엇을 기다리느냐”로 읽는 편이 낫습니다.
R (running / runnable) — “CPU를 쓰거나, 곧 쓰라고 런큐에 올라가 있는” 쪽입니다. 멀티코어에선 여럿이 R일 수 있습니다.
S (interruptible sleep) — “대기 중이지만, 시그널로 깨울 수 있음”이 핵심입니다. 소켓·futex·많은 블로킹이 여기에 붙습니다.
D (uninterruptible sleep) — “지금 끊으면 위험한 구간”에 있을 수 있습니다. I/O·특수 드라이버 경로에서 길어지면 응답이 멈춘 듯 보이고, kill -9도 “당장”처럼 안 먹는 느낌이 날 수 있습니다. 이때 CPU보다 스토리지·NFS·블록을 의심합니다.
T (stopped, traced) — SIGSTOP·ptrace 쪽. 스케줄 후보에서 잠시 빠집니다.
Z (zombie) — “이미 exit했는데, 부모가 wait을 안 해서 기록만 남은 것” (1절).
상태 전이를 한 흐름으로
태스크가 CPU에서 밀려나면, “지금 뭐 기다리지?”로 갈립니다. 인터럽트 가능한 대기면
대개 `S`이고, 커널이 “여기서 시그널로 깨면 곤란하다”고 택한 경로는 `D` 쪽이 될 수
있습니다. I/O·wake-up·스케줄 인/아웃이 이 그림을 오가고, `Z` 는 “실행”이 아니라
**회수 직전의 잔상**에 가깝습니다.
RUNNING에서 벗어날 때 인터럽트 가능/불가능이 갈리는 지점이 S/D 체감과 직결됩니다. 대기 큐(wait queue) 는 조건이 맞을 때 wake_up으로 깨웁니다. wake-up 누락·과다는 스케줄 오버헤드·기아로 이어질 수 있습니다.
6. 프로세스 종료: exit(), _exit(), atexit()
exit() (라이브러리)
exit()는 atexit 핸들러·stdio 플러시 뒤 _exit로 갑니다.
_exit() / _Exit()
fork 직후 부모·자식 IO가 섞이지 않게 쓰는 즉시 커널 경로입니다.
atexit()
전역·소멸 순서에 얽히면 디버깅이 지옥이 될 수 있어, 핵심 정리는 명시 shutdown이 낫다고 봅니다.
7. 시그널: SIGTERM, SIGKILL, 처리 모델
시그널은 비동기 이벤트입니다. 자주 쓰는 것만 이야기 흐름으로 묶겠습니다. SIGTERM 은 “정리하고 내려가 달라”는 악수에 가깝고, 먼저 보내는 편이 관례입니다. SIGKILL 은 핸들러 없이 커널이 강제로 끊습니다. SIGSTOP/SIGCONT 는 정지·재개이고, SIGCHLD 는 자식 상태가 바뀌었을 때 — 1절의 회수와 짝입니다.
SIGKILL 오해는 D 잠에서: “죽이는” 게 아니라 스케줄/블로킹 경로를 빠져나와야 보이는 종료가 됩니다.
핸들러와 비동기 시그널 안전
핸들러에서 malloc·printf는 사고를 부릅니다. 보통 플래그만 세우고, 메인 루프에서 정리합니다.
#include <signal.h>
#include <stdatomic.h>
#include <unistd.h>
static atomic_int stop_flag;
static void onterm(int signo) {
(void)signo;
atomic_store(&stop_flag, 1);
}
int main(void) {
struct sigaction sa;
sa.sa_handler = onterm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGTERM, &sa, NULL);
while (!atomic_load(&stop_flag)) {
pause(); /* 실제론 epoll/작업 루프 */
}
return 0;
}
Kubernetes는 먼저 SIGTERM, grace 뒤 강제 흐름이 일반적입니다.
8. 프로세스 간 통신(IPC) 개요: 파이프·소켓·공유 메모리
왜 IPC가 필요한가
프로세스는 별도 가상 주소 공간이므로, 주고받을 때는 파이프·소켓·공유 메모리 등이 필요합니다.
파이프(anonymous / named FIFO)
익명 파이프는 |·부모-자식 fd 상속에, FIFO 는 경로로 붙습니다. 바이트 스트림에 가깝습니다.
소켓(UNIX / TCP)
UNIX 도메인 소켓은 동일 호스트 서비스 간, TCP 는 네트워크. 둘 다 fd로 epoll과 맞습니다.
공유 메모리(shm)와 동기화
빠르지만 락 없으면 레이스입니다.
기타
System V IPC는 레거시에서 보이고, 최근은 RPC·잘 정의된 프로토콜 쪽이 많습니다.
9. 프로세스 우선순위: nice와 renice
nice는 CFS 상대 영향(대개 −20~19). renice는 기존 PID에.
# 새 프로세스에 nice 10
nice -n 10 ./batch_job
# PID 1234를 nice 5로(권한 필요할 수 있음)
renice -n 5 -p 1234
주의: I/O·네트워크 “우선”은 별도입니다. cgroup I/O·TC가 다른 글·절의 주제입니다.
10. 프로세스 스케줄링: CFS, 스케줄러, 선점
리눅스는 스케줄링 클래스를 쌓고, 일반은 CFS 입니다. vruntime은 장기 공정성에 가깝고, p99를 “약속”하진 않습니다. per-CPU 런큐·cfs_rq, NUMA·steal(가상화)·SCHED_FIFO/DEADLINE 은 9절·FAQ와 같이 읽으면 됩니다.
11. 프로세스 모니터링: ps, top, htop
ps
# 모든 프로세스, 트리, 사용자 정의 열
ps auxf
ps -eo pid,ppid,stat,pcpu,pmem,cmd --sort=-pcpu | head
aux: BSD 관용,-o: 스크립트,-L: 스레드(LWP).
top / htop
top은 주기 갱신, per-CPU·스레드(버전에 따라). cgroup 자체는 직접 안 보이므로 systemd-cgtop·eBPF·kube를 병행합니다.
보조
-
pidstat: CPU·CS·I/O 시계열. -
vmstat/iostat: 총괄. -
perf/eBPFrunqlat: 스케줄 대기.
12. /proc 파일시스템: “경로 = 차트”로 읽기
/proc 은 가상 파일로 커널이 상태를 냅니다. 테이블로 외우지 말고, “무엇을 보려면 어디를 연다” 를 한 환자 차트처럼 잡는 편이 낫습니다.
“이 프로세스 뭐냐” — cmdline·status·environ(권한)으로 이름·UID·State·PPid를 먼저 봅니다. “뭘 잡고 있냐” — fd 심볼릭. “얼마나 쓰냐” — limits·io(환경), 메모리는 smaps_rollup 등. “컨테이너/한도” — cgroup이 지도입니다. “전체” 쪽에선 loadavg, meminfo, (켜졌다면) PSI·pid_max가 “왜 fork가 실패?” 같은 질문에 답합니다.
트러블슈팅을 “살아 있나 / 무엇을 붙잡나 / 어떤 한도냐”로 좁힐 때, 위 순서를 응급실 초진 같이 쓰면 표 없이도 머릿속이 정리됩니다.
13. 프로세스 제어: kill, pkill, killall
kill 1234
kill -TERM 1234
kill -9 1234 # SIGKILL
kill은 “죽이기” 전용이 아니라 시그널이며, 운영에선 systemctl stop·컨테이너 stop이 먼저이고, pkill은 패턴 실수에 유의합니다(pgrep -af로 먼저).
14. 데몬 프로세스: 백그라운드·systemd 서비스
데몬은 터미널에 안 묶인 장기 프로세스입니다. nohup·& 는 임시에 가깝고, 프로덕션은 유닛에 의존성·재시작·로그를 모읍니다. 4절의 제 입장과 맞닿아 있습니다. k8s 는 런타임·probe가 비슷한 역할을 합니다.
15. 실전 예제: 프로세스 관리 스크립트
#!/usr/bin/env bash
set -euo pipefail
PATTERN='myworker'
GRACE=10
mapfile -t PIDS < <(pgrep -f -- "${PATTERN}" || true)
if ((${#PIDS[@]}==0)); then
echo "no processes matched: $PATTERN"
exit 0
fi
printf 'matched PIDs: %s\n' "${PIDS[*]}"
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo "SIGTERM $pid"
kill -TERM "$pid" || true
fi
done
for _ in $(seq 1 "$GRACE"); do
still=()
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then still+=("$pid"); fi
done
((${#still[@]}==0)) && break
sleep 1
done
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo "SIGKILL $pid (still alive)"
kill -KILL "$pid" || true
fi
done
주의: 무차별 pkill·패턴은 사고입니다. 가능한 곳에선 systemctl이 낫습니다.
C그룹 CPU 조회(환경에 따라)
grep . /proc/self/cgroup
# cat /sys/fs/cgroup/$(grep -oP '^0::\K.+' /proc/self/cgroup)/cpu.stat
16. 성능 최적화: CPU 친화도·cgroups
taskset은 CPU 집합에 묶어 캐시와 병목을 동시에 바꿉니다. 측정 없이 “고정”만 하지 마세요. cgroup cpu.max/cpu.weight·상위 한도는 9절·FAQ와 연결됩니다. isolation·nohz는 최후; 먼저 runq·throttle·steal을 봅니다.
17. 디버깅 일지 — “표” 대신, 그날의 순서
트러블슈팅을 “증상-원인-조치” 표로 박제해두면, 글같이 깔끔하지만 현장은 순서가 틀어집니다. 그래서 대신 날짜를 찍는 일지 형태로 남깁니다(아래는 합성한 예이지만, 실제로 이렇게 씁니다).
-
3주차 수요일 02:14 — “응답만 끔찍하다, 로드는 낮다.” cgroup
cpu.max·cpu.statthrottling, eBPFrunqlat, steal을 같은 화면에 올렸다.top의 “CPU %”만 보면 틀릴 수밖에 없는 밤. -
다음날 10:22 — “이 프로세스만 멈춘 듯.”
ps의 State·D 를 보고, 디스크·NFS·블록 I/O 쪽으로 꺾었다.kill -9가 “안 먹는다”는 말이 나올 때, I/O를 의심. -
다른 주 월요일 — “스레드만 폭증, CPU는 뚫려 있다.”
pidstat -w·perf로 CS·락을 숫자로. 감(感)이 아니라 전환/초. -
컨테이너 — “노드는 멀쩡, 파드만 느리다.”
cpu.stat·request/limit. QOS. -
Z가 줄을 섰다 — 1절. 누가
wait하냐·PID1·핸들러.
이런 일지는 나중에 나에게 최빈값을 알려줍니다. “표의 행”이 아니라, 한 번씩 찍힌 로그입니다.
cgroups·systemd·Kubernetes와의 접점(요약)
cpu.weight·cpu.max 는 CFS 한도와 맞물리고, 상위 cgroup에서 죽은 한도는 하위가 못 넘깁니다. k8s limit·throttle·“노드는 괜찮고 파드만”은 여기 한 줄로 요약됩니다.
보충: 태스크 상태와 대기 큐(핵심 복습)
-
인터럽트 가능
S: 대부분의 블로킹·futex·소켓. -
인터럽트 불가
D: 일부 I/O/드라이버 — 스토리지·NFS. -
정지/추적
T:SIGSTOP,ptrace. -
좀비
Z:wait·부모·PID 1(1절). -
wake_up·wait queue.
정리
리눅스의 프로세스는 태스크의 집합이고, 생명주기는 fork·exit·wait·시그널이 한 줄이 아니라 고리로 맞물립니다. 좀비 20만은 드문 숫자지만, 같은 고리가 느슨하면 슬쩍 터집니다. CPU는 CFS·vruntime이지만, 지연은 I/O·NUMA·cgroup·가상화와 공범입니다. ps·/proc·systemctl·cgroup을 하나의 사고로 엮을 때, “느리다/죽지 않는다/좀비”가 원인까지 이어집니다.
이 글의 선행/관련 흐름은 Linux 완전 가이드를 참고하시고, 메모리는 Linux 메모리·가상 메모리 심화에서 이어집니다. Go 완전 가이드·Bun 런타임은 다른 런타임과의 대비에 도움이 됩니다.
자주 묻는 질문 (요약)
프론트매터 faq에 D 상태, CFS vruntime, 컨테이너 CPU가 있습니다. 이어서 짧게.
-
Q.
ps의D는 디스크만 의미하나요?A.
D는 uninterruptible sleep일 뿐, 스택에 따라 다양합니다. -
Q. 서비스는 왜
kill대신systemctl로 종료하나요?A. 유닛·의존성·cgroup·로그를 묶은 한 번의 종료 시퀀스가 낫기 때문입니다.
-
Q.
nice를 크게 올리면 I/O가 빨라지나요?A. CPU 쪽 상대에 가깝고, I/O는 별도입니다.
같이 보면 좋은 글 (내부 링크)
이 글에서 다루는 키워드(관련 검색어)
Linux, 프로세스, PID, fork, exec, wait, 좀비·고아, 시그널, IPC, CFS, nice, ps/top/htop, /proc, kill, systemd, cgroup, NUMA, SRE 등.