들어가며: 4코어 서버에서 1000 프로세스가 돌아가는 법
간단한 관찰
htop을 실행해 보자. 수백 개의 프로세스가 보인다. 그러나 CPU는 4개, 혹은 8개뿐이다. 어떻게 이 모든 프로세스가 "동시에" 실행되는가?
답: 아무도 실제로 동시에 실행되지 않는다. Linux 커널은 수십 ms 단위로 CPU를 프로세스들 사이에 시간 분할한다. 너무 빠르게 전환되어 사용자에겐 마치 동시 실행처럼 보인다.
이 마법의 주인공이 Linux 스케줄러다. 언제, 어떤 프로세스에게 CPU를 줄지를 결정하는 커널의 심장.
좋은 스케줄러의 조건
스케줄러는 여러 상충하는 목표를 동시에 만족해야 한다:
- 공정성(Fairness): 모든 프로세스가 합리적 시간을 받음.
- 응답성(Responsiveness): 인터랙티브 작업은 빨리 반응.
- 처리량(Throughput): CPU가 놀지 않음.
- 에너지 효율: 배터리 오래.
- 확장성: 수백 코어에서 선형 성능.
- 우선순위 존중: 중요한 작업 우선.
- Real-time 지원: 데드라인 맞추기.
이 목표들은 때로 충돌한다. 공정성과 응답성, 처리량과 에너지 효율. 스케줄러의 역사는 이 균형을 찾는 긴 여정이다.
이 글에서 다룰 것
- 스케줄링의 기본: Time slice, preemption, context switch.
- O(1) 스케줄러: 2.4~2.6 초기.
- CFS: 2007년 혁명.
- EEVDF: 2023년 CFS의 후계자.
- Real-time 클래스: FIFO, RR, Deadline.
- Nice와 가중치: 우선순위 메커니즘.
- cgroup과 그룹 스케줄링.
- 실전 튜닝과 관찰.
1. 스케줄링의 기본 개념
Process States
Linux 프로세스는 여러 상태를 가진다:
- R (Running): 현재 CPU에서 실행 중 또는 실행 대기열에 있음.
- S (Sleeping): I/O 대기 등으로 잠자는 중 (interruptible).
- D (Uninterruptible sleep): 디스크 I/O 등으로 잠자는 중. 시그널도 못 깨움.
- T (Stopped): SIGSTOP 등으로 중단됨.
- Z (Zombie): 종료됐지만 부모가 수확 안 함.
스케줄러는 R 상태 프로세스들 사이에서 누구를 실행할지 결정한다.
Context Switch
한 프로세스를 중단하고 다른 프로세스로 전환하는 과정:
- 현재 프로세스의 레지스터, 스택 포인터 저장 (kernel stack).
- Address space 전환 (
mm_struct, CR3 레지스터). - TLB flush (필요 시).
- 새 프로세스의 레지스터 로드.
- 새 프로세스 실행.
비용: 수 μs (대략 1~10 μs). 너무 자주 하면 오버헤드 폭발. 너무 드물면 응답성 나쁨.
Time Slice (Quantum)
프로세스가 CPU를 한 번에 잡을 수 있는 최대 시간. 이 시간이 지나면 강제로 양보:
- 너무 짧으면: 너무 많은 context switch → 오버헤드.
- 너무 길면: 다른 프로세스가 기다림 → 응답성 나쁨.
전형적 값: 1~10 ms.
Preemption
프로세스가 time slice를 다 쓰기 전에도 강제로 중단될 수 있다. 다음 경우:
- Time slice 만료: 정해진 시간 다 씀.
- 더 높은 우선순위 프로세스가 ready: 현재 것 중단.
- 프로세스가 block: I/O 대기 등.
- 자발적 양보:
sched_yield().
Linux 2.6부터 kernel preemption (커널 코드 실행 중에도 preempt 가능) 지원.
Run Queue
각 CPU는 run queue를 가진다. Ready 상태 프로세스들의 리스트:
CPU 0 rq: [P1, P5, P9, P12, ...]
CPU 1 rq: [P2, P6, P10, ...]
CPU 2 rq: [P3, P7, ...]
CPU 3 rq: [P4, P8, ...]
각 CPU가 자기 큐에서 다음 프로세스를 선택. 이는 확장성 핵심이다 (전역 락 없음).
Load Balancing
프로세스를 CPU들에 균등 분산하는 작업. 주기적으로 실행:
- 한 CPU의 큐가 다른 것보다 훨씬 길면 프로세스 이동.
- NUMA 인식: 메모리 locality 고려.
- Cache affinity: 프로세스를 기존 CPU에 붙이려는 경향 (캐시 히트율).
2. O(1) 스케줄러: 2.6 초기의 답
배경
Linux 2.4의 스케줄러는 O(N)이었다. 프로세스 수가 증가하면 스케줄링 오버헤드도 증가. 수천 프로세스면 병목.
O(1)의 핵심 아이디어
2002년 Ingo Molnar가 O(1) 스케줄러를 도입했다. 상수 시간에 다음 프로세스를 선택.
구조:
- 각 CPU마다 140개의 우선순위 큐 (100 real-time + 40 일반).
- Bitmap으로 비어 있지 않은 큐 빠르게 추적.
__ffs()(find first set) 명령으로 O(1) 탐색.
인터랙티브 휴리스틱
O(1)의 복잡한 부분: 인터랙티브 프로세스 감지. "사용자와 상호작용하는 프로세스는 우선순위를 올려주자"는 의도.
- Sleep 시간 측정.
- Sleep이 많으면 인터랙티브로 판단 → bonus.
- 계산 집약적이면 penalty.
문제: 이 휴리스틱이 매우 복잡하고 때때로 틀렸다. Ingo Molnar 본인도 만족하지 못했다.
O(1)의 한계
- 휴리스틱 블랙박스: 왜 이 프로세스가 저 프로세스보다 우선인지 이해하기 어려움.
- 코너 케이스: 특정 워크로드에서 이상한 동작.
- Mobile/Interactive: 데스크톱에선 응답성 문제.
Linux 커뮤니티는 더 우아한 해결책을 찾기 시작했다. 그 답이 CFS였다.
3. CFS: Completely Fair Scheduler
등장
2007년 Linux 2.6.23에 CFS (Completely Fair Scheduler) 가 도입되었다. 개발자는 Ingo Molnar.
철학: "이상적인 공정성"
CFS의 핵심 아이디어:
"이상적으로는 모든 ready 프로세스가 동시에 CPU를 N분의 1씩 쓴다."
N개 프로세스가 있으면 각자 1/N의 CPU를 받아야 한다. 그러나 실제론 한 번에 하나만 실행 가능. 그래서 CFS는 "가장 덜 실행된 프로세스" 를 다음에 실행한다.
vruntime: 가상 실행 시간
각 프로세스는 vruntime (virtual runtime)을 가진다. 프로세스가 CPU를 사용한 "공정한" 양.
- CPU를 쓰는 동안 vruntime이 증가.
- 가중치(nice)에 따라 증가 속도가 다름.
- 가장 낮은 vruntime 프로세스가 다음에 실행.
이는 "모든 프로세스의 vruntime을 동기화" 하려는 시도와 같다.
Red-Black Tree
CFS는 프로세스들을 red-black tree로 정렬한다. 키는 vruntime:
[P5: vr=100]
/ \
[P3: vr=50] [P8: vr=150]
/ \ / \
[P1: vr=25] [P4: vr=75] [P7: vr=125] [P9: vr=200]
- 삽입/삭제: O(log N).
- 최솟값 찾기: O(log N) (또는 캐시된 leftmost 노드로 O(1)).
- 다음 실행: 가장 왼쪽 노드.
Nice와 가중치
Unix의 nice 값 (-20 ~ +19):
- 낮음 (-20): 높은 우선순위. 더 많은 CPU.
- 높음 (+19): 낮은 우선순위. 적은 CPU.
CFS는 nice를 가중치로 변환한다:
nice 0 → weight 1024 (기준)
nice -1 → weight 1277 (~1.25배)
nice +1 → weight 820 (~0.8배)
nice -20 → weight 88761 (~87배)
nice +19 → weight 15 (~0.014배)
각 단계마다 약 1.25배씩 변한다. 10단계 차이는 ~10배 차이.
가중치 적용
vruntime 증가 속도가 가중치에 반비례:
실제 CPU 시간 Δt 사용 시
vruntime 증가 = Δt × (NICE_0_LOAD / weight)
- Nice 0:
vruntime += Δt. - Nice -5: 가중치 높음 → vruntime 덜 증가 → 더 자주 선택됨.
- Nice +10: 가중치 낮음 → vruntime 많이 증가 → 덜 선택됨.
결과: CPU 시간이 가중치에 비례해서 분배된다. 이것이 "공정한" 분배다.
예시 계산
nice 0 프로세스 3개가 있다면:
- 각자 동일한 weight 1024.
- 총 weight: 3072.
- 각자 몫: 1024/3072 = 1/3.
nice 0과 nice -5 (weight 약 3906) 프로세스가 있으면:
- 총 weight: 1024 + 3906 = 4930.
- nice 0: 1024/4930 ≈ 21%.
- nice -5: 3906/4930 ≈ 79%.
Target Latency와 Minimum Granularity
CFS에는 목표 레이턴시 개념이 있다:
sched_latency_ns: 모든 프로세스가 한 번씩 실행되는 목표 주기 (기본 24 ms).- N개 프로세스면 각자
24ms / N몫.
그러나 프로세스가 많으면 time slice가 너무 작아진다. 그래서:
sched_min_granularity_ns: 최소 time slice (기본 3 ms).
if N ≤ 8: 각자 24/N ms
if N > 8: 각자 3 ms, 총 사이클 3N ms
장점
- 우아한 알고리즘: 단일 메커니즘으로 공정성과 우선순위 처리.
- 예측 가능: Nice 값과 CPU 배분이 직관적 관계.
- 휴리스틱 없음: 인터랙티브 휴리스틱 제거, 단순화.
- 확장성: 프로세스 수에 O(log N).
단점과 EEVDF로의 전환
CFS는 2007년부터 2023년까지 16년간 Linux의 기본이었다. 그러나 시간이 지나며 문제가 드러났다:
- Latency-sensitive 작업: 게임, 오디오 등은 평균 공정성보다 지연 상한이 중요.
- Sleep에 대한 보너스: Sleep에서 깨어난 프로세스는 vruntime이 낮아 즉시 선점됨 → 복잡한 보정 로직.
- WAKE_UP_PREEMPT: 깨어난 프로세스가 현재 프로세스를 즉시 preempt할지 결정이 까다로움.
CFS의 코드는 점점 복잡해졌고 코너 케이스가 늘어났다. 새로운 접근이 필요했다.
4. EEVDF: 2023년의 새 시대
EEVDF란
EEVDF (Earliest Eligible Virtual Deadline First) 는 1995년 Stoica와 Abdel-Wahab이 발표한 알고리즘이다. 2023년 Linux 6.6에서 CFS를 대체했다. Peter Zijlstra가 구현.
새로운 아이디어: Deadline
CFS는 "과거의 공정성"에 집중했다 (vruntime은 과거 실행량). EEVDF는 "미래의 deadline"에 집중한다.
각 프로세스는 virtual deadline을 가진다:
vdeadline = 최근 ready 시점 + (time_slice / weight)
스케줄러는 가장 이른 deadline을 가진 적격 프로세스를 선택한다.
Eligible (적격)
"적격"이라는 개념이 EEVDF의 핵심:
- 프로세스가 지금까지 공정한 몫보다 덜 받았으면 eligible.
- 더 받았으면 not eligible (잠시 양보).
이는 과거 공정성과 미래 deadline을 결합한다. "빚이 있는" 프로세스만 deadline 경쟁에 참여.
장점
- Latency 제어: deadline 기반이라 최악 지연 제한.
- Time slice 유연성: 프로세스별로 slice 요청 가능.
- 단순성: CFS의 복잡한 휴리스틱 제거.
- 수학적 증명: 공정성과 지연이 수학적으로 보장됨.
실전 영향
대부분의 워크로드에선 사용자가 차이를 느끼지 못한다. EEVDF는 CFS와 거의 같은 동작을 하되 코너 케이스가 개선됐다.
구체적 개선:
- 오디오/비디오 재생 더 안정.
- 게임 프레임 시간 일관성 향상.
- 대량 task 환경에서 공정성 더 정확.
현재 상태
Linux 6.6+ 시스템은 기본적으로 EEVDF를 쓴다. 대부분의 문서와 도구는 아직 "CFS"라고 부르지만 내부는 EEVDF다.
5. Scheduling Classes
Linux는 여러 스케줄링 클래스를 계층적으로 운영한다. 우선순위 순서대로:
stop_sched_class (최고: 정지 태스크)
↓
dl_sched_class (SCHED_DEADLINE)
↓
rt_sched_class (SCHED_FIFO, SCHED_RR)
↓
fair_sched_class (CFS / EEVDF - 대부분 프로세스)
↓
idle_sched_class (최저: idle task)
상위 클래스에 runnable 태스크가 있으면 항상 먼저 실행된다.
SCHED_NORMAL (CFS/EEVDF)
일반 프로세스. nice로 조정.
SCHED_BATCH
CFS와 유사하지만:
- 선점을 덜 함 (응답성보다 처리량 우선).
- 긴 계산 작업에 적합.
- 에너지 효율이 약간 더 좋음.
chrt -b -p 0 <pid>
SCHED_IDLE
가장 낮은 우선순위. 다른 작업이 없을 때만 실행.
chrt -i -p 0 <pid>
백그라운드 큰 계산 (백업, 인덱싱)에 유용.
6. Real-Time Classes
Real-time 스케줄링은 엄격한 타이밍 요구가 있는 경우를 위한 것:
SCHED_FIFO
"First-In-First-Out". 특성:
- 우선순위 레벨 1~99 (99가 가장 높음).
- 높은 우선순위가 항상 선점.
- Time slice 없음: 스스로 양보하거나 block할 때까지 실행.
- 같은 우선순위끼리는 FIFO 순서.
struct sched_param param = { .sched_priority = 50 };
sched_setscheduler(pid, SCHED_FIFO, ¶m);
주의: 무한 루프 RT 태스크는 시스템 동결 가능. RT 태스크는 반드시 I/O로 block하거나 sleep해야 한다.
SCHED_RR (Round Robin)
SCHED_FIFO와 비슷하지만 time slice 있음:
- 같은 우선순위끼리 시간 분할.
- 기본 time slice: 100 ms.
- 더 안전.
SCHED_DEADLINE
가장 엄격한 real-time. "이 시점까지 이만큼 CPU 필요" 를 명시:
struct sched_attr attr = {
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10 * 1000 * 1000, // 10 ms
.sched_deadline = 30 * 1000 * 1000, // 30 ms
.sched_period = 30 * 1000 * 1000, // 30 ms
};
sched_setattr(0, &attr, 0);
의미: "매 30 ms 주기마다 10 ms의 CPU를 30 ms 이내에 끝내줘".
EDF (Earliest Deadline First) + CBS (Constant Bandwidth Server) 기반. 수학적 보장 제공.
사용처: 오디오 처리, 로봇 제어, 산업 자동화.
RT Throttling
RT 태스크가 영원히 CPU를 잡을 위험 방지:
# /proc/sys/kernel/
sched_rt_runtime_us = 950000 # 950 ms
sched_rt_period_us = 1000000 # 1000 ms
기본적으로 RT는 매 1초 중 950 ms만 쓸 수 있음. 나머지 50 ms는 일반 태스크에 양보. 이 덕분에 RT 태스크 버그로 시스템이 멈추지 않는다.
PREEMPT_RT Patch
표준 Linux 커널의 real-time 지원은 제한적이다. PREEMPT_RT 패치는 거의 모든 커널 섹션을 preemptable로 만들어 실시간 성능을 제공한다.
Linux 6.12 (2024)부터 PREEMPT_RT가 메인라인에 병합되었다. 산업용 Linux의 큰 이정표.
용도: 자동차 제어, 공장 자동화, 전자 증권 거래, 로봇, 우주선.
7. Nice와 ionice
nice 명령어
프로세스 CPU 우선순위 조정:
# nice 10으로 실행 (낮은 우선순위)
nice -n 10 ./heavy_script.sh
# 실행 중 프로세스의 nice 변경
renice 15 -p 1234
# 사용자의 모든 프로세스
renice 10 -u alice
ionice 명령어
디스크 I/O 우선순위 조정 (스케줄러가 CPU뿐 아니라 I/O 스케줄링도 관리):
# Idle class (최저)
ionice -c 3 -p 1234
# Best-effort with priority 7 (낮음)
ionice -c 2 -n 7 -p 1234
- Class 1: Real-time.
- Class 2: Best-effort (기본, priority 0-7).
- Class 3: Idle (다른 I/O 없을 때만).
백업 스크립트에서 흔히 사용:
ionice -c 3 nice -n 19 ./backup.sh
"CPU와 디스크 모두 idle 우선순위로". 다른 작업을 방해하지 않음.
8. cgroup과 그룹 스케줄링
문제
nice 값은 프로세스 단위. 하지만 복잡한 시나리오:
- 사용자 Alice가 10개 프로세스 실행.
- 사용자 Bob이 1000개 프로세스 실행.
- Bob이 총 CPU의 99%를 가져간다 (많은 프로세스 덕분).
이는 공정하지 않다. Alice와 Bob에게 50%씩 주고 싶다.
cgroup의 그룹 스케줄링
cgroup v2의 cpu controller가 이를 해결한다. 프로세스를 그룹으로 묶어 그룹 단위 공정성:
mkdir /sys/fs/cgroup/alice
echo "500" > /sys/fs/cgroup/alice/cpu.weight
mkdir /sys/fs/cgroup/bob
echo "500" > /sys/fs/cgroup/bob/cpu.weight
# Alice와 Bob 프로세스를 각 그룹에 할당
echo <pid_alice_1> > /sys/fs/cgroup/alice/cgroup.procs
echo <pid_bob_1> > /sys/fs/cgroup/bob/cgroup.procs
이제 그룹 수준에서 CFS가 적용되고, 그 안에서 다시 프로세스 수준에서 CFS. 이중 공정성.
cpu.max: 절대 제한
# 최대 50% CPU
echo "50000 100000" > /sys/fs/cgroup/alice/cpu.max
# "100 ms 주기 중 50 ms 사용 가능"
cpu.weight.nice: Nice 스타일
# 일반 nice 값과 매핑
echo "10" > /sys/fs/cgroup/alice/cpu.weight.nice
실전: Kubernetes
Kubernetes의 CPU requests와 limits가 바로 cgroup으로 구현된다:
resources:
requests:
cpu: "500m" # 최소 500 milli-cores (공정 몫)
limits:
cpu: "1000m" # 최대 1 core
kubelet이 이를 cgroup 파일로 변환:
requests.cpu: 500m→cpu.weight = ...limits.cpu: 1000m→cpu.max = "100000 100000"
9. 멀티 CPU 스케줄링
Per-CPU Run Queue
앞서 설명했듯 각 CPU가 자기 run queue를 가진다. 이는 확장성의 기반이다. 수백 코어에서도 한 CPU의 스케줄링이 다른 CPU에 영향 없음.
Load Balancing
그러나 때때로 균형이 필요하다:
- 한 CPU에 3개, 다른 CPU에 0개면 불공평.
- 주기적으로 커널이 load balance 실행.
Load balance 트리거:
- 주기적 (수 ms 단위).
- CPU가 idle이 되면 (`nohz_idle_balance`).
- Exec/fork 시 (새 프로세스 CPU 선택).
- Wake up 시 (깨운 프로세스 CPU 선택).
Scheduling Domain
모든 CPU가 같은 "비용"으로 접근되지 않는다:
- 같은 코어의 하이퍼스레드: 매우 저렴 (캐시 공유).
- 같은 소켓의 코어: 저렴 (L3 공유).
- 다른 소켓 (NUMA): 비쌈 (원격 메모리 접근).
Linux는 scheduling domain을 계층적으로 구성:
NUMA Node 0
├── Socket 0
│ ├── Core 0
│ │ ├── CPU 0 (HT 0)
│ │ └── CPU 1 (HT 1)
│ └── Core 1
│ ├── CPU 2
│ └── CPU 3
└── Socket 1
├── Core 2
│ ...
NUMA Node 1
...
Load balance는 작은 도메인부터 먼저 시도. NUMA 노드 간 이동은 최후의 수단.
CPU Affinity
특정 프로세스를 특정 CPU에 고정:
# PID 1234를 CPU 2, 3에만 실행
taskset -pc 2,3 1234
# 새 명령어 실행
taskset -c 0-3 ./my_app
효과:
- 캐시 효율: 항상 같은 L1/L2 cache 재사용.
- NUMA 최적: 메모리가 있는 노드의 CPU에 고정.
- 간섭 격리: 중요한 워크로드를 특정 코어 전용.
Isolcpus: 코어 격리
부팅 시 일부 CPU를 스케줄러로부터 완전히 분리:
# GRUB에 추가
isolcpus=4,5,6,7 nohz_full=4,5,6,7 rcu_nocbs=4,5,6,7
이 CPU들은:
- 일반 태스크가 자동으로 안 들어감.
- 명시적으로
taskset으로 할당한 태스크만 실행. nohz_full: tick interrupt도 안 들어감 (거의 진짜 bare metal).
용도: 초저지연 트레이딩, 고성능 네트워킹(DPDK), 실시간 처리.
10. 성능 관찰과 분석
top, htop
가장 기본. CPU 사용률, 우선순위, nice 값 확인.
top
htop # 더 예쁨
ps로 스케줄링 클래스 확인
ps -eLo pid,tid,class,rtprio,pri,nice,comm
# ↑ ↑ ↑ ↑
# 클래스 rt우선순위 실시간우선순위 nice
TS: SCHED_NORMAL (CFS)FF: SCHED_FIFORR: SCHED_RRIDL: SCHED_IDLEDLN: SCHED_DEADLINE
/proc/[pid]/sched
각 프로세스의 스케줄러 통계:
cat /proc/1234/sched
# nr_switches, nr_voluntary_switches, run_delay, sum_exec_runtime, ...
run_delay: CPU 대기 시간. 높으면 스케줄링 부담.
/proc/schedstat
커널 전체 스케줄링 통계. 거의 원시 데이터라 해석 어려움.
perf sched
가장 강력한 도구. 스케줄링 이벤트 트레이스:
# 몇 초간 기록
sudo perf sched record -- sleep 10
# 분석
sudo perf sched latency # latency 통계
sudo perf sched map # 시각적 실행 맵
sudo perf sched timehist # 시간 히스토리
출력 예시:
Task Runtime ms Switches Avg delay Max delay
sshd:1234 5.2 12 0.102 0.342
chrome:5678 123.4 1543 1.234 8.901
Max delay가 크면 (수 ms+) 응답성 문제.
bcc/bpftrace
커널 내부 이벤트 추적:
# 스케줄링 지연 히스토그램
sudo runqlat 10
# 대기 시간 분포
# Context switch 빈도
sudo cpudist -p 1234 10
vmstat 1
r b swpd free ...
3 0 0 1GB ... ← r: runnable, b: blocked
r (runnable)이 CPU 수보다 계속 크면 스케줄링 경합.
11. 실전 튜닝
인터랙티브 응답성 향상
# /etc/sysctl.conf
kernel.sched_migration_cost_ns = 5000000
# 프로세스 이동 최소 비용. 높이면 캐시 locality 유지.
kernel.sched_autogroup_enabled = 1
# 데스크톱 autogroup (터미널 세션 단위 공정성)
서버 처리량 우선
# 선점 덜 자주
kernel.sched_min_granularity_ns = 10000000 # 10 ms
kernel.sched_wakeup_granularity_ns = 15000000 # 15 ms
# Autogroup 끔
kernel.sched_autogroup_enabled = 0
낮은 지연 (DB, Real-time)
# 선점 더 자주
kernel.sched_min_granularity_ns = 1000000 # 1 ms
kernel.sched_wakeup_granularity_ns = 2000000 # 2 ms
# Isolcpus + RT priority (부팅 옵션)
isolcpus=4-7 nohz_full=4-7
배치 작업: nice 활용
# CI 빌드 스크립트
nice -n 19 ionice -c 3 ./build.sh
CPU와 디스크 모두 최저 우선순위. 다른 작업 방해 없음.
Kubernetes: Guaranteed QoS
Pod에 integer CPU를 요청하면 static CPU manager가 활성화되어 전용 코어가 할당된다:
resources:
requests:
cpu: "2" # 정수만!
memory: "4Gi"
limits:
cpu: "2" # requests == limits
memory: "4Gi"
낮은 지연이 필요한 워크로드에 유용.
12. 흔한 문제와 해결
문제 1: 프로세스가 "가끔" 느림
증상: 평균은 빠른데 가끔 수 ms pause.
원인:
- 다른 CPU 집약적 프로세스의 경합.
- Context switch 오버헤드.
- Load balancing migration.
진단:
perf sched record -- sleep 10
perf sched latency
해결:
taskset으로 affinity 설정.isolcpus로 코어 격리.- Real-time priority 상승.
문제 2: RT 태스크가 시스템 동결
증상: 시스템이 응답 안 함.
원인: SCHED_FIFO 무한 루프.
해결:
sched_rt_runtime_us설정 (기본 950000). 이미 기본적으로 RT throttling.- RT 코드 검증 (sleep 또는 I/O 포함).
예방: RT 태스크는 반드시 주기적으로 양보 또는 I/O block.
문제 3: Load average 높은데 CPU 여유
증상: uptime load average가 높지만 top의 CPU 사용률은 낮음.
원인: D state (uninterruptible sleep) 프로세스. Load average에 포함되지만 CPU 안 씀.
진단:
ps -eo state,pid,cmd | grep "^D"
해결: 디스크 I/O 또는 네트워크 block 원인 찾기. 실제 CPU 스케줄링 문제 아님.
문제 4: 특정 프로세스 starvation
증상: 특정 프로세스가 CPU 거의 못 받음.
원인:
- Nice 값 높음 (+19).
- 너무 많은 동등한 우선순위 프로세스.
- I/O 병목으로 ready 시간 짧음.
해결:
renice -5 -p <pid>
# 또는
chrt -r -p 50 <pid> # SCHED_RR
문제 5: 컨테이너 CPU throttling
증상: Kubernetes Pod이 latency spike.
원인: limits.cpu 100ms 주기 내 소진 → 나머지 강제 sleep.
해결:
- CPU limit 높이거나 제거.
requests만 설정 (공정 몫만 확보).- Static CPU manager (Guaranteed QoS).
cpu.cfs_period_us축소.
퀴즈로 복습하기
Q1. CFS가 O(1) 스케줄러를 대체하면서 버린 것과 얻은 것은?
A.
버린 것: 복잡한 인터랙티브 휴리스틱 O(1) 스케줄러는 "이 프로세스가 인터랙티브인가?"를 판단하는 복잡한 규칙이 있었다:
- Sleep 시간을 측정해서 많이 자면 보너스.
- 긴 계산 작업은 페널티.
- 특정 threshold, 특정 scaling factor 등 마법 숫자 가득.
결과: 코너 케이스가 많았고, "왜 이 프로세스가 저 프로세스보다 우선인가?"에 답하기 어려웠다.
얻은 것: 수학적으로 단순한 공정성 모델 CFS는 하나의 개념만 쓴다: vruntime.
- 모든 프로세스의 vruntime을 "동시에" 증가시키려 함.
- 현재 가장 vruntime이 낮은 것(= 가장 덜 실행된 것)을 선택.
- Nice 값은 vruntime 증가 속도만 조절 (가중치).
이 모델의 우아함:
- 휴리스틱 없음: 인터랙티브/batch 구분 불필요. Sleep이 많은 프로세스는 자연스럽게 vruntime이 낮아서 우선.
- 예측 가능: nice 값이 가중치를 결정하고, CPU 시간은 가중치에 비례.
- 증명 가능: 수학적으로 공정성 보장.
대가: Red-black tree 사용으로 O(log N) (O(1)에서 열화). 그러나 실전에선 차이 무시할 정도.
교훈: 좋은 설계는 복잡한 휴리스틱을 하나의 우아한 원리로 대체한다. CFS는 이 철학의 모범 사례다. Ingo Molnar가 O(1)을 본인이 만들었음에도 CFS로 전환한 것은 "설계의 단순성"이 "성능 최적화"보다 장기적으로 가치 있다는 증거다.
Q2. EEVDF가 CFS보다 latency-sensitive 워크로드에 유리한 이유는?
A. CFS의 철학은 "과거 공정성" 이다. vruntime이 가장 낮은 것(= 가장 적게 실행된 것)을 선택한다. 이는 평균적으로 좋지만 최악 지연 보장이 없다.
CFS의 문제:
- 많은 프로세스가 있으면 한 바퀴 도는 데 오래 걸림.
- 인터랙티브 프로세스가 wake up 할 때 즉시 preempt되지 않을 수 있음.
- Wake-up 보너스 로직이 복잡하고 완벽하지 않음.
EEVDF의 해결: 각 프로세스에 virtual deadline을 부여한다:
vdeadline = 현재 + (요청한 time slice / 가중치)
스케줄러는 가장 이른 deadline을 가진 적격(eligible) 프로세스를 선택한다.
결과:
- 최악 지연 상한: deadline이 설정되므로 그 시간 내에 반드시 실행됨.
- Slice 유연성: 프로세스가 "나는 2 ms만 필요해"를 요청 가능.
- 공정성 유지: eligible 개념이 과거 공정성도 보장.
실전 효과:
- 오디오 재생: 버퍼 언더런 감소.
- 게임: 프레임 타이밍 일관성.
- Interactive shell: 반응 시간 더 균일.
- VoIP: 패킷 처리 지연 감소.
대부분 사용자는 차이를 못 느낀다. 워크로드가 그리 까다롭지 않으면 CFS와 EEVDF는 비슷하게 동작한다. 그러나 특정 latency-sensitive 워크로드에선 EEVDF가 명확히 낫다.
또 하나 중요한 점: EEVDF는 수학적 보장이 있다. CFS는 경험적으로 잘 동작했지만 특정 코너 케이스에서의 보장이 없었다. EEVDF는 1995년 논문에서 공정성과 지연의 상한을 수학적으로 증명했다.
Linux 6.6 (2023년)에서 기본 스케줄러가 되었고, 앞으로 수년간 Linux의 주력이 될 것이다. CFS의 진화는 멈추지 않는다.
Q3. SCHED_FIFO와 SCHED_DEADLINE의 차이는?
A. 둘 다 real-time 스케줄링 클래스지만 접근이 완전히 다르다.
SCHED_FIFO: 우선순위 기반
- 1~99의 우선순위 레벨.
- 높은 우선순위가 낮은 것을 항상 선점.
- 같은 우선순위끼리는 FIFO 순서.
- Time slice 없음: 스스로 양보하거나 block까지 계속 실행.
- 사용: 우선순위 관계가 명확한 경우.
문제점:
- 여러 RT 태스크가 있을 때 "누가 몇 % 받아야 하는가"를 우선순위로만 표현하기 어려움.
- 실수로 무한 루프면 낮은 우선순위 태스크가 starvation.
- Rate monotonic analysis 등 수학적 도구 필요.
SCHED_DEADLINE: 공약(budget) 기반 세 가지 값으로 표현:
- Runtime: 이만큼 CPU가 필요.
- Deadline: 이 시점까지.
- Period: 이 주기마다 반복.
예: "매 30 ms 주기마다, 10 ms의 CPU를, 30 ms 이내에 끝내줘".
이점:
- 수학적 보장: EDF + CBS 알고리즘으로 공정한 스케줄링.
- 자원 격리: 한 태스크의 런타임 초과가 다른 태스크에 영향 없음.
- 스케줄 가능성 검증: 커널이 admission control 수행 (받을지 거절할지 판단).
- Real-time 이론과 일치: Liu-Layland 같은 고전 이론 직접 적용.
사용 예:
- SCHED_FIFO: 오래된 real-time 애플리케이션, 단순 제어 루프.
- SCHED_DEADLINE: 오디오 처리 (주기적), 비디오 디코딩, 로봇 제어, 산업 자동화.
주의:
- SCHED_DEADLINE은 root 권한 필요.
- admission control이 거부하면 설정 실패.
- 다른 SCHED_DEADLINE 태스크와의 관계 고려 필요.
현대 real-time 시스템은 SCHED_DEADLINE을 선호한다. 더 체계적이고 예측 가능하기 때문이다. SCHED_FIFO는 legacy 또는 단순한 경우에만.
Q4. Nice 값 -5와 +5의 차이는 몇 배의 CPU 시간인가?
A. 약 12배다.
계산: CFS는 nice 값을 가중치로 변환한다. 변환 테이블:
nice -5 → weight ~3121
nice 0 → weight 1024
nice +5 → weight ~335
두 프로세스만 있을 때:
- 총 weight: 3121 + 335 = 3456.
- nice -5 몫: 3121 / 3456 ≈ 90.3%.
- nice +5 몫: 335 / 3456 ≈ 9.7%.
비율: 90.3 / 9.7 ≈ 9.3배.
조금 다르게 계산하면 10단계 차이마다 약 10배다. (1.25^10 ≈ 9.3)
실제 커널 테이블:
static const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};
Nice 값의 유용성:
- nice -5: 중요한 작업에 약 3배 더 많은 CPU (다른 것과 동시일 때).
- nice +5: 백그라운드 작업에 약 1/3.
- nice +19: 거의 idle만.
- nice -20: 거의 독점.
실전 권장:
- 배치/백업:
nice +10~+19. - 일반:
nice 0. - 대화형 shell:
nice 0또는-5. - 중요한 데몬:
nice -5 ~ -10. - 극단 값 (-20, +19): 특별한 경우만.
주의:
- CPU가 충분하면 nice 효과 없음: 모두가 CPU를 받으니까.
- 경쟁 상황에서만 의미: 여러 CPU 집약적 작업 있을 때.
- cgroup과 함께: cgroup이 우선적 메커니즘. Nice는 그 안에서.
가중치의 기하급수성: 1.25배씩 증가가 절대적이 아니라 상대적이다. nice -5는 nice 0 대비 3배지만, nice 0이 nice +5 대비해서도 3배다. 균등한 인상/하락 경험을 제공.
이것이 "10단계 차이면 10배"라는 Linux 규칙의 수학적 근거다. 엔지니어링의 아름다운 간결성.
Q5. CPU affinity (taskset)를 쓰면 왜 성능이 향상되는가?
A. 세 가지 주요 이유가 있다: 캐시 locality, NUMA locality, 간섭 감소.
1. 캐시 Locality CPU는 L1/L2/L3 캐시를 가진다. L1은 각 코어 전용, L3는 보통 공유.
Core 0 (L1: 32KB, L2: 256KB)
Core 1 (L1: 32KB, L2: 256KB)
Shared L3: 8MB
프로세스가 Core 0에서 실행되면 그 워킹셋이 Core 0의 L1/L2에 로드된다. 같은 프로세스가 다음에 다른 코어로 이동하면 (스케줄러의 load balancing) 새 코어의 캐시는 비어 있어서 모든 데이터를 다시 로드. 캐시 miss 폭발 → 느려짐.
taskset으로 CPU를 고정하면 항상 같은 캐시 사용 → 높은 hit rate → 빠른 실행.
효과: 캐시 민감 워크로드 (in-memory DB, 해시테이블 등)에서 10~30% 향상 가능.
2. NUMA Locality 멀티 소켓 서버에서 메모리는 NUMA 노드별로 분산되어 있다:
Node 0: [CPUs 0-15][RAM 128GB]
Node 1: [CPUs 16-31][RAM 128GB]
Core 0이 Node 0 RAM에 접근하면 로컬 → 빠름. Core 0이 Node 1 RAM에 접근하면 원격 → 1.5~2배 느림.
프로세스가 Node 0 RAM에 할당되었는데 스케줄러가 Core 16으로 이동시키면 모든 메모리 접근이 원격. 재앙.
numactl + taskset으로 프로세스와 메모리를 같은 노드에 고정:
numactl --cpunodebind=0 --membind=0 ./myapp
효과: 큰 메모리 워크로드에서 최대 2배 향상.
3. 간섭 감소 일반 스케줄러는 "중요한 작업"과 "백그라운드 작업"을 구분하지 않고 섞는다. 중요한 작업이 실행 중 갑자기 선점되면 지연 발생.
격리 기법:
isolcpus=4-7로 CPU 4-7을 커널 스케줄러로부터 제외.taskset -c 4-7 ./important_app으로 명시적 할당.- 이 코어에선 오직
important_app만 실행.
효과:
- p99 latency 극적 감소.
- 트레이딩 시스템의 마이크로초 수준 지연 일관성.
- DPDK 네트워킹의 라인레이트 달성.
실전 사용:
# 캐시 locality만 필요
taskset -c 0-3 ./my_cache_sensitive_app
# NUMA locality 추가
numactl --cpunodebind=0 --membind=0 ./my_memory_app
# 완전 격리 (부팅 isolcpus=4-7 전제)
taskset -c 4-7 ./my_isolated_app
함정:
- Over-affinity: 너무 엄격하게 고정하면 load balancing이 안 돼 다른 CPU가 idle이어도 활용 못 함.
- 코어 수보다 많은 프로세스: affinity 있어도 자기들끼리 경합.
- 측정 필수: 벤치마크로 확인. 이론만으론 효과 예측 어려움.
요약: taskset은 "스케줄러를 믿지 않고 내가 직접 관리"하는 도구다. 일반 워크로드엔 불필요하지만, 성능이 극도로 중요한 시스템(DB, 트레이딩, 실시간 처리)에서 필수적이다. Linux 스케줄러가 똑똑해도, 특정 워크로드의 요구사항을 완전히 아는 건 개발자뿐이다.
마치며: 보이지 않는 조율자
핵심 정리
- CFS (2007~): 공정성 기반 스케줄러. vruntime으로 우아하게.
- EEVDF (2023+): CFS의 후계자. deadline 기반 latency 제어.
- Real-time: SCHED_FIFO, SCHED_RR, SCHED_DEADLINE.
- Nice: 프로세스 우선순위. 10단계 = 10배.
- cgroup: 그룹 단위 공정성. Kubernetes의 기반.
- CPU affinity: 캐시/NUMA locality 확보.
- isolcpus: 극한 격리. 실시간 워크로드.
왜 이 지식이 가치 있는가
스케줄러는 눈에 안 보이지만 모든 성능의 근원이다. 이해 없이는:
- 왜 특정 프로세스가 느린지 설명 못함.
- CPU 제한이 어떻게 작동하는지 모름.
- Kubernetes CPU throttling에 당황.
- Real-time 요구사항 만족 못함.
- 서버 튜닝이 감에 의존.
이 글의 지식은 수십 년간 수천 명의 커널 개발자가 쌓아 올린 공학이다. 그것을 이해하면:
- 적절한 nice 값 선택.
- 언제 taskset 쓸지 판단.
- cgroup 설정을 의식적으로.
- RT 클래스 활용 여부 결정.
- 스케줄링 관련 문제 논리적 디버깅.
마지막 교훈
O(1) → CFS → EEVDF. 20년의 진화는 "좋은 해결책은 계속 발견된다" 는 교훈을 준다. 각 단계는 이전 단계의 한계를 직시하고 새로운 원리로 해결했다. Ingo Molnar는 자기가 만든 O(1)을 버리고 CFS를 만들었고, 이제 CFS도 EEVDF에게 자리를 내줬다.
이는 소프트웨어 엔지니어링의 본질이다. "영원한 최적"은 없다. 시스템이 진화하고, 워크로드가 변하고, 하드웨어가 바뀌면 답도 바뀐다. 그리고 매번 더 우아하고 더 정확한 해결책이 등장한다.
당신이 다음에 top을 볼 때, 잠시 멈추고 생각해 보자: 이 모든 프로세스가 공평하게(?) 실행되도록 수천 번의 스케줄링 결정이 매 초마다 내려지고 있다. 그 복잡한 연산이 당신의 컴퓨터를 "반응하는 살아있는 기계"로 만든다. 스케줄러는 조용한 조율자이며, 그 존재를 알아챌 때 비로소 당신은 시스템을 진짜로 이해하는 것이다.
참고 자료
- CFS Scheduler Design Documentation
- EEVDF: An earliest eligible virtual deadline first scheduler (LWN)
- Linux Kernel Scheduler Documentation
- Stoica & Abdel-Wahab: Earliest Eligible Virtual Deadline First (1995)
- Con Kolivas' BFS scheduler debates (historical) - 스케줄러 대안 역사
- Robert Love: Linux Kernel Development, Ch.4 Process Scheduling
- Understanding the Linux Kernel, Ch.7 - 전통적 설명
- LWN: An EEVDF CPU scheduler for Linux
- The PREEMPT_RT real-time Linux project
- Brendan Gregg: Systems Performance Ch.6 CPUs
현재 단락 (1/556)
`htop`을 실행해 보자. 수백 개의 프로세스가 보인다. 그러나 CPU는 4개, 혹은 8개뿐이다. 어떻게 이 모든 프로세스가 "동시에" 실행되는가?