본문으로 건너뛰기
Previous
Next
Linux 프로세스·스케줄러 심화 — 태스크 상태 머신, CFS·런큐, NUMA와 프로덕션 튜닝

Linux 프로세스·스케줄러 심화 — 태스크 상태 머신, CFS·런큐, NUMA와 프로덕션 튜닝

Linux 프로세스·스케줄러 심화 — 태스크 상태 머신, CFS·런큐, NUMA와 프로덕션 튜닝

이 글의 핵심

리눅스에서 “프로세스”는 스케줄링 단위인 태스크(task_struct)의 집합이고, CPU는 CFS 등 스케줄링 클래스 규칙에 따라 런큐에서 고릅니다. 상태 머신·스케줄러·cgroup·NUMA를 한데 엮어야 지연·기아·스틸 시간 같은 운영 증상을 원인까지 설명할 수 있습니다.

들어가며 — Linux 시리즈 #05

이 글은 Linux 시리즈 다섯 번째로, 좀비·고아와 실패한 수거부터 짚고, 그다음에 사용자 공간의 “프로세스”와 커널 태스크(task)·스케줄러·cgroup·관측을 잇습니다. 이유는 단순합니다. 문서·튜토리얼이 흔히 forkR/S/D를 먼저 깔고 가지만, 현장에서 밤을 새게 하는 건 PID 소진, Z 누적, PID 1이 자식을 안 거두는 케이스가 많기 때문입니다.

이후 (1) 프로세스 개요와 PID, (2) fork/exec, (3) 부모–자식·init/systemd제가 systemd를 대하는 방식, (4) psR/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로 가져갈 때까지 유지합니다. 그 전까지 psZ 를 붙입니다. 원인은 한 줄로 말해 부모가 회수의 책임을 안 지는 설계/버그입니다.

처리: 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인가”를 같이 봅니다.

생명주기(요약) — 뒤로 이어지는 이야기

  1. 생성: fork/exec 또는 clone.

  2. 실행: 스케줄러에 의해 런큐·CPU를 오감.

  3. 대기/차단: I/O·락 등으로 S/D 쪽.

  4. 종료: exit 뒤 부모가 wait할 때까지 Z일 수 있음(위 1절).

  5. 정리: 부모가 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. 프로세스 계층: 부모–자식, initsystemd (그리고 제 입장)

부모–자식 트리, 세션, 프로세스 그룹

모든 프로세스는 PPID 를 가지고, pstree/proc/PID/statusPpid로 추적합니다. 세션·프로세스 그룹SIGINT·잡 제어·파이프라인과 엮여 있고, 데몬화 때 터미널에서 떼는 이야기로 이어집니다.

PID 1과 systemd — “사랑과 미움”

systemd사랑받는 사람도, 먹는 사람도 둘 다 꽤 많습니다. 저의 선은 이렇습니다. 서버·배포판의 기본 init으로서 유닛 파일 하나에 의존성·재시작·리소스 한도·로그를 모으는 경험은, 쉘 스크립트·크론·수작업으로 같은 것을 10년 유지해본 사람에게는 너무나 실질적입니다. 반면 디버깅이 어렵다, 마법 같다, 유닛이 너무 많다는 비판도 이해합니다.

그래서 제 실무 룰은 냉정합니다. “systemd vs 그전이 뭐가 낫냐” 가 아니라, “PID 1·서비스 경계·자식 수거·시그널”을 유닗으로 명시했는가를 본다는 것입니다. PID 1이 좀비를 거두는 이유는 철학이 아니라 책임입니다. 1절의 좀비 이야기는 “부모·PID1·회수”의 삼각형이 흔들리면 같이 흔들립니다.


5. 프로세스 상태: R, S, D, T, Z (표가 아닌 “내러티브”)

psSTAT 한 글자는, 커널 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 는 네트워크. 둘 다 fdepoll과 맞습니다.

공유 메모리(shm)와 동기화

빠르지만 없으면 레이스입니다.

기타

System V IPC는 레거시에서 보이고, 최근은 RPC·잘 정의된 프로토콜 쪽이 많습니다.


9. 프로세스 우선순위: nicerenice

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/eBPF runqlat: 스케줄 대기.


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

tasksetCPU 집합에 묶어 캐시병목을 동시에 바꿉니다. 측정 없이 “고정”만 하지 마세요. cgroup cpu.max/cpu.weight·상위 한도9절·FAQ와 연결됩니다. isolation·nohz최후; 먼저 runq·throttle·steal을 봅니다.


17. 디버깅 일지 — “표” 대신, 그날의 순서

트러블슈팅을 “증상-원인-조치” 표로 박제해두면, 글같이 깔끔하지만 현장순서가 틀어집니다. 그래서 대신 날짜를 찍는 일지 형태로 남깁니다(아래는 합성한 예이지만, 실제로 이렇게 씁니다).

  • 3주차 수요일 02:14 — “응답만 끔찍하다, 로드는 낮다.” cgroup cpu.max·cpu.stat throttling, eBPF runqlat, steal같은 화면에 올렸다. top의 “CPU %”만 보면 틀릴 수밖에 없는 밤.

  • 다음날 10:22 — “이 프로세스만 멈춘 듯.” psState·D 를 보고, 디스크·NFS·블록 I/O 쪽으로 꺾었다. kill -9가 “안 먹는다”는 말이 나올 때, I/O를 의심.

  • 다른 주 월요일 — “스레드만 폭증, CPU는 뚫려 있다.” pidstat -w·perfCS·락숫자로. 감(感)이 아니라 전환/초.

  • 컨테이너 — “노드는 멀쩡, 파드만 느리다.” 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 런타임은 다른 런타임과의 대비에 도움이 됩니다.


자주 묻는 질문 (요약)

프론트매터 faqD 상태, CFS vruntime, 컨테이너 CPU가 있습니다. 이어서 짧게.

  • Q. psD는 디스크만 의미하나요?

    A. Duninterruptible 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 등.