수백 개 프로그램이 코어 몇 개를 나눠 쓰는 법 — 프로세스와 스케줄러

컴퓨터를 켜면 웹 브라우저, 셸, SSH 서버, 시스템 모니터까지 수십 ~ 수백 개 프로그램이 "동시에" 돌아간다. 하지만 CPU 코어는 보통 4 ~ 16개뿐이다. 어떻게 가능할까?

정답은 CPU를 매우 빠르게 번갈아 쓰기 때문이다. 1초에 수천 번 문맥(context)을 바꿔가며 각 프로그램에 조금씩 CPU를 나눠준다. 사람 눈엔 "동시에" 보이지만, 실은 빠른 교대 실행일 뿐이다. 이 교대를 가능하게 하는 두 추상 — 프로세스(독립된 메모리 방을 가진 실행 단위)와 스케줄러(수백 개 중 지금 누구를 CPU에 올릴지 고르는 커널의 판관) — 가 이 글의 주제다.

이 둘을 모르면 "프로세스가 응답 없다", "좀비가 쌓인다", "CPU를 하나만 잡아먹는다" 같은 문제를 진단할 수 없다.

프로세스와 스레드 — 격리의 단위가 갈린다

  프로세스 스레드
비유 독립된 단독 주택 — 자기 마당·부엌·창고 쉐어하우스의 방 — 거실·부엌·창고는 공유, 방만 내 것
메모리 자기만의 가상 주소 공간(남이 못 건드림) 같은 프로세스의 주소 공간을 서로 공유
안전 하나가 죽어도 다른 건 안전 한 스레드가 공유 메모리를 망치면 형제도 사고
만들기 비용 큼(주소 공간 복사) 작음(스택만 추가)

이 비유는 "메모리 격리"만 잡는다. 스레드의 진짜 핵심 — 스케줄링 단위가 커널의 task라는 것 — 은 비유 너머에 있다.

정확한 사실을 보자. 리눅스 커널은 프로세스와 스레드를 모두 같은 task_struct 구조체로 다룬다. 차이는 주소 공간을 공유하느냐뿐이다.

  • 프로세스: 자기 task_struct + 자기 mm_struct(주소 공간)
  • 스레드: 자기 task_struct + 형제의 mm_struct를 공유

사용자 입장에선 프로세스/스레드 구분. 커널 입장에선 둘 다 "task"다. 그래서 리눅스에서 스레드를 "lightweight process"라 부르기도 한다.

생성 비용의 체감: fork()로 프로세스를 만드는 건 ~ 수백 μs(주소 공간 복사, 실제론 COW로 지연), pthread_create()로 스레드를 만드는 건 ~ 수십 μs(스택만). 스레드가 한 자릿수 빠르다. 그래서 수천~

수만 동시 처리(웹 서버)에선 스레드나 이벤트 방식을 쓴다. 단, 격리·안전이 필요하면 프로세스가 낫다 — 상황에 따라. (수치는 규모의 직관용이지 정확값은 시스템마다 다름.)

새 프로그램은 fork와 exec, 두 단계로만 띄운다

여기서 많이 틀린다. "bash가 ls를 실행한다 = bash가 ls 코드를 직접 부른다"라고 생각하기 쉽다. 아니다. 실제로는:

  1. bash가 자기 복사본을 하나 더 만들고(fork)
  2. 그 복사본이 자기 자신을 ls로 덮어쓴다(exec)
  3. ls가 끝나면 복사본은 사라진다(exit)
  4. bash가 그 종료 코드를 회수한다(wait)

원래의 bash는 그대로 살아있다. 이것이 리눅스에서 새 프로그램을 띄우는 유일한 방법이다.

sequenceDiagram
    participant B as bash (PID 100)
    participant K as 커널
    participant L as ls (PID 101)
    B->>K: fork()
    K-->>B: 새 PID 101 생성 (bash 복사본)
    K-->>L: PID 101 = bash 복제, 아직 bash 코드 실행 중
    L->>K: exec("ls")
    K-->>L: PID 101의 메모리를 ls로 덮어씀 (PID는 그대로 101)
    L->>L: ls 코드 실행, 결과 출력
    L->>K: exit(0)
    K-->>B: SIGCHLD + 종료코드 대기
    B->>K: wait()
    K-->>B: 종료코드 0 회수 → PID 101 소멸
단계 시스템 콜 무슨 일 PID 변화
복제 fork() 부모의 거의 완전한 복사본 생성(COW) 새 PID
교체 exec() 현재 프로세스의 코드/메모리를 새 프로그램으로 교체 PID 그대로(중요)
종료 exit() 프로세스 종료, 종료코드를 커널에 저장
회수 wait() 부모가 자식의 종료코드 회수 사라짐

왜 fork와 exec을 분리했는가

이 분리는 Unix의 천재적 설계다. 두 단계 사이에 부모는 자식의 환경(파일 기술자, 환경변수, 권한)을 자유롭게 조작할 수 있다. 셸의 리다이렉션(> file), 파이프(|), 백그라운드(&)가 전부 이 "사이"에서 일어난다. 단일 호출이었다면 이 유연성이 불가능했다.

좀비(Z) — 회수되지 않은 종료자

자식이 exit() 했는데 부모가 wait()을 안 하면? 자식은 종료했지만 사라지지 못한 상태로 남는다. 이게 좀비다.

흔한 오해: "좀비는 시스템을 위협하는 괴물" — 보통 아니다. 좀비는 메모리를 거의 안 쓴다(커널에 종료코드 한 줄만 보관). 진짜 문제는 PID를 소모한다는 것. PID 한계(보통 32768~약 400만 개)에 도달하면 새 프로세스를 못 만든다.

좀비 해법: 좀비를 직접 죽일 수 없다(SIGKILL도 안 통함). 부모가 wait()하게 하거나, 부모를 죽여서 init(systemd, PID 1)이 회수하게 해야 한다.

프로세스 상태 다섯 가지 — ps 출력이 말하는 것

flowchart LR
    R((R<br/>실행/실행대기)) -->|이벤트 대기| S((S<br/>수면))
    S -->|이벤트 도착| R
    R -->|시그널/트레이스| T((T<br/>정지))
    T -->|SIGCONT| R
    R -->|exit| Z((Z<br/>좀비))
    Z -->|부모 wait| X((소멸))
    S -.디스크 I/O 대기.-> D((D<br/>불가중 수면))
    D -.I/O 완료.-> R
기호 이름 한 줄 시그널로 빠져나올 수 있나
R running/runnable CPU에서 돌고 있거나 돌 차례를 기다리는 중
S interruptible sleep 이벤트(타이머, I/O 완료) 기다리는 수면 O(대부분 시그널)
D uninterruptible sleep 디스크 I/O 등 강제 수면 X(SIGKILL도 안 통함)
T stopped 시그널(Ctrl+Z)이나 디버거로 멈춤 O(SIGCONT)
Z zombie 종료했지만 부모가 아직 회수 안 함 X(직접 처리 불가)

D 상태의 함정

D 상태 프로세스가 있으면 root도 kill -9로 못 죽인다. D는 커널이 디스크(또는 NFS) 응답을 기다리는 동안 프로세스를 "절대 깨우지 마" 모드로 놓은 것이다. 보통 디스크가 느리면 잠깐 D → 해결이지만, NFS 서버가 죽었는데 마운트가 안 풀리면 D가 무한 지속 → 서버 재부팅 외엔 답이 없다. D가 오래 지속되면 저장소 문제를 의심하라.

시그널 — 비동기 알림과 "왜 SIGTERM을 먼저 써야 하나"

시그널은 커널이나 다른 프로세스가 프로세스에 던지는 작은 비동기 알림이다. 프로세스는 받으면 하던 일을 멈추고 정해진 동작을 수행한다.

시그널 번호 보통 어떻게 쓰나 무시 가능?
SIGTERM 15 "정리하고 종료해라"(kill의 기본) O
SIGINT 2 "중단해라"(Ctrl+C) O
SIGHUP 1 "설정 다시 읽어라"(데몬 관례) O
SIGKILL 9 "당장 죽어라"(강제, 최후 수단) X(무조건 죽음)
SIGSTOP 19 "멈춰라"(Ctrl+Z와 유사) X(무조건 멈춤)
SIGCONT 18 "다시 돌아가라"
SIGCHLD 17 "자식이 죽었어"(부모에게) O(보통 무시)

위험한 습관: "프로세스가 응답 없으면 kill -9부터" — SIGKILL(9)은 프로세스에게 정리 기회를 안 준다. 열어둔 파일이 flush 안 되어 데이터가 깨지거나, 잠금(lock)이 안 풀려 다른 프로세스가 멈출 수 있다.

올바른 순서: kill 1234(SIGTERM) → 30초 대기 → 그래도 안 죽으면 kill -9 1234. 정리할 기회를 먼저 줘라.

절대 무시 불가능한 시그널은 SIGKILL(9)과 SIGSTOP(19)뿐. 그래서 프로세스가 미친 듯이 돌 때 최후의 수단으로 쓴다. 단, D 상태 프로세스에는 SIGKILL도 대기 행렬에 들어갈 뿐 즉시 효과가 없다.

nice — 우선순위인데 왜 반대일까

모든 프로세스가 같은 우선권이면 급한 것과 안 급한 것을 구분 못 한다. 누구를 조금 더 자주 CPU에 올릴 것인가? 여기서 nice가 우선순위를 조절한다.

여기서 꼭 기억할 것: nice 값은 "작을수록 우선"이다. 일반적 직관(클수록 좋다)과 반대다.

nice 의미
-20 최우선(root만 설정 가능)
0 기본
+19 최하위

일반 사용자는 nice를 높이기만 가능(우선순위 낮추기만). 자기 프로세스를 더 우선시하려면 root 권한이 필요하다. 범위는 -20 ~ +19.

왜 반대일까? "nice"라는 이름 자체가 "다른 사람에게 양보(nice)한다"에서 왔다. nice 값이 높다 = 남에게 더 양보한다 = 내 우선순위가 낮아진다. 어원을 알면 헷갈리지 않는다.

커널은 누구에게 CPU를 줄까 — EEVDF가 CFS 자리를 빼앗은 이유

수백 개 프로세스가 코어 몇 개를 두고 줄을 선다. "공평"이란 대체 무엇일까. 2004년부터 커널은 CFS로 답했다 — "가장 오래 기다린 순"을 고른다. 그런데 2023년(kernel 6.6) 그 자리를 EEVDF가 빼앗았다. Rocky 10(커널 6.12)은 EEVDF를 쓴다. 왜 바뀌었을까.

EEVDF — 짧게 끝나는 일을 먼저

EEVDF의 핵심은 한 줄로 간다: 공평함은 유지하되, "짧게 끝나는 일(응답이 중요한 일)"을 더 빨리 처리한다. CFS는 "누가 더 기다렸나"만 봤다면, EEVDF는 "누가 더 기다렸나 + 이 작업이 얼마나 짧나"를 함께 본다. 짧게 끝날 작업의 deadline이 더 빠르게 잡히고, 그게 먼저 실행된다.

작동은 세 단계(굳이 외우지 않아도 된다):

  1. 각 작업의 lag(자기 몫 대비 덜/더 받은 정도) 계산
  2. lag ≥ 0인 작업(eligible, 자기 차례 됨)만 후보
  3. 후보 중 deadline이 가장 빠른 작업 선택

EEVDF는 비유로 가르치기 어렵다(은행 줄·급한 사람 같은 비유는 정반대 직관을 심는다). 수치와 작동으로 이해하라.

실용적 의미: 데스크탑/대화형 작업(마우스 클릭, 오디오, 터미널 응답)이 더 빠르게 반응한다. 일반 서버 워크로드는 체감이 어렵다.

역사 박스 — CFS는 어떻게 작동했고, 왜 시대에 뒤처졌나

CFS(Completely Fair Scheduler)의 목표는 단순했다: "모든 프로세스에게 CPU를 공평하게 나눠준다." 각 프로세스마다 가상 시계(vruntime)를 하나씩 들려준다. CPU를 쓴 만큼 이 시계가 앞으로 간다. 스케줄러는 시계가 가장 뒤처진 프로세스를 다음으로 실행한다. 그러면 자연스럽게 모두의 시계가 비슷하게 맞춰진다 → 공평.

프로세스 A: vruntime = 100   ← 가장 작음, 다음 차례
프로세스 B: vruntime = 250
프로세스 C: vruntime = 300
  • nice가 낮으면(-20) 시계가 느리게 감 → 더 자주 차례가 옴 → 우선
  • nice가 높으면(+19) 시계가 빠르게 감 → 덜 차례가 옴 → 양보

CFS는 rbtree(레드블랙트리)로 vruntime 순 정렬 → 가장 작은 것을 O(1)에 선택했다. 매우 효율적이었다.

그런데 데스크탑·대화형 워크로드의 요구가 변했다. "공평"만으로는 터미널 입력 한 번에 반응이 느릴 수 있었다. EEVDF는 공평성(vruntime/lag)은 계승하되, 짧은 작업 우선(deadline)을 더해 대화형 반응을 끌어올렸다. 한 줄 요약: CFS = "가장 오래 기다린 순", EEVDF = "차례가 된 것 중 가장 짧게 끝날 순".

체감 수치 — 왜 이 복잡함이 필요한가

이 모든 복잡함(프로세스 분리, 스케줄러, 컨텍스트 스위치)이 왜 필요한지 수치로 체감하자.

연산 대략 지연 비고
CPU 레지스터 연산 ~1 ns  
L1 캐시 접근 ~0.5 ns  
메인 메모리 접근 ~100 ns L1의 약 200배
컨텍스트 스위치 ~3-5 μs (3000-5000 ns) 캐시를 어느 정도 무효화
SSD 4KB 읽기 ~150 μs 메모리의 1500배
디스크 탐색 ~10 ms (10,000,000 ns) 메모리의 10만 배

출처: Jeff Dean/Peter Norvig "Latency Numbers Every Programmer Should Know" — 규모의 직관용이지 정확값은 시스템마다 다름. 접근 2026-07-09.

의미: 컨텍스트 스위치 한 번 = 메모리 접근 수십 번 비용. 그래서 같은 주소 공간을 공유해 캐시를 유지하는 스레드가 프로세스 스위치보다 싸다. 디스크 I/O는 메모리의 10만 배 — 그래서 디스크 접근을 최소화하려고 페이지 캐시와 스와핑이 존재한다(04장).

Rocky 10에서 직접 확인하기

미검증(출처 인용). Rocky 10 VM에서 실행 권장.

# Rocky 10
ps -ef --forest | head

확인할 것: PID 1이 systemd이고, 모든 프로세스가 systemd의 자손이라는 사실. fork의 연쇄가 보인다.

UID   PID  PPID  ... CMD
root    1     0  ... /usr/lib/systemd/systemd --system
root    2     0  ... [kthreadd]
...
ps -eo pid,ppid,stat,nice,comm --sort=-stat | head -15

확인할 것: STAT의 첫 글자(R/S/D/T/Z)가 상태, 두 번째 글자(s=세션 리더, +=포그라운드)는 부가 정보.

# Rocky 10
bash -c 'echo "내 PID: $$"; exec echo "exec 후 같은 PID: $$"'

확인할 것: 두 PID가 같다. "exec은 PID를 바꾸지 않는다"를 눈으로 확인.

# Rocky 10 (권장 순서)
kill 1234           # SIGTERM - 정리 기회 부여
# 30초 대기 후에도 살아있으면
kill -9 1234        # SIGKILL - 강제
# Rocky 10
nice -n 10 ./heavy_job &     # 낮은 우선순위로 백그라운드
sudo renice -n -5 -p 1234    # 실행 중인 프로세스 우선순위 높이기(root)
ps aux | awk '$8 ~ /^Z/'

좀비가 보이면 부모를 찾아라: ps -o ppid= -p <좀비PID>. 부모를 재시작하거나 죽여야 회수된다.

uname -r   # 6.6+ = EEVDF, 4.x = CFS

Rocky 10(6.12)은 EEVDF. 구형 자료의 "CFS" 설명은 역사적 배경으로 읽어라.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"nice가 높으면 더 우선이다" 반대. 낮을수록(-20 방향) 우선
"프로세스가 안 죽으면 kill -9부터" 위험. SIGTERM(15) 먼저, 정리 기회 줄 것
"좀비는 위험한 괴물" 보통 아님. 메모리 거의 안 씀. PID 소모가 유일한 문제
"좀비를 kill -9로 없앤다" 불가능. 부모를 wait하게 하거나 부모를 죽여야
"D 상태는 kill -9로 해결" 안 됨. 디스크/NFS 문제 해결이 근본
"bash가 ls를 직접 부른다" 아니다. bash가 fork하고 그 자식이 exec으로 ls가 됨
"스레드는 프로세스보다 무조건 좋다" 상황에 따라. 격리·안전이 필요하면 프로세스가 나음
"모든 CPU 코어를 100% 쓰면 빠르다" 컨텍스트 스위치 비용이 크면 병렬이 역효과
"Rocky 10도 CFS를 쓴다" 아니다. EEVDF(kernel 6.6+). CFS는 구버전(4.x) 이야기

요약 — 이 글의 결론

  • 프로세스 = 실행 중인 프로그램 + 독립된 메모리 공간. 스레드 = 같은 주소 공간을 공유하는 실행 흐름. 커널은 둘 다 task_struct로 본다.
  • 새 프로그램 실행 = fork(복제) + exec(교체, PID 유지) + exit + wait. 이 분리 덕에 리다이렉션·파이프가 가능하다.
  • 상태 5종: R/S/D/T/Z. D(디스크 대기)와 Z(회수 안 된 종료자)는 시그널로 직접 해결 불가 — 근본 원인을 봐야 한다.
  • 시그널: SIGKILL(9), SIGSTOP(19)만 무시 불가. 나머지는 처리 가능. "응답 없다"고 해서 바로 -9 금지, TERM 먼저.
  • nice: -20~19, 낮을수록 우선. 일반 사용자는 높이기만(양보만) 가능.
  • 스케줄러: Rocky 10은 EEVDF — 공평함은 유지하되 짧게 끝나는 일(대화형)을 우선. CFS(vruntime 공평)는 구버전 이야기이자 EEVDF가 계승한 전신.

생각해 볼 문제

  1. bash가 ls를 실행할 때 일어나는 일을 fork/exec/exit/wait로 설명하라.
  2. PID 100의 프로세스가 exec()하면 PID는 어떻게 되는가? 왜?
  3. SIGTERM과 SIGKILL의 차이, 그리고 "왜 TERM을 먼저 써야 하는가"를 설명하라.
  4. nice 값 -20, 0, +19 중 가장 우선인 것은? 왜 반대인가?
  5. 좀비 프로세스를 직접 kill할 수 있는가? 없다면 어떻게 해결하는가?
  6. D 상태 프로세스에 SIGKILL을 보내면 어떻게 되는가?
  7. CFS가 "공평"을 달성한 방식(vruntime)과 EEVDF가 거기에 더한 것을 비교하라.

참고

'Tech Artifacts > Linux' 카테고리의 다른 글

Linux - 06. io device  (0) 2026.07.09
Linux - 05. filesystem  (0) 2026.07.09
Linux - 04. memory  (0) 2026.07.09
Linux - 02. boot systemd  (0) 2026.07.09
Linux - 01. overview  (0) 2026.07.08