수백 개 프로그램이 코어 몇 개를 나눠 쓰는 법 — 프로세스와 스케줄러
컴퓨터를 켜면 웹 브라우저, 셸, 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 코드를 직접 부른다"라고 생각하기 쉽다. 아니다. 실제로는:
- bash가 자기 복사본을 하나 더 만들고(fork)
- 그 복사본이 자기 자신을
ls로 덮어쓴다(exec) ls가 끝나면 복사본은 사라진다(exit)- 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이 더 빠르게 잡히고, 그게 먼저 실행된다.
작동은 세 단계(굳이 외우지 않아도 된다):
- 각 작업의 lag(자기 몫 대비 덜/더 받은 정도) 계산
- lag ≥ 0인 작업(eligible, 자기 차례 됨)만 후보
- 후보 중 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가 계승한 전신.
생각해 볼 문제
- bash가
ls를 실행할 때 일어나는 일을 fork/exec/exit/wait로 설명하라. - PID 100의 프로세스가
exec()하면 PID는 어떻게 되는가? 왜? - SIGTERM과 SIGKILL의 차이, 그리고 "왜 TERM을 먼저 써야 하는가"를 설명하라.
- nice 값 -20, 0, +19 중 가장 우선인 것은? 왜 반대인가?
- 좀비 프로세스를 직접 kill할 수 있는가? 없다면 어떻게 해결하는가?
- D 상태 프로세스에 SIGKILL을 보내면 어떻게 되는가?
- CFS가 "공평"을 달성한 방식(vruntime)과 EEVDF가 거기에 더한 것을 비교하라.
참고
- EEVDF Scheduler — Linux Kernel docs - 접근 2026-07-09
- An EEVDF CPU scheduler for Linux — LWN - 접근 2026-07-09 (CFS→EEVDF 전환 배경)
- CFS Documentation — Linux Kernel docs - 접근 2026-07-09 (역사적 전신)
- Latency Numbers — Google SRE - 접근 2026-07-09
- man page:
man ps,man kill,man signal(7),man nice,man fork(2),man execve(2)
'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 |
