본문 바로가기

현대 OS의 스케줄링

Linux의 CFS가 가상 실행 시간과 가중치로 CPU를 공정하게 나누는 원리와, Linux 6.6부터 쓰이는 EEVDF가 다음 작업을 고르는 방식을 모델로 따라갑니다. Windows의 우선순위 기반 스케줄러, 멀티코어의 친화성과 부하 분산, NUMA를 살펴보고, nice와 cgroup으로 CPU 몫을 조절하고 스케줄링 문제를 진단하는 방법을 정리합니다.

앞의 글들에서 본 알고리즘은 스케줄링을 생각하는 틀이지만, 실제 운영체제의 스케줄러는 그중 하나를 그대로 구현한 것이 아닙니다. 수천 개의 스레드와 수십 개의 코어, 서로 다른 캐시와 메모리 구조를 다루면서 오랜 시간 측정하고 고쳐 온 결과물입니다. 이 글에서는 Linux와 Windows가 실제로 다음에 실행할 스레드를 어떻게 고르는지, 코어가 여러 개일 때 어떤 결정을 더 해야 하는지, 그리고 개발자가 스케줄링에 영향을 주는 방법은 무엇인지 살펴봅니다.

Linux CFS: 받은 몫이 가장 적은 작업부터

Linux는 2007년의 2.6.23 버전부터 일반 작업의 스케줄러로 CFS(Completely Fair Scheduler)를 썼습니다. 잉고 몰나르(Ingo Molnár)가 설계한 CFS의 출발점은 이상적인 멀티태스킹 CPU입니다. 실행할 수 있는 작업이 nn개일 때 CPU가 각 작업을 1n\frac{1}{n}의 속도로 동시에 실행해 줄 수 있다면, 모든 작업은 언제나 정확히 같은 양의 CPU 시간을 받을 것입니다. 실제 CPU는 한 번에 하나만 실행하므로, CFS는 지금까지 받은 몫이 가장 적은 작업을 다음에 실행하는 방식으로 이 이상에 가까이 가려고 합니다.

가상 실행 시간과 가중치

CFS는 작업마다 가상 실행 시간(virtual runtime, vruntime)을 기록합니다. 작업이 CPU에서 실행되면 그 시간만큼 vruntime이 늘어나고, 스케줄러는 실행할 수 있는 작업 가운데 vruntime이 가장 작은 작업을 고릅니다. 우선순위가 모두 같다면 vruntime은 실제로 실행한 시간과 같은 속도로 늘어나므로, 가장 덜 실행한 작업이 먼저 실행되어 결국 모든 작업이 비슷한 몫을 받게 됩니다.

우선순위는 vruntime이 늘어나는 속도로 반영합니다. 작업의 nice 값마다 가중치(weight)가 정해져 있고, vruntime은 실제 실행 시간에 "nice 0의 가중치 ÷ 그 작업의 가중치"를 곱한 만큼 늘어납니다. 가중치가 큰 작업, 곧 nice 값이 낮은 작업은 vruntime이 천천히 늘어나므로 더 자주 선택되어 CPU를 더 많이 받습니다. Linux의 가중치 표에서 nice 0은 1024, 1은 820, 5는 335이며, nice 값이 한 단계 차이 날 때마다 가중치가 약 1.25배 차이 나도록 정해져 있습니다. 다음 코드는 두 작업이 1초 동안 계속 CPU를 원할 때, 1ms마다 vruntime이 가장 작은 작업을 실행하며 각자 받는 시간을 셉니다.

WEIGHT = {0: 1024, 1: 820, 5: 335}   # Linux의 nice 값별 가중치 가운데 일부

def run(nices, total_ms):
    vruntime = {name: 0.0 for name in nices}
    used = {name: 0 for name in nices}
    for _ in range(total_ms):
        name = min(vruntime, key=vruntime.get)                # vruntime이 가장 작은 작업
        used[name] += 1                                       # 1ms 실행
        vruntime[name] += 1 * 1024 / WEIGHT[nices[name]]      # 가중치가 클수록 천천히 증가
    return used

for nices in ({"A": 0, "B": 0}, {"A": 0, "B": 1}, {"A": 0, "B": 5}):
    used = run(nices, 1000)
    print(nices, "->", ", ".join(f"{n} {ms}ms" for n, ms in used.items()))
{'A': 0, 'B': 0} -> A 500ms, B 500ms
{'A': 0, 'B': 1} -> A 555ms, B 445ms
{'A': 0, 'B': 5} -> A 753ms, B 247ms

nice 값이 같으면 정확히 반씩 나누어 받습니다. nice 1인 B는 vruntime이 1024 ÷ 820 ≈ 1.25배 빨리 늘어나므로, 두 작업의 몫은 가중치의 비율인 1024 : 820이 되어 A가 약 55.5%, B가 약 44.5%를 받습니다. nice 5인 B는 1024 : 335로 약 4분의 1만 받습니다. 가중치의 절댓값이 아니라 함께 경쟁하는 작업들 사이의 비율이 몫을 정한다는 점이 중요합니다. 예를 들어 nice -20의 가중치는 88761이고 19는 15인데, 이 숫자가 CPU 시간 자체를 뜻하는 것은 아니며, 두 작업만 경쟁한다면 몫의 비율이 약 5,917 : 1이 된다는 뜻입니다. 실제 배분은 작업이 계속 실행할 수 있는 상태인지, 어느 cgroup에 속하는지, 어느 CPU에 놓였는지에 따라서도 달라집니다.

vruntime을 정렬해 두는 레드-블랙 트리

CFS는 CPU마다 실행을 기다리는 작업들을 vruntime 순서로 정렬한 레드-블랙 트리(red-black tree)에 넣어 둡니다. 레드-블랙 트리는 스스로 균형을 맞추는 이진 탐색 트리여서 삽입과 삭제가 O(log⁡n)O(\log n)에 끝납니다. 가장 작은 vruntime을 가진 작업은 트리의 가장 왼쪽 노드인데, CFS는 이 노드를 따로 기억해 두므로 다음 작업을 고르는 일은 바로 끝납니다. 실행을 마친 작업은 늘어난 vruntime에 맞는 자리로 다시 들어가고, 그러면 다른 작업이 가장 왼쪽이 되어 다음 차례를 받습니다.

고정된 퀀텀이 없는 시간 나누기

CFS에는 라운드 로빈 같은 고정된 퀀텀이 없습니다. 대신 실행할 수 있는 모든 작업이 한 번씩 실행되는 데 걸리기를 바라는 목표 지연 시간(target latency)을 두고, 이를 작업들의 가중치 비율로 나누어 각자 한 번에 실행할 시간을 정합니다. 목표 지연 시간을 6ms로 놓고 가중치가 같은 작업이 셋이라면 각자 2ms씩, 여섯이라면 1ms씩 받는 식입니다. 작업이 아주 많아지면 한 번에 실행하는 시간이 너무 짧아져 전환 비용만 커지므로, 최소 실행 단위를 두어 그보다 짧게 나누지는 않습니다. 이 값들은 커널 버전과 CPU 수에 따라 달라지므로 특정 숫자로 외울 필요는 없습니다.

EEVDF: 몫을 덜 받은 작업 가운데 마감이 이른 작업부터

Linux 6.6부터 일반 작업의 스케줄러는 EEVDF(Earliest Eligible Virtual Deadline First)로 바뀌기 시작했습니다. EEVDF도 CFS처럼 작업들이 받은 몫을 가상 시간으로 추적하지만, 다음 작업을 고르는 규칙이 다릅니다. 먼저 받아야 할 몫보다 덜 받은 작업만 자격(eligible)이 있다고 보고, 그 가운데 가상 마감(virtual deadline)이 가장 이른 작업을 고릅니다.

받아야 할 몫과 실제로 받은 몫의 차이를 랙(lag)이라고 합니다. 가중치가 같은 작업들이라면, 모든 작업의 평균 가상 실행 시간 VV에서 그 작업의 vruntime을 뺀 값으로 단순하게 생각할 수 있습니다. 랙이 0 이상이면 아직 받을 몫이 남은 작업이므로 자격이 있고, 음수면 이미 몫보다 많이 받은 작업이므로 이번에는 뒤로 밀립니다. 가상 마감은 작업이 한 번에 실행하겠다고 요청한 시간을 바탕으로 정해지며, 짧게 자주 실행하려는 작업일수록 마감이 일찍 옵니다. 그래서 EEVDF는 몫을 공정하게 나누면서도, 짧게 반응해야 하는 작업을 빨리 실행할 수 있습니다.

다음 단계 개념도는 같은 작업 상태에서 CFS와 EEVDF가 서로 다른 작업을 고르는 예를 보여 줍니다. 가중치가 같은 세 작업이 있고 V=20V = 20이며, 작업 A는 vruntime 18에 가상 마감 30, B는 19에 25, C는 23에 24입니다. 숫자는 설명을 위해 정한 값이며 실제 커널에서 측정한 것이 아닙니다.

동일한 작업 상태에서 다른 선택 기준

같은 가중치 후보의 lag를 계산한다. eligible 후보 중 가장 이른 가상 데드라인을 고른다. 각 단계의 값과 전환 이유를 비교합니다.

공정 스케줄러 · 두 선택 기준

EEVDF는 eligible 작업 중 가장 이른 가상 데드라인을 고릅니다

같은 가중치의 세 후보와 평균 가상 실행 시간 V=20을 놓은 설명용 상태입니다. lag=V−vruntime으로 단순화하며 VD는 현실의 마감 시각이나 커널 실측값이 아닙니다.

기존 CFS와 EEVDF의 후보 선택 경로같은 후보에서기존 CFS는최소vruntime18의A, EEVDF는lag가0이상인A B를남기고VD25의B를선택.C는VD24가가장일러도lag음수여서제외. 두정책의화살표는선택추론이며시간흐름아님.동일 후보 · V=20 · 같은 가중치 A: vr18 / lag+2 / VD30 B: vr19 / lag+1 / VD25 C: vr23 / lag−3 / VD24기존 CFS 기본 최소 vruntimeA 선택 · vr18EEVDF 자격 판단 lag ≥ 0 C는 −3 → 제외eligible A·B A: VD30 / B: VD25가장 이른 VD B 선택 · VD25최소 vruntime 기준VD24의 C는 현재 eligible이 아님 · VD는 현실 마감 시각이 아님
기존 CFS와 EEVDF의 후보 선택 경로같은 후보에서기존 CFS는최소vruntime18의A, EEVDF는lag가0이상인A B를남기고VD25의B를선택.C는VD24가가장일러도lag음수여서제외. 두정책의화살표는선택추론이며시간흐름아님.동일 후보 · V=20 A: vr18 / VD30 lag +2 B: vr19 / VD25 lag +1 C: vr23 / VD24 · lag −3기존 CFS 기본 최소 vruntimeA 선택 · vr18EEVDF 자격 판단 lag ≥ 0 C는 −3 → 제외eligible 후보 A: VD30 B: VD25가장 이른 VD B 선택 · VD25오른쪽 경로: EEVDFVD24의 C를 먼저 고르면 자격 판단을 빠뜨린 것 VD는 현실 마감 시각 아님
동일한 작업 상태에서 다른 선택 기준
작업가상 실행·받을 몫가상 데드라인·선택
A

vruntime 18 · lag +2

받을 몫이 남아 있음

VD 30

CFS 기본 기준: A 선택

B

vruntime 19 · lag +1

받을 몫이 남아 있음

VD 25

EEVDF: eligible 중 가장 이른 B

C

vruntime 23 · lag −3

현재 몫을 초과함

VD 24

가장 이른 VD여도 현재 eligible 아님

A
가상 실행·받을 몫:

vruntime 18 · lag +2

받을 몫이 남아 있음

가상 데드라인·선택:

VD 30

CFS 기본 기준: A 선택

B
가상 실행·받을 몫:

vruntime 19 · lag +1

받을 몫이 남아 있음

가상 데드라인·선택:

VD 25

EEVDF: eligible 중 가장 이른 B

C
가상 실행·받을 몫:

vruntime 23 · lag −3

현재 몫을 초과함

가상 데드라인·선택:

VD 24

가장 이른 VD여도 현재 eligible 아님

기존 CFS 기본 기준은 최소 vruntime의 A입니다. EEVDF는 현재 몫을 초과한 C를 제외하고 A·B의 VD를 비교하여 B를 선택합니다. C의 VD 24가 더 이르다는 사실만으로 선택하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 몫 계산부터 VD 선택까지 진행합니다.
동일한 작업 상태에서 다른 선택 기준
작업가상 실행·받을 몫가상 데드라인·선택
Avruntime 18 · lag +2. 받을 몫이 남아 있음VD 30. CFS 기본 기준: A 선택
Bvruntime 19 · lag +1. 받을 몫이 남아 있음VD 25. EEVDF: eligible 중 가장 이른 B
Cvruntime 23 · lag −3. 현재 몫을 초과함VD 24. 가장 이른 VD여도 현재 eligible 아님
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명같은 후보에서 기존 CFS는 최소 vruntime 18의 A를 선택한다. EEVDF는 lag가 0 이상인 A B를 남기고 VD 25의 B를 선택한다. C는 VD 24가 가장 일러도 lag가 음수여서 제외한다. 두 정책의 화살표는 선택 추론이며 시간 흐름이 아니다.
노드·선 표기A: vr 18 / lag +2 / VD 30; B: vr 19 / lag +1 / VD 25
노드·선 표기C: vr 23 / lag −3 / VD 24
노드·선 표기A 선택 · vr 18
노드·선 표기A: VD 30 / B: VD 25
노드·선 표기B 선택 · VD 25
노드·선 표기VD 24의 C는 현재 eligible이 아님 · VD는 현실 마감 시각이 아님
노드·선 표기A: vr 18 / VD 30
노드·선 표기B: vr 19 / VD 25
노드·선 표기C: vr 23 / VD 24 · lag −3
노드·선 표기A: VD 30
노드·선 표기B: VD 25
노드·선 표기VD 24의 C를 먼저 고르면
설명·조건A. 가상 실행·받을 몫:
설명·조건B. 가상 실행·받을 몫:
설명·조건C. 가상 실행·받을 몫:
예의 값과 변화
관찰·상황값·보충 정보변화·이유
몫 계산

같은 가중치 후보의 lag를 계산한다.

  • lag·VD 1: V 20
  • lag·VD 2: A 18→+2, B 19→+1, C 23→−3
  • lag 준비
자격 필터

받을 몫이 남은 후보만 남긴다.

  • lag·VD 1: A, B eligible
  • lag·VD 2: C VD 24지만 제외
  • VD 비교 대상 A, B
VD 선택

eligible 후보 중 가장 이른 가상 데드라인을 고른다.

  • lag·VD 1: A VD 30, B VD 25
  • lag·VD 2: B 선택
  • EEVDF B; 기존 CFS 최소 vr A는 별도 기준

동일한 작업 상태에서 다른 선택 기준

같은 가중치 후보의 lag를 계산한다. eligible 후보 중 가장 이른 가상 데드라인을 고른다. 각 단계의 값과 전환 이유를 비교합니다.

공정 스케줄러 · 두 선택 기준

EEVDF는 eligible 작업 중 가장 이른 가상 데드라인을 고릅니다

같은 가중치의 세 후보와 평균 가상 실행 시간 V=20을 놓은 설명용 상태입니다. lag=V−vruntime으로 단순화하며 VD는 현실의 마감 시각이나 커널 실측값이 아닙니다.

기존 CFS와 EEVDF의 후보 선택 경로같은 후보에서기존 CFS는최소vruntime18의A, EEVDF는lag가0이상인A B를남기고VD25의B를선택.C는VD24가가장일러도lag음수여서제외. 두정책의화살표는선택추론이며시간흐름아님.동일 후보 · V=20 · 같은 가중치 A: vr18 / lag+2 / VD30 B: vr19 / lag+1 / VD25 C: vr23 / lag−3 / VD24기존 CFS 기본 최소 vruntimeA 선택 · vr18EEVDF 자격 판단 lag ≥ 0 C는 −3 → 제외eligible A·B A: VD30 / B: VD25가장 이른 VD B 선택 · VD25최소 vruntime 기준VD24의 C는 현재 eligible이 아님 · VD는 현실 마감 시각이 아님
기존 CFS와 EEVDF의 후보 선택 경로같은 후보에서기존 CFS는최소vruntime18의A, EEVDF는lag가0이상인A B를남기고VD25의B를선택.C는VD24가가장일러도lag음수여서제외. 두정책의화살표는선택추론이며시간흐름아님.동일 후보 · V=20 A: vr18 / VD30 lag +2 B: vr19 / VD25 lag +1 C: vr23 / VD24 · lag −3기존 CFS 기본 최소 vruntimeA 선택 · vr18EEVDF 자격 판단 lag ≥ 0 C는 −3 → 제외eligible 후보 A: VD30 B: VD25가장 이른 VD B 선택 · VD25오른쪽 경로: EEVDFVD24의 C를 먼저 고르면 자격 판단을 빠뜨린 것 VD는 현실 마감 시각 아님
동일한 작업 상태에서 다른 선택 기준
작업가상 실행·받을 몫가상 데드라인·선택
A

vruntime 18 · lag +2

받을 몫이 남아 있음

VD 30

CFS 기본 기준: A 선택

B

vruntime 19 · lag +1

받을 몫이 남아 있음

VD 25

EEVDF: eligible 중 가장 이른 B

C

vruntime 23 · lag −3

현재 몫을 초과함

VD 24

가장 이른 VD여도 현재 eligible 아님

A
가상 실행·받을 몫:

vruntime 18 · lag +2

받을 몫이 남아 있음

가상 데드라인·선택:

VD 30

CFS 기본 기준: A 선택

B
가상 실행·받을 몫:

vruntime 19 · lag +1

받을 몫이 남아 있음

가상 데드라인·선택:

VD 25

EEVDF: eligible 중 가장 이른 B

C
가상 실행·받을 몫:

vruntime 23 · lag −3

현재 몫을 초과함

가상 데드라인·선택:

VD 24

가장 이른 VD여도 현재 eligible 아님

기존 CFS 기본 기준은 최소 vruntime의 A입니다. EEVDF는 현재 몫을 초과한 C를 제외하고 A·B의 VD를 비교하여 B를 선택합니다. C의 VD 24가 더 이르다는 사실만으로 선택하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 몫 계산부터 VD 선택까지 진행합니다.
동일한 작업 상태에서 다른 선택 기준
작업가상 실행·받을 몫가상 데드라인·선택
Avruntime 18 · lag +2. 받을 몫이 남아 있음VD 30. CFS 기본 기준: A 선택
Bvruntime 19 · lag +1. 받을 몫이 남아 있음VD 25. EEVDF: eligible 중 가장 이른 B
Cvruntime 23 · lag −3. 현재 몫을 초과함VD 24. 가장 이른 VD여도 현재 eligible 아님

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명같은 후보에서 기존 CFS는 최소 vruntime 18의 A를 선택한다. EEVDF는 lag가 0 이상인 A B를 남기고 VD 25의 B를 선택한다. C는 VD 24가 가장 일러도 lag가 음수여서 제외한다. 두 정책의 화살표는 선택 추론이며 시간 흐름이 아니다.
노드·선 표기A: vr 18 / lag +2 / VD 30; B: vr 19 / lag +1 / VD 25
노드·선 표기C: vr 23 / lag −3 / VD 24
노드·선 표기A 선택 · vr 18
노드·선 표기A: VD 30 / B: VD 25
노드·선 표기B 선택 · VD 25
노드·선 표기VD 24의 C는 현재 eligible이 아님 · VD는 현실 마감 시각이 아님
노드·선 표기A: vr 18 / VD 30
노드·선 표기B: vr 19 / VD 25
노드·선 표기C: vr 23 / VD 24 · lag −3
노드·선 표기A: VD 30
노드·선 표기B: VD 25
노드·선 표기VD 24의 C를 먼저 고르면
설명·조건A. 가상 실행·받을 몫:
설명·조건B. 가상 실행·받을 몫:
설명·조건C. 가상 실행·받을 몫:
예의 값과 변화
관찰·상황값·보충 정보변화·이유
몫 계산

같은 가중치 후보의 lag를 계산한다.

  • lag·VD 1: V 20
  • lag·VD 2: A 18→+2, B 19→+1, C 23→−3
  • lag 준비
자격 필터

받을 몫이 남은 후보만 남긴다.

  • lag·VD 1: A, B eligible
  • lag·VD 2: C VD 24지만 제외
  • VD 비교 대상 A, B
VD 선택

eligible 후보 중 가장 이른 가상 데드라인을 고른다.

  • lag·VD 1: A VD 30, B VD 25
  • lag·VD 2: B 선택
  • EEVDF B; 기존 CFS 최소 vr A는 별도 기준

CFS라면 vruntime이 가장 작은 A를 고릅니다. EEVDF는 먼저 랙을 계산해 A는 +2, B는 +1, C는 -3을 얻고, 랙이 음수인 C를 제외합니다. C의 가상 마감 24가 가장 이르지만, C는 이미 몫보다 많이 받았으므로 자격이 없습니다. 남은 A와 B 가운데 가상 마감이 이른 B를 고릅니다. 다음 코드로 같은 계산을 확인할 수 있으며, 둘째 줄은 C의 vruntime이 20이어서 랙이 0인 경우입니다.

def choose(tasks, V):
    """tasks는 {이름: (vruntime, 가상 마감)}이고 모두 가중치가 같다고 본다."""
    cfs = min(tasks, key=lambda n: tasks[n][0])                     # CFS: vruntime이 가장 작은 작업
    eligible = [n for n in tasks if V - tasks[n][0] >= 0]           # lag = V - vruntime이 0 이상
    eevdf = min(eligible, key=lambda n: tasks[n][1])                # 그 가운데 가상 마감이 가장 이른 작업
    lags = {n: V - tasks[n][0] for n in tasks}
    print(f"lag {lags}, 자격 {eligible} -> CFS {cfs}, EEVDF {eevdf}")

choose({"A": (18, 30), "B": (19, 25), "C": (23, 24)}, V=20)
choose({"A": (18, 30), "B": (19, 25), "C": (20, 24)}, V=20)
lag {'A': 2, 'B': 1, 'C': -3}, 자격 ['A', 'B'] -> CFS A, EEVDF B
lag {'A': 2, 'B': 1, 'C': 0}, 자격 ['A', 'B', 'C'] -> CFS A, EEVDF C

둘째 경우에는 C의 랙이 0이어서 자격이 있으므로, 세 작업 가운데 가상 마감이 가장 이른 C가 선택됩니다. CFS는 두 경우 모두 vruntime이 가장 작은 A를 고릅니다. 가상 마감은 공정한 몫을 나누기 위해 계산하는 값이지, 앞 글의 실시간 스케줄링에서 본 실제 마감 시각이 아니라는 점에 주의해야 합니다. 두 스케줄러의 자세한 설계는 커널의 CFS 설계 문서와 EEVDF 문서에 있습니다.

더 깊이: 스케줄러를 BPF 프로그램으로 바꿔 끼우는 sched_ext

Linux 6.12에는 sched_ext라는 스케줄링 클래스가 더해졌습니다. 커널을 다시 빌드하지 않고, BPF 프로그램 묶음으로 작성한 스케줄러를 실행 중에 올리고 내릴 수 있게 하는 기능입니다. 커널을 CONFIG_SCHED_CLASS_EXT로 빌드해도 BPF 스케줄러가 올라와 있지 않으면 쓰이지 않습니다. 이때 정책을 SCHED_EXT로 정한 작업은 SCHED_NORMAL처럼 다루어져 기본 스케줄러가 실행합니다.

BPF 스케줄러가 올라오면 기본적으로 SCHED_NORMAL, SCHED_BATCH, SCHED_IDLE, SCHED_EXT 작업을 모두 넘겨받습니다. 스케줄러가 SCX_OPS_SWITCH_PARTIAL 플래그를 켜면 SCHED_EXT 작업만 맡고 나머지는 기본 스케줄러에 남겨 둡니다. 실시간 정책의 작업은 어느 경우에도 넘겨받는 목록에 들어 있지 않습니다.

잘못 짠 스케줄러가 시스템을 멈추게 하면 안 되므로 안전장치가 있습니다. sched_ext 문서에 따르면 오류가 발견되거나, 실행할 수 있는 작업이 오래 실행되지 못하거나, 관리자가 SysRq-S 키 조합을 누르면 BPF 스케줄러를 내리고 모든 작업을 기본 스케줄러로 되돌립니다. 오류가 났을 때는 원인을 찾을 수 있도록 디버그 정보도 남깁니다.

Windows의 스케줄러

Windows는 우선순위 기반의 선점형 스케줄링을 씁니다. 스레드의 우선순위는 0부터 31까지 32단계이며, 실행할 수 있는 스레드 가운데 우선순위가 가장 높은 스레드가 언제나 실행되고, 같은 우선순위 안에서는 라운드 로빈으로 돌아갑니다.

범위쓰임
0쉬는 시간에 빈 메모리 페이지를 0으로 채우는 시스템 스레드 하나만 쓴다
1–15일반 스레드. 운영체제가 우선순위를 바꿀 수 있는 가변 범위
16–31실시간 범위. 운영체제가 우선순위를 바꾸지 않는다

1부터 15까지의 가변 범위에서 Windows는 스레드의 행동에 따라 우선순위를 잠시 올려 주는 부스트(boost)를 합니다. 입출력이 끝나 깨어난 스레드는 그 입출력의 종류에 따라 우선순위를 잠시 올려 받으므로, 입력을 기다리던 대화형 프로그램이 빨리 반응할 수 있습니다. 사용자가 보고 있는 창의 스레드도 반응이 빨라지도록 우대받습니다. 부스트된 우선순위는 스레드가 실행되면서 조금씩 원래 값으로 돌아옵니다. 또 준비 상태에서 오래 기다린 스레드의 우선순위를 잠시 올려, 우선순위가 높은 스레드에 계속 밀리는 기아도 줄입니다. 부스트의 정확한 크기와 퀀텀의 길이는 Windows 버전과 설정에 따라 다르므로, 고정된 수치로 기대하지 않는 것이 좋습니다.

멀티코어 스케줄링

코어가 여러 개이면 스케줄러는 어떤 작업을 실행할지에 더해, 어느 코어에서 실행할지도 정해야 합니다. 이 결정에는 캐시와 메모리의 위치가 크게 작용합니다.

같은 코어에서 계속 실행하기: 프로세서 친화성

작업이 한 코어에서 실행되는 동안, 그 코어의 캐시에는 작업이 쓰는 데이터가 채워집니다. 작업을 다른 코어로 옮기면 새 코어의 캐시에는 그 데이터가 없으므로 메모리에서 다시 가져와야 하고, 그동안 작업이 느려집니다. 그래서 스케줄러는 가능하면 작업을 같은 코어에서 계속 실행하려고 하며, 이를 프로세서 친화성(processor affinity)이라고 합니다.

친화성에는 두 종류가 있습니다. 소프트 친화성(soft affinity)은 운영체제가 되도록 같은 코어를 쓰지만, 부하가 크게 기울면 다른 코어로 옮기는 것으로, Linux의 기본 동작입니다. 하드 친화성(hard affinity)은 작업이 실행될 수 있는 CPU의 집합을 지정해 그 밖으로는 옮기지 못하게 하는 것입니다. CPU를 여러 개 지정하면 그 집합 안에서는 옮겨 다닐 수 있습니다. Linux에서는 taskset과 numactl로 지정합니다.

taskset -c 0,1 ./my_program        # CPU 0과 1에서만 실행
taskset -acp 0-3 1234              # PID 1234의 모든 스레드를 CPU 0~3으로 제한
numactl --cpunodebind=0 --membind=0 ./memory_intensive_app   # NUMA 노드 0의 CPU와 메모리만 사용

taskset -p는 지정한 스레드 하나의 친화성만 바꾸므로, 스레드가 여러 개인 프로세스 전체에 적용하려면 -a 옵션을 함께 줍니다. 친화성을 고정하면 캐시를 잘 활용할 수 있지만, 고정한 CPU가 바쁠 때 다른 CPU가 놀아도 옮겨 갈 수 없으므로, 바꾸기 전과 후를 측정해 보고 결정해야 합니다.

코어 사이의 일 나누기: 부하 분산

한 코어에는 작업이 몰려 있는데 다른 코어는 놀고 있다면 CPU를 낭비하는 것입니다. Linux는 CPU마다 따로 실행 큐를 두므로, 큐들 사이의 균형을 맞추는 부하 분산(load balancing)을 합니다. 주기적으로 각 CPU의 부하를 비교해 바쁜 CPU에서 한가한 CPU로 작업을 옮기고, CPU가 할 일이 없어 쉬려 할 때는 바쁜 CPU의 작업을 가져옵니다. 바쁜 쪽에서 밀어내는 것을 푸시 이동(push migration), 한가한 쪽에서 가져오는 것을 풀 이동(pull migration)이라고 합니다.

부하 분산과 친화성은 서로 부딪힙니다. 일을 고르게 나누려면 작업을 옮겨야 하는데, 옮기면 캐시의 이점을 잃습니다. Linux는 이 균형을 스케줄링 도메인(scheduling domain)으로 잡습니다. CPU들을 하드웨어 구조에 따라 계층으로 묶어 두고, 같은 물리 코어의 하이퍼스레드나 L3 캐시를 함께 쓰는 같은 소켓 안에서는 쉽게 옮기되, 캐시를 공유하지 않는 다른 소켓으로는 불균형이 클 때만 옮깁니다. 또 CPU마다 성능이 다른 시스템에서는 고르게 나누는 것이 늘 최선은 아니므로, 성능과 전력을 함께 고려해 배치합니다.

메모리의 위치까지 고려하기: NUMA

여러 소켓을 가진 서버는 보통 NUMA(non-uniform memory access) 구조이며, 하드웨어 쪽의 구조와 접근 비용 계산은 컴퓨터구조 교재의 공유 메모리·NUMA·일관성에서 다룹니다. CPU마다 가까운 메모리가 있어서, 자기 노드의 메모리는 빨리 읽지만 다른 노드의 메모리를 읽을 때는 추가 비용이 듭니다. 그 차이는 하드웨어와 접근 방식에 따라 다릅니다. 그래서 스케줄러는 작업의 메모리가 어느 NUMA 노드에 있는지도 고려해, 되도록 같은 노드의 CPU에서 실행하려고 합니다. Linux는 작업이 주로 접근하는 메모리를 관찰해 작업이나 메모리 페이지를 옮기는 자동 NUMA 균형(automatic NUMA balancing) 기능도 갖추고 있습니다. 결국 현대 스케줄러의 성능 문제는 CPU 시간만이 아니라 캐시, 실행 큐, 메모리의 위치까지 함께 보아야 풀 수 있습니다.

개발자가 스케줄링에 관여하는 방법

nice 값으로 몫을 조절하기

Linux에서 nice 값은 -20부터 19까지이며, 기본값은 0이고 낮을수록 CPU를 더 많이 받습니다. 이름은 다른 작업에게 얼마나 친절하게(nice) 양보하느냐에서 왔습니다. 앞에서 본 것처럼 nice 값은 CFS와 EEVDF의 가중치로 바뀌어, 함께 경쟁하는 작업들 사이의 몫을 정합니다.

nice -n 10 ./background_task       # 낮은 우선순위로 실행
sudo nice -n -5 ./important_task   # 높은 우선순위로 실행(권한 필요)
renice -n 5 -p 1234                # 실행 중인 PID 1234의 nice 값 변경
renice -n 10 -u username           # 한 사용자의 모든 프로세스 변경

nice 값을 낮추어 우선순위를 높이는 것은 보통 root 권한이나 CAP_SYS_NICE 능력이 필요합니다. 일반 사용자는 자기 작업의 nice 값을 높여 양보하는 쪽으로만 바꿀 수 있습니다. 백업이나 대량 변환처럼 급하지 않은 작업을 nice -n 10으로 실행해 두면, 같은 서버의 대화형 작업이 덜 방해받습니다.

cgroup으로 CPU 사용량 제한하기

nice는 함께 경쟁할 때의 비율만 정하므로, 다른 작업이 없으면 nice 19인 작업도 CPU를 다 쓸 수 있습니다. 작업 묶음이 쓸 수 있는 CPU 시간에 상한을 두려면 cgroup(control group)을 씁니다. Docker와 Kubernetes가 컨테이너의 CPU 한도를 정할 때 쓰는 것도 이 기능입니다. cgroup v2에서는 그룹의 cpu.max 파일에 "한 주기에 쓸 수 있는 시간"과 "주기"를 마이크로초 단위로 적습니다.

echo "50000 100000" > /sys/fs/cgroup/my_app/cpu.max   # 100ms마다 합계 50ms까지

이 설정은 그룹에 속한 모든 작업이 100ms마다 합쳐서 50ms까지 CPU를 쓸 수 있다는 뜻으로, 논리 CPU 하나의 50%에 해당합니다. 컴퓨터 전체 CPU의 50%가 아니라는 점에 주의해야 합니다. 그룹의 두 작업이 서로 다른 CPU에서 동시에 실행된다면, 50ms의 예산을 약 25ms 만에 다 쓰고 나머지 75ms 동안은 둘 다 멈추게 됩니다. 이렇게 예산을 다 써서 멈추는 것을 스로틀링(throttling)이라고 하며, 응답 시간이 갑자기 길어지는 원인이 되기도 합니다. 이 명령이 동작하려면 cgroup을 만들고 CPU 컨트롤러를 켜고 작업을 그룹에 넣어야 하며, 파일에 쓸 권한도 있어야 합니다. 비율로 나누는 cpu.weight 같은 다른 설정은 cgroup v2 문서에 있습니다.

스케줄링 때문에 느린지 진단하기

프로그램이 느린 원인이 CPU를 기다리는 데 있는지 확인하는 방법은 여러 가지입니다. 아래 명령은 세 가지를 봅니다. 한 프로세스가 지금까지 겪은 전환 횟수, 시스템 전체에서 CPU를 기다리는 작업의 수, 그리고 스케줄러가 작업을 언제 실행했는지의 기록입니다. perf sched record는 -- sleep 5가 끝나는 5초 동안의 사건을 현재 디렉터리의 파일에 기록하고, 다음 줄의 perf sched latency가 그 파일을 읽어 요약합니다.

pid=1234                                  # 살펴볼 프로세스의 PID로 바꾼다
grep ctxt /proc/$pid/status               # 자발적, 비자발적 컨텍스트 스위칭 횟수
vmstat 1                                  # r 열: 실행 중이거나 CPU를 기다리는 작업 수
sudo perf sched record -p $pid -- sleep 5 # 5초 동안 스케줄링 사건 기록
sudo perf sched latency                   # 작업별 스케줄링 지연 요약

vmstat의 r 열이 쓸 수 있는 논리 CPU 수보다 계속 크다면, CPU를 기다리는 작업이 많다는 신호입니다. 비자발적 컨텍스트 스위칭이 빠르게 늘어나는 작업은 CPU를 두고 경쟁하고 있을 가능성이 높습니다. perf sched latency는 작업이 실행할 수 있게 된 뒤 실제로 CPU를 받기까지 기다린 시간을 보여 주므로 가장 직접적인 정보를 줍니다. 다만 이 값들만으로 "코어를 늘려야 한다"고 결론 내리기는 이릅니다. CPU 사용률, 친화성 설정, cgroup의 스로틀링 여부를 함께 확인해야 원인을 좁힐 수 있습니다.

정리

  • Linux CFS는 작업마다 가상 실행 시간을 기록하고, 가장 작은 작업을 실행해 CPU를 공정하게 나눕니다. nice 값은 가중치가 되어 가상 실행 시간이 늘어나는 속도를 바꾸며, 한 단계 차이마다 몫이 약 1.25배 달라집니다.
  • Linux 6.6부터 쓰이는 EEVDF는 받을 몫이 남은 작업 가운데 가상 마감이 가장 이른 작업을 골라, 공정한 몫을 지키면서 짧게 반응해야 하는 작업을 빨리 실행합니다.
  • Windows는 0부터 31까지의 우선순위 가운데 가장 높은 준비된 스레드를 실행하고, 가변 범위에서는 입출력 완료나 전경 창에 따라 우선순위를 잠시 올립니다.
  • 멀티코어에서는 캐시를 위해 같은 코어에서 계속 실행하려는 친화성과, 코어 사이에 일을 나누는 부하 분산이 서로 부딪히며, NUMA 시스템에서는 메모리의 위치도 고려합니다.
  • nice는 경쟁할 때의 비율을, cgroup의 cpu.max는 그룹이 쓸 수 있는 CPU 시간의 상한을 정합니다. 스케줄링 문제는 vmstat, 컨텍스트 스위칭 횟수, perf sched를 함께 보고 진단합니다.

연습 문제

1. nice 값이 다른 세 작업의 몫

CPU 하나에서 nice 0인 작업 A와 B, nice 5인 작업 C가 계속 CPU를 원합니다. 가중치는 nice 0이 1024, 5가 335입니다. CFS에서 1초 동안 각 작업이 받는 CPU 시간을 대략 구하세요. C의 nice 값을 0으로 바꾸면 어떻게 될까요?

해설 보기

몫은 가중치의 비율로 나뉘므로 전체 가중치는 1024 + 1024 + 335 = 2383입니다. A와 B는 각각 1024 ÷ 2383 ≈ 43%, 약 430ms를 받고, C는 335 ÷ 2383 ≈ 14%, 약 141ms를 받습니다. C의 nice 값을 0으로 바꾸면 세 작업의 가중치가 모두 같아지므로 각자 3분의 1, 약 333ms씩 받습니다. nice 값은 함께 경쟁하는 작업들 사이의 비율만 정하므로, C 혼자 남는다면 nice 5라도 CPU를 모두 쓸 수 있습니다. 앞의 시뮬레이터에 작업 C를 더해 확인해 볼 수 있습니다.

2. EEVDF가 고르는 작업

가중치가 같은 세 작업이 있고 평균 가상 실행 시간 VV가 50입니다. 작업 X는 vruntime 47에 가상 마감 60, Y는 52에 가상 마감 55, Z는 50에 가상 마감 58입니다. CFS와 EEVDF가 각각 어느 작업을 고르는지 이유와 함께 답하세요.

해설 보기

CFS는 vruntime이 가장 작은 X를 고릅니다. EEVDF는 먼저 랙을 계산합니다. X는 50 − 47 = 3, Y는 50 − 52 = -2, Z는 50 − 50 = 0입니다. Y는 랙이 음수여서 이미 몫보다 많이 받았으므로 자격이 없고, 가상 마감 55가 가장 이르지만 제외됩니다. 자격이 있는 X와 Z 가운데 가상 마감이 이른 Z(58)를 고릅니다. 앞의 choose 함수에 {"X": (47, 60), "Y": (52, 55), "Z": (50, 58)}과 V=50을 넣어도 같은 결과가 나옵니다.

3. cpu.max와 두 CPU

cgroup v2에서 한 그룹의 cpu.max를 "30000 100000"으로 정했습니다. 이 그룹의 두 작업이 계속 CPU를 원하고, 서로 다른 CPU에서 동시에 실행될 수 있습니다. 한 주기에 그룹이 쓸 수 있는 CPU 시간과, 그 예산을 다 쓰는 데 걸리는 시간을 구하세요. 작업이 하나뿐이라면 어떻게 달라질까요?

해설 보기

그룹 전체가 100ms마다 합계 30ms까지 쓸 수 있습니다. 두 작업이 서로 다른 CPU에서 동시에 실행되면 1ms마다 2ms씩 쓰므로 15ms 만에 예산을 다 쓰고, 그 주기의 나머지 85ms 동안은 둘 다 멈춥니다. 작업이 하나뿐이라면 30ms 동안 실행하고 나머지 70ms 동안 멈춥니다. 어느 경우든 30%는 논리 CPU 하나를 기준으로 한 값이므로, 두 CPU 컴퓨터 전체의 30%가 아닙니다. 요청이 주기의 앞부분에 몰려 예산을 일찍 다 쓰면, 그 뒤에 온 요청은 다음 주기까지 기다려야 하므로 응답 시간이 길어질 수 있습니다.

다음 장의 동기화 문제의 본질에서는 여러 스레드가 공유 자원에 동시에 접근할 때 어떤 문제가 생기는지 살펴봅니다.