CFS 스케줄러(Scheduler) 상세
Linux 커널의 CFS(Completely Fair Scheduler)를 vruntime 수식과 실행 큐(Runqueue) 동작 기준으로 심층 분석합니다. nice 가중치 기반 시간 분배, Red-Black Tree 스케줄링, wakeup preemption, PELT 부하 추적, cgroup 대역폭(Bandwidth) 제한, EEVDF와의 연계 변화, 성능 관측 지표와 튜닝 전략까지 실무 관점에서 다룹니다.
핵심 요약
- 핵심 객체 — 이 문서의 중심이 되는 자료구조/API를 먼저 파악합니다.
- 실행 경로 — 요청이 들어와 처리되고 종료되는 흐름을 확인합니다.
- 병목(Bottleneck) 지점 — 지연(Latency)이나 처리량(Throughput) 저하가 발생하는 구간을 점검합니다.
- 동기화 지점 — 경합(Contention)과 경쟁 조건(Race Condition)이 생길 수 있는 구간을 구분합니다.
- 운영 포인트 — 관측 지표와 튜닝 항목을 함께 확인합니다.
단계별 이해
- 구성요소 확인
핵심 자료구조와 주요 API를 먼저 식별합니다. - 요청 흐름 추적
입력부터 완료까지의 호출 경로를 순서대로 따라갑니다. - 예외 경로 점검
실패 처리, 재시도, 타임아웃 등 경계 조건을 확인합니다. - 성능/안정성 점검
잠금 경합(Lock Contention), 큐 적체, 병목 지점을 측정하고 조정합니다.
개요
CFS(Completely Fair Scheduler)는 2007년 Linux 2.6.23에서 기존 O(1) 스케줄러를 대체하며 도입된 프로세스(Process) 스케줄러입니다.
핵심 원칙
- 완전한 공정성(Fairness): 모든 프로세스가 CPU 시간을 공정하게 분배받음
- 가상 런타임(vruntime): 실제 실행 시간을 우선순위(Priority)로 가중 계산
- Red-Black Tree: O(log N) 시간 복잡도로 다음 태스크(Task) 선택
- 동적 타임슬라이스(Time Slice): 실행 가능한 프로세스 수에 따라 자동 조정
O(1) 스케줄러와의 차이
| 구분 | O(1) 스케줄러 | CFS |
|---|---|---|
| 시간 복잡도 | O(1) | O(log N) |
| 자료구조 | 우선순위별 큐 배열 | Red-Black Tree |
| 타임슬라이스 | 고정 (5-800ms) | 동적 조정 |
| 대화형 태스크 | 휴리스틱 기반 감지 | 자동 공정 분배 |
| 복잡도 | 높음 (~2000 LOC) | 낮음 (~1000 LOC) |
CFS 개발 역사
CFS는 2007년 Ingo Molnár가 리눅스 커널 2.6.23에 처음 도입한 이후 꾸준히 발전해 왔습니다. 아래 타임라인은 주요 이정표를 정리한 것입니다.
가상 런타임 (vruntime)
vruntime은 CFS의 핵심 메트릭으로, 실제 실행 시간을 nice 값(우선순위)으로 가중한 값입니다.
vruntime 계산
/* kernel/sched/fair.c */
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
delta_exec = now - curr->exec_start;
curr->exec_start = now;
/* 실제 실행 시간을 우선순위로 가중 */
curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);
}
/* vruntime = 실제시간 * (NICE_0_LOAD / 현재_weight) */
static u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
return delta;
}
코드 설명
- 2행
update_curr()는 현재 실행 중인 태스크의 vruntime을 갱신하는 함수로,kernel/sched/fair.c에 정의되어 있습니다. 타이머 틱, 태스크 인큐/디큐, 선점 검사 등 다양한 경로에서 호출됩니다. - 6~7행
delta_exec = now - curr->exec_start: 마지막 갱신 이후 경과한 실제 실행 시간(나노초)을 계산합니다.rq_clock_task()는 인터럽트 시간이 제외된 순수 태스크 클록을 반환합니다. - 10행
calc_delta_fair(delta_exec, curr): 실제 시간을 가상 시간으로 변환합니다. 공식은delta_exec * NICE_0_LOAD / weight이며, nice 0(weight=1024)인 태스크는 1:1 변환됩니다. - 11행
update_min_vruntime(): 런큐의min_vruntime을 단조 증가 방식으로 갱신합니다. 새 태스크 인큐 시 vruntime 초기화의 기준점으로 사용됩니다. - 15행
calc_delta_fair()에서se->load.weight == NICE_0_LOAD이면 가중 계산을 생략하여 성능을 최적화합니다. 대부분의 태스크가 nice 0이므로 이 최적화의 효과가 큽니다. - 17행
__calc_delta()는 64비트 나눗셈을inv_weight를 이용한 곱셈+시프트 연산으로 대체하여 정수 나눗셈의 높은 비용을 회피합니다.
Nice 값과 Weight 테이블
| Nice | Weight | vruntime 증가율 | 설명 |
|---|---|---|---|
| -20 | 88761 | 0.12x | 최고 우선순위 (1/8 속도로 증가) |
| -10 | 9548 | 0.11x | 높은 우선순위 |
| 0 | 1024 | 1.0x | 기본 우선순위 |
| +10 | 110 | 9.3x | 낮은 우선순위 |
| +19 | 15 | 68x | 최저 우선순위 (68배 빠르게 증가) |
vruntime Overflow 처리
vruntime은 u64 타입이지만, 수십 년 실행 시 오버플로우 가능성이 있습니다. CFS는 entity_before() 함수에서 부호 있는 차이 계산으로 오버플로우를 안전하게 처리합니다:
static int entity_before(struct sched_entity *a, struct sched_entity *b)
{
return (s64)(a->vruntime - b->vruntime) < 0;
}
⚠ Nice 값 설정 주의사항
- Nice 값은 상대적: nice -5는 "빠름"이 아니라 "nice 0보다 더 많은 CPU"를 의미합니다. 시스템에 프로세스가 1개뿐이면 nice 값은 무의미합니다.
- 루트 권한 필요: 음수 nice (높은 우선순위)는
CAP_SYS_NICEcapability가 필요합니다. 일반 사용자는 자신의 프로세스 nice 값을 증가(우선순위 낮춤)만 가능합니다. - 실시간(Real-time) 아님: nice -20도
SCHED_FIFORT 태스크보다 낮은 우선순위입니다. 엄격한 지연시간 보장이 필요하면 실시간 스케줄링 정책을 사용하세요. - I/O 대기는 무관: Nice 값은 CPU 경쟁 시에만 영향을 줍니다. 디스크 I/O나 네트워크 대기 중인 시간은 vruntime에 포함되지 않습니다.
Red-Black Tree 구조
CFS는 각 CPU의 실행 큐(runqueue)에서 vruntime을 키로 하는 Red-Black Tree를 관리합니다.
sched_entity 구조체(Struct)
/* include/linux/sched.h */
struct sched_entity {
struct load_weight load; /* 우선순위 가중치 */
unsigned long runnable_weight;
struct rb_node run_node; /* RB 트리 노드 */
u64 exec_start; /* 마지막 실행 시작 시각 */
u64 sum_exec_runtime; /* 누적 실행 시간 */
u64 vruntime; /* 가상 런타임 */
u64 prev_sum_exec_runtime;
struct sched_statistics statistics;
struct sched_entity *parent; /* 그룹 스케줄링용 */
struct cfs_rq *cfs_rq; /* 소속 CFS 런큐 */
struct cfs_rq *my_q; /* 그룹인 경우 자식 런큐 */
};
코드 설명
- 3행
load:load_weight구조체로, nice 값에서 변환된 가중치(weight)와 나눗셈 최적화를 위한inv_weight를 저장합니다.include/linux/sched.h에 정의된sched_prio_to_weight[]테이블에서 nice 0 기준 1024 값을 가집니다. - 5행
run_node: CFS 런큐의 RB-tree(tasks_timeline)에 삽입되는 노드입니다.vruntime을 키로 정렬되며,rb_first_cached()로 최소 vruntime 노드에 O(1) 접근이 가능합니다. - 6행
exec_start:update_curr()호출 시 기준 시각입니다.rq_clock_task() - exec_start로 실제 실행 시간(delta_exec)을 계산합니다. - 8행
vruntime: CFS의 핵심 메트릭으로, 실제 실행 시간을 가중치로 나눈 가상 시간입니다. RB-tree에서 이 값이 가장 작은 태스크가 다음 실행 대상이 됩니다. - 12행
parent: 그룹 스케줄링에서 상위 그룹의sched_entity를 가리킵니다.for_each_sched_entity(se)매크로가 이 포인터를 따라 루트까지 순회합니다. - 13행
cfs_rq: 이 엔티티가 소속된 CFS 런큐입니다.my_q는 이 엔티티가 그룹 대표인 경우 자식 태스크들이 속한 런큐를 가리킵니다.
트리 구조 시각화
다음 태스크 선택 (pick_next_task)
스케줄러의 핵심 동작은 가장 작은 vruntime을 가진 태스크를 선택하는 것입니다.
pick_next_entity 구현
/* kernel/sched/fair.c */
static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq)
{
struct sched_entity *se = __pick_first_entity(cfs_rq);
struct sched_entity *left = __pick_first_entity(cfs_rq);
/* leftmost 노드 = RB 트리 최좌측 = 최소 vruntime */
if (left) {
se = left;
}
return se;
}
static struct sched_entity *
__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
}
코드 설명
- 3행
pick_next_entity()는 CFS 런큐에서 다음에 실행할sched_entity를 선택합니다.kernel/sched/fair.c에 정의되어 있으며,pick_next_task_fair()에서 호출됩니다. - 5행
__pick_first_entity()로 RB-tree의 leftmost 노드(최소 vruntime)를 가져옵니다. 이것이 기본 후보이며, 실제 구현에서는skip/next/last힌트로 조정될 수 있습니다. - 16행
__pick_first_entity()는rb_first_cached()를 사용하여 O(1)에 leftmost 노드를 반환합니다. 캐시된 포인터 덕분에 트리를 탐색할 필요가 없습니다. - 21행
rb_entry()는container_of()매크로의 래퍼로,rb_node포인터에서 이를 포함하는sched_entity구조체의 시작 주소를 계산합니다.
스케줄링 흐름
schedule()
└─ __schedule()
├─ pick_next_task()
│ └─ pick_next_task_fair() /* CFS */
│ └─ pick_next_entity()
│ └─ __pick_first_entity() /* leftmost */
└─ context_switch()
타임슬라이스와 지연시간
CFS는 고정 타임슬라이스 대신 동적으로 계산된 시간 할당을 사용합니다.
핵심 파라미터
| 파라미터 | 기본값 | 설명 |
|---|---|---|
sched_latency_ns | 6ms | 모든 프로세스가 1회 실행되는 기간 (목표 지연) |
sched_min_granularity_ns | 0.75ms | 최소 타임슬라이스 (너무 잦은 전환 방지) |
sched_wakeup_granularity_ns | 1ms | 선점(Preemption) 결정 임계값 |
타임슬라이스 계산
/* kernel/sched/fair.c */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
u64 slice = __sched_period(cfs_rq->nr_running);
/* 프로세스의 가중치 비율로 분배 */
slice *= se->load.weight;
do_div(slice, cfs_rq->load.weight);
return slice;
}
/* 실행 가능한 프로세스 수에 따라 period 조정 */
static u64 __sched_period(unsigned long nr_running)
{
if (unlikely(nr_running > sched_nr_latency))
return nr_running * sysctl_sched_min_granularity;
else
return sysctl_sched_latency;
}
코드 설명
- 2행
sched_slice()는 태스크에 할당할 타임슬라이스를 계산합니다.kernel/sched/fair.c에 정의되며,check_preempt_tick()에서 선점 판단 기준으로 사용됩니다. - 3행
__sched_period()는 전체 스케줄링 주기를 반환합니다. 실행 가능 태스크 수가sched_nr_latency(기본 8) 이하이면sched_latency_ns(6ms)를, 초과하면nr_running * sched_min_granularity를 사용합니다. - 5~6행타임슬라이스는 전체 주기를 가중치 비율로 분배합니다.
se->load.weight / cfs_rq->load.weight비율이 높을수록(nice가 낮을수록) 더 긴 타임슬라이스를 받습니다. - 11~14행
__sched_period()에서 태스크 수가 많으면sched_min_granularity(0.75ms)를 보장합니다. 예를 들어 태스크 16개일 때 주기는16 * 0.75ms = 12ms가 됩니다.
예제 계산
타임슬라이스 계산 예제
4개 프로세스 (모두 nice 0, weight=1024)가 실행 중일 때:
- Period = sched_latency = 6ms
- 각 프로세스 타임슬라이스 = 6ms / 4 = 1.5ms
Nice -10 (weight=9548) 프로세스 1개 + Nice 0 프로세스 3개:
- 총 가중치 = 9548 + 1024×3 = 12620
- Nice -10 프로세스: 6ms × (9548/12620) ≈ 4.5ms
- Nice 0 프로세스 각각: 6ms × (1024/12620) ≈ 0.5ms
타임슬라이스 계산 시뮬레이션 (코드)
/* CFS 타임슬라이스 계산 시뮬레이터 */
#include <stdio.h>
/* 커널 기본 값 */
#define SCHED_LATENCY_NS 6000000 /* 6ms */
#define MIN_GRANULARITY_NS 750000 /* 0.75ms */
#define NICE_0_LOAD 1024
/* Nice 값 → Weight 변환 테이블 (일부) */
const unsigned long sched_prio_to_weight[] = {
88761, /* nice -20 */
71755, 56483, 46273, 36291,
29154, 23254, 18705, 14949, 11916, /* -10 */
9548, 7620, 6100, 4904, 3906,
3121, 2501, 1991, 1586, 1277,
1024, /* nice 0 (기준) */
820, 655, 526, 423,
335, 272, 215, 172, 137,
110, 87, 70, 56, 45,
36, 29, 23, 18, 15, /* nice +19 */
};
/* 타임슬라이스 계산 */
void calculate_timeslice(int *nice_values, int nr_tasks)
{
unsigned long total_weight = 0;
unsigned long period;
int i;
/* 1. 총 가중치 계산 */
for (i = 0; i < nr_tasks; i++) {
int nice = nice_values[i];
total_weight += sched_prio_to_weight[nice + 20]; /* nice -20 ~ +19 → 인덱스 0~39 */
}
/* 2. Period 결정 (nr_latency 고려) */
int nr_latency = SCHED_LATENCY_NS / MIN_GRANULARITY_NS; /* 8 */
if (nr_tasks > nr_latency)
period = nr_tasks * MIN_GRANULARITY_NS; /* 태스크 많으면 확장 */
else
period = SCHED_LATENCY_NS;
printf("\\n=== CFS 타임슬라이스 시뮬레이션 ===\\n");
printf("프로세스 수: %d, Period: %lu ns (%.2f ms)\\n",
nr_tasks, period, period / 1000000.0);
printf("총 가중치: %lu\\n\\n", total_weight);
/* 3. 각 태스크의 타임슬라이스 계산 */
for (i = 0; i < nr_tasks; i++) {
int nice = nice_values[i];
unsigned long weight = sched_prio_to_weight[nice + 20];
unsigned long slice_ns = (period * weight) / total_weight;
printf("Task %d: nice=%d, weight=%lu, 타임슬라이스=%lu ns (%.2f ms)\\n",
i, nice, weight, slice_ns, slice_ns / 1000000.0);
printf(" → Period 대비 %.1f%% 할당\\n\\n",
(double)slice_ns * 100 / period);
}
}
int main(void)
{
/* 시나리오 1: 모두 nice 0 */
int scenario1[] = {0, 0, 0, 0};
calculate_timeslice(scenario1, 4);
/* 시나리오 2: nice -10, 0, 0, 0 (우선순위 혼재) */
int scenario2[] = {-10, 0, 0, 0};
calculate_timeslice(scenario2, 4);
/* 시나리오 3: 극단적 차이 (nice -20, +19) */
int scenario3[] = {-20, 19};
calculate_timeslice(scenario3, 2);
return 0;
}
/* 출력 예시:
*
* === CFS 타임슬라이스 시뮬레이션 ===
* 프로세스 수: 4, Period: 6000000 ns (6.00 ms)
* 총 가중치: 4096
*
* Task 0: nice=0, weight=1024, 타임슬라이스=1500000 ns (1.50 ms)
* → Period 대비 25.0% 할당
*
* Task 1: nice=0, weight=1024, 타임슬라이스=1500000 ns (1.50 ms)
* → Period 대비 25.0% 할당
* ...
*
* === 시나리오 2 ===
* Task 0: nice=-10, weight=9548, 타임슬라이스=4539810 ns (4.54 ms)
* → Period 대비 75.7% 할당 ← 높은 우선순위!
*
* Task 1: nice=0, weight=1024, 타임슬라이스=486730 ns (0.49 ms)
* → Period 대비 8.1% 할당
*/
부하 분산 (Load Balancing)
멀티코어 시스템에서 CFS는 CPU 간 태스크를 균등하게 분배합니다.
부하 분산 트리거
- Periodic balancing: 주기적 타이머 (1-100ms 간격)
- Idle balancing: CPU가 유휴 상태(Idle State)로 전환될 때
- Fork balancing: 새 태스크 생성 시 (wake_up_new_task)
- Exec balancing: execve() 호출 시
load_balance 핵심 로직
/* kernel/sched/fair.c */
static int load_balance(int this_cpu, struct rq *this_rq,
struct sched_domain *sd,
enum cpu_idle_type idle)
{
struct rq *busiest;
struct lb_env env = {
.sd = sd,
.dst_cpu = this_cpu,
.dst_rq = this_rq,
.idle = idle,
};
/* 1. 가장 바쁜 CPU 찾기 */
busiest = find_busiest_queue(&env);
if (!busiest)
goto out_balanced;
/* 2. 이동할 태스크 선택 */
env.src_cpu = busiest->cpu;
env.src_rq = busiest;
detach_tasks(&env);
/* 3. 태스크 이동 */
if (!list_empty(&env.tasks))
attach_tasks(&env);
return 1;
out_balanced:
return 0;
}
Load Balancing 오버헤드(Overhead)
부하 분산은 성능 향상에 도움이 되지만, 과도한 분산은 오버헤드를 유발합니다:
- 캐시(Cache) 미스: 태스크 이동 시 L1/L2 캐시 무효화(Invalidation)
- Lock contention: 여러 CPU의 runqueue lock 경쟁
- Migration cost: Context switch + TLB flush
고성능 애플리케이션은 taskset이나 CPU affinity 설정으로 부하 분산을 제한할 수 있습니다.
PELT (Per-Entity Load Tracking)
PELT는 각 태스크와 CPU의 부하를 시간 감쇠 평균으로 추적하는 메커니즘입니다.
PELT 메트릭
| 메트릭 | 의미 | 용도 |
|---|---|---|
util_avg | 평균 활용률 (0-1024) | CPU 용량 대비 부하 계산 |
load_avg | 평균 부하 (가중치 포함) | 부하 분산 결정 |
runnable_avg | 실행 가능 시간 평균 | 스케줄 지연 추정 |
감쇠 계산
/* kernel/sched/pelt.c */
/* 32ms 주기로 절반으로 감쇠 (half-life) */
#define LOAD_AVG_PERIOD 32
#define LOAD_AVG_MAX 47742
static void __update_load_avg_se(u64 now, struct sched_entity *se)
{
u64 delta = now - se->avg.last_update_time;
/* Geometric series로 감쇠 적용 */
se->avg.load_sum = decay_load(se->avg.load_sum, delta);
se->avg.util_sum = decay_load(se->avg.util_sum, delta);
se->avg.load_avg = se->avg.load_sum / LOAD_AVG_MAX;
se->avg.util_avg = se->avg.util_sum / LOAD_AVG_MAX;
}
📊 PELT 메트릭과 부하 분산
PELT (Per-Entity Load Tracking)는 각 태스크와 CPU의 부하를 추적하여 CFS의 부하 분산 결정에 사용됩니다:
- util_avg: 평균 CPU 활용률 (0~1024). 이 값이 높으면 해당 태스크가 CPU 집약적입니다.
- load_avg: nice 값으로 가중된 부하. 부하 분산 시 CPU 간 이동 우선순위 결정에 사용
- runnable_avg: 실행 대기 중인 평균 시간. 런큐(Runqueue) 대기 지연 추정에 사용
확인 방법: /proc/<pid>/sched에서 se.avg.util_avg, se.avg.load_avg 필드로 태스크별 PELT 메트릭 확인 가능
grep "avg\." /proc/self/sched
# se.avg.load_avg : 512
# se.avg.util_avg : 480
# se.avg.runnable_avg : 490
스케줄러 클래스
CFS는 여러 스케줄러 클래스 중 하나입니다.
스케줄러 클래스 우선순위
| 순위 | 클래스 | 정책 | 설명 |
|---|---|---|---|
| 1 | stop_sched_class | - | CPU 정지 태스크 (최고 우선순위) |
| 2 | dl_sched_class | SCHED_DEADLINE | Deadline 스케줄링 (EDF) |
| 3 | rt_sched_class | SCHED_FIFO, SCHED_RR | 실시간 스케줄링 |
| 4 | fair_sched_class | SCHED_NORMAL, SCHED_BATCH | CFS (일반 프로세스) |
| 5 | idle_sched_class | SCHED_IDLE | 유휴 태스크 |
sched_class 구조체
/* kernel/sched/sched.h */
struct sched_class {
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
struct task_struct *(*pick_next_task)(struct rq *rq);
void (*put_prev_task)(struct rq *rq, struct task_struct *p);
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
/* ... */
};
const struct sched_class fair_sched_class = {
.enqueue_task = enqueue_task_fair,
.dequeue_task = dequeue_task_fair,
.pick_next_task = pick_next_task_fair,
.task_tick = task_tick_fair,
/* ... */
};
CFS 그룹 스케줄링
그룹 스케줄링은 프로세스 그룹(cgroup) 단위로 CPU 시간을 공정하게 분배합니다.
그룹 스케줄링 구조
task_group (cgroup)
├─ cfs_rq[CPU0] ← CPU0의 그룹 런큐
│ ├─ Task A (vruntime=100)
│ └─ Task B (vruntime=150)
└─ cfs_rq[CPU1] ← CPU1의 그룹 런큐
└─ Task C (vruntime=80)
sched_entity (그룹 대표)
├─ vruntime: 그룹 전체의 가상 런타임
└─ load: 그룹 내 태스크 부하 합
설정 방법
# cgroup v2에서 CPU 시간 제한
mkdir /sys/fs/cgroup/mygroup
echo "100000 1000000" > /sys/fs/cgroup/mygroup/cpu.max
# 100ms / 1000ms = 10% CPU 시간 제한
echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs
선점 메커니즘
CFS는 다음 조건에서 현재 실행 중인 태스크를 선점합니다.
선점 조건
- Tick 기반 선점:
scheduler_tick()에서check_preempt_tick()호출 - Wake-up 선점: 깨어난 태스크의 vruntime이 현재 태스크보다 충분히 작을 때
- 실시간 태스크: 실시간 태스크가 깨어나면 즉시 선점
check_preempt_tick 구현
/* kernel/sched/fair.c */
static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
ideal_runtime = sched_slice(cfs_rq, curr);
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
/* 타임슬라이스 소진 */
if (delta_exec > ideal_runtime) {
resched_curr(rq_of(cfs_rq));
return;
}
/* leftmost 태스크와 vruntime 차이 확인 */
se = __pick_first_entity(cfs_rq);
delta_exec = curr->vruntime - se->vruntime;
if (delta_exec > sysctl_sched_wakeup_granularity)
resched_curr(rq_of(cfs_rq));
}
scheduler_tick → task_tick_fair 경로
타이머 인터럽트(Timer Interrupt)가 발생하면 scheduler_tick()이 호출되고, 이 함수가 현재 실행 중인 태스크의 타임슬라이스 소진 여부를 점검합니다. 아래는 전체 호출 경로입니다.
scheduler_tick()의 핵심 코드입니다.
/* kernel/sched/core.c */
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
struct rq_flags rf;
sched_clock_tick(); /* 스케줄러 클럭 갱신 */
rq_lock(rq, &rf);
update_rq_clock(rq); /* rq->clock 갱신 */
/* 현재 스케줄링 클래스의 task_tick 호출 */
curr->sched_class->task_tick(rq, curr, 0);
calc_global_load_tick(rq); /* 전역 부하 평균 갱신 */
psi_task_tick(rq); /* PSI(Pressure Stall Info) 갱신 */
rq_unlock(rq, &rf);
perf_event_task_tick();
}
task_tick_fair()는 CFS 스케줄링 클래스의 tick 핸들러로, 모든 sched_entity 계층을 순회하며 갱신합니다.
/* kernel/sched/fair.c */
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;
/* 태스크 그룹(cgroup) 계층 전체를 bottom-up으로 순회 */
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued); /* 각 레벨에서 tick 처리 */
}
if (static_branch_unlikely(&sched_numa_balancing))
task_tick_numa(rq, curr); /* NUMA 밸런싱 tick */
update_misfit_status(curr, rq); /* asymmetric CPU 불균형 감지 */
update_overutilized_status(task_rq(curr));
}
entity_tick()은 update_curr()로 vruntime을 갱신하고, check_preempt_tick()으로 선점 여부를 결정합니다.
static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
/* vruntime 및 부하 가중치 갱신 */
update_curr(cfs_rq);
/* PELT: 지수 이동 평균으로 부하 추적 */
update_load_avg(cfs_rq, curr, UPDATE_TG);
update_cfs_group(curr);
if (cfs_rq->nr_running > 1)
check_preempt_tick(cfs_rq, curr); /* 태스크가 2개 이상일 때만 */
}
check_preempt_tick()이 resched_curr()를 호출하면 현재 태스크의 TIF_NEED_RESCHED 플래그가 설정됩니다. 실제 스케줄러 호출(schedule())은 인터럽트 반환 시점이나 커널-사용자 공간 전환 시점에 일어납니다.
깨우기 선점(Wakeup Preemption) 상세
태스크가 슬립 상태에서 깨어날 때(wake-up), CFS는 현재 실행 중인 태스크를 선점할지 결정합니다. 이 결정은 check_preempt_wakeup()에서 수행됩니다.
/* kernel/sched/fair.c — wakeup 선점 진입점 */
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
struct task_struct *curr = rq->curr;
struct sched_entity *se = &curr->se, *pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
int scale = cfs_rq->nr_running >= sched_nr_latency;
int next_buddy_marked = 0;
/* 현재 태스크가 SCHED_IDLE이면 항상 선점 */
if (unlikely(curr->policy == SCHED_IDLE) &&
likely(p->policy != SCHED_IDLE))
goto preempt;
/* WAKEUP_PREEMPTION 기능 비활성 시 선점 안 함 */
if (!sched_feat(WAKEUP_PREEMPTION))
return;
find_matching_se(&se, &pse); /* 같은 cgroup 레벨로 올라감 */
update_curr(cfs_rq_of(se));
/* NEXT_BUDDY: 방금 깨어난 태스크를 다음 실행 후보로 힌트 */
if (sched_feat(NEXT_BUDDY) && scale && !(wake_flags & WF_FORK)) {
set_next_buddy(pse);
next_buddy_marked = 1;
}
if (!wakeup_preempt_entity(se, pse))
return; /* 선점 조건 미충족 */
preempt:
resched_curr(rq); /* TIF_NEED_RESCHED 설정 */
/* LAST_BUDDY: 선점된 현재 태스크를 나중 실행 후보로 힌트 */
if (sched_feat(LAST_BUDDY) && scale && entity_is_task(se))
set_last_buddy(se);
}
wakeup_preempt_entity()는 두 엔티티의 vruntime 차이가 sysctl_sched_wakeup_granularity보다 클 때 선점을 허용합니다.
/* 깨어난 태스크(s)가 현재 태스크(curr)를 선점할 수 있는지 판단 */
static int
wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se)
{
s64 gran, vdiff = curr->vruntime - se->vruntime;
/* 깨어난 태스크의 vruntime이 더 작지 않으면 선점 불가 */
if (vdiff <= 0)
return 0;
/* vruntime 차이가 granularity보다 커야 선점 허용 */
gran = wakeup_gran(se);
if (vdiff > gran)
return 1;
return 0;
}
sysctl_sched_wakeup_granularity(기본 1ms)를 크게 설정하면 wakeup 선점이 줄어들어 처리량(throughput)이 향상되지만, 대화형 애플리케이션의 반응성이 낮아질 수 있습니다. 낮추면 반응성이 높아지지만 컨텍스트 스위치 오버헤드가 증가합니다.
모니터링
CFS 스케줄러의 동작을 관찰하고 디버깅(Debugging)하는 방법입니다.
🔍 스케줄러 성능 문제 진단 순서
애플리케이션 성능 저하 시 다음 순서로 CFS 관련 문제를 진단하세요:
- 컨텍스트 스위치 빈도 확인:
vmstat 1 # cs 열이 초당 수만~수십만이면 과도한 전환 - 런큐 길이 (load average) 확인:
uptime # load average: 2.50, 3.10, 2.90 # CPU 4코어 시스템에서 2.5 → 62% 활용 (정상) - 프로세스별 CPU 시간 분포:
top -H -p <pid> # TIME+ 열에서 스레드별 CPU 누적 시간 확인 - 비자발적 선점 횟수 (nr_involuntary_switches):
grep involuntary /proc/<pid>/status # 높으면 타임슬라이스 소진으로 자주 선점됨 - CPU migration 횟수:
grep nr_migrations /proc/<pid>/sched # 높으면 부하 분산으로 CPU 간 이동 빈번 (캐시 오염)
/proc 인터페이스
# 프로세스별 스케줄링 통계
cat /proc/1234/sched
# se.sum_exec_runtime : 누적 실행 시간
# se.vruntime : 가상 런타임
# se.nr_migrations : CPU 이동 횟수
# nr_voluntary_switches : 자발적 컨텍스트 스위치
# nr_involuntary_switches: 비자발적 컨텍스트 스위치
# 전체 스케줄러 디버그 정보
cat /proc/sched_debug
# CPU별 런큐 상태, 부하, 태스크 목록
schedstats 활성화
# CONFIG_SCHEDSTATS=y로 커널 빌드 시 사용 가능
cat /proc/schedstat
# 또는 동적 활성화 (커널 4.6+)
echo 1 > /proc/sys/kernel/sched_schedstats
perf 도구
# 스케줄링 이벤트 추적
perf sched record -- sleep 10
perf sched latency
# 출력 예:
# Task | Runtime ms | Switches | Avg delay ms | Max delay ms |
# my_app:1234 | 1245.123 | 512 | 0.123 | 12.456 |
성능 튜닝
CFS 파라미터를 조정하여 워크로드에 맞게 최적화할 수 있습니다.
튜닝 파라미터
| 파라미터 | 경로 | 효과 |
|---|---|---|
| sched_latency_ns | /proc/sys/kernel/sched_latency_ns | ↑ 처리량 증가, 지연 증가 |
| sched_min_granularity_ns | /proc/sys/kernel/sched_min_granularity_ns | ↓ 컨텍스트 스위치 감소 |
| sched_wakeup_granularity_ns | /proc/sys/kernel/sched_wakeup_granularity_ns | ↑ 대화형 성능 향상 |
| sched_migration_cost_ns | /proc/sys/kernel/sched_migration_cost_ns | ↑ 캐시 친화성 증가 |
| sched_nr_migrate | /proc/sys/kernel/sched_nr_migrate | 부하 분산 시 이동 태스크 수 |
워크로드별 권장 설정
대화형 데스크톱
# 낮은 지연, 빠른 응답
echo 2000000 > /proc/sys/kernel/sched_latency_ns # 2ms
echo 500000 > /proc/sys/kernel/sched_min_granularity_ns # 0.5ms
echo 500000 > /proc/sys/kernel/sched_wakeup_granularity_ns # 0.5ms
배치 처리 서버
# 높은 처리량, 지연 허용
echo 24000000 > /proc/sys/kernel/sched_latency_ns # 24ms
echo 3000000 > /proc/sys/kernel/sched_min_granularity_ns # 3ms
echo 4000000 > /proc/sys/kernel/sched_wakeup_granularity_ns # 4ms
CPU Affinity 설정
# taskset으로 CPU 고정 (부하 분산 비활성화)
taskset -c 0-3 ./my_app
# C API
#include <sched.h>
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
sched_setaffinity(0, sizeof(cpuset), &cpuset);
💡 CPU Affinity와 CFS 상호작용
CPU affinity는 부하 분산과 상호작용하여 성능에 영향을 줍니다:
- 캐시 친화성 향상: 태스크를 특정 CPU에 고정하면 L1/L2 캐시 히트율이 증가하여 메모리 접근 속도 향상
- 부하 불균형 위험: 여러 태스크를 같은 CPU에 고정하면 다른 CPU가 유휴 상태여도 부하 분산이 불가능
- NUMA 고려: 원격 NUMA 노드의 CPU에 고정하면 메모리 접근 지연 증가 (200ns → 100ns).
numactl --cpunodebind로 로컬 노드 CPU만 사용 권장 - 실시간 워크로드: 지연시간 민감 태스크는 전용 CPU에 고정하고 (
isolcpus커널 파라미터), 해당 CPU에서 일반 태스크 제외
권장 패턴: 일반 애플리케이션은 affinity 설정하지 않고 커널의 부하 분산에 맡기고, 고성능 컴퓨팅이나 실시간 시스템에서만 선택적으로 사용
CFS Bandwidth Control
cgroup을 통해 그룹의 CPU 사용량을 제한할 수 있습니다.
설정 예제
# cgroup v2
mkdir /sys/fs/cgroup/limited
echo "50000 100000" > /sys/fs/cgroup/limited/cpu.max
# quota=50ms, period=100ms → 50% CPU 제한
echo $$ > /sys/fs/cgroup/limited/cgroup.procs
# cgroup v1
echo 100000 > /sys/fs/cgroup/cpu/limited/cpu.cfs_period_us
echo 50000 > /sys/fs/cgroup/cpu/limited/cpu.cfs_quota_us
Throttling 주의사항
CFS bandwidth control이 활성화되면 그룹이 quota를 소진했을 때 throttling됩니다. 이는 지연 시간 민감 애플리케이션에 영향을 줄 수 있습니다:
/proc/<pid>/sched에서nr_throttled확인/sys/fs/cgroup/cpu.stat에서throttled_time확인
🎯 CFS Bandwidth Control 효과적 사용법
CPU quota 설정은 멀티테넌트 환경이나 리소스 격리(Isolation)가 필요한 경우 유용합니다. 다음 원칙을 따르세요:
- Period는 100ms 권장: 기본값 100ms(100000us)가 대부분 상황에 적합. 더 짧으면 throttling 오버헤드 증가, 더 길면 지연시간 증가
- Quota는 여유있게: 평균 사용량의 120-150% 설정. 버스(Bus)트 트래픽을 수용하면서 폭주 방지
# 평균 30% CPU 사용 → 40-50% quota 설정 echo "40000 100000" > cpu.max - 컨테이너(Container) 단위로 적용: 개별 프로세스보다 컨테이너/서비스 단위로 제한하면 관리 용이. Docker/Kubernetes는
--cpus옵션으로 자동 설정 - 모니터링 필수:
cpu.stat의nr_throttled,throttled_time을 주기적으로 확인하여 quota 부족 감지while true; do grep throttled /sys/fs/cgroup/myapp/cpu.stat sleep 5 done - 실시간 워크로드는 제외: SCHED_FIFO/SCHED_DEADLINE은 bandwidth control 영향받지 않으므로, 지연시간 민감 태스크는 RT 정책 사용
실제 사례: Kubernetes에서 requests.cpu=0.5 → 50ms quota, limits.cpu=2 → 200ms quota 매핑(Mapping). limit 초과 시 throttling 발생
다른 스케줄러와 비교
| 항목 | CFS | O(1) | RT (FIFO/RR) | SCHED_DEADLINE |
|---|---|---|---|---|
| 용도 | 일반 프로세스 | 일반 프로세스 (구식) | 실시간 태스크 | Deadline 보장 |
| 알고리즘 | Fair sharing | 우선순위 큐 | 고정 우선순위 | EDF (Earliest Deadline First) |
| 복잡도 | O(log N) | O(1) | O(N) | O(log N) |
| 지연시간 | 동적 (~ms) | 고정 (5-800ms) | 즉시 | Deadline 내 보장 |
| 공정성 | 매우 높음 | 중간 | 없음 (우선순위) | Deadline 기반 |
🎯 실시간 워크로드를 위한 대안
CFS는 공정성을 우선하므로, 지연시간 보장이 필요한 실시간 워크로드에는 적합하지 않습니다. 다음 대안을 고려하세요:
- SCHED_FIFO / SCHED_RR: 고정 우선순위 실시간 스케줄링. 오디오/비디오 처리, 로봇 제어 등에 적합
struct sched_param param = { .sched_priority = 50 }; sched_setscheduler(0, SCHED_FIFO, ¶m); - SCHED_DEADLINE: 주기적 태스크의 데드라인 보장. 산업 제어, 통신 프로토콜 구현에 적합
struct sched_attr attr = { .sched_policy = SCHED_DEADLINE, .sched_runtime = 10 * 1000 * 1000, /* 10ms */ .sched_deadline = 30 * 1000 * 1000, /* 30ms */ .sched_period = 30 * 1000 * 1000 /* 30ms */ }; sched_setattr(0, &attr, 0); - PREEMPT_RT 패치(Patch): 전체 커널을 선점 가능하게 만들어 지연시간을 마이크로초 단위로 감소 (산업용 Linux)
- CPU 격리 (isolcpus): 특정 CPU를 일반 스케줄링에서 제외하여 전용 사용
# 커널 부트 파라미터: isolcpus=2,3 # CPU 2, 3은 CFS 부하 분산에서 제외됨
vruntime 타임라인
vruntime은 CFS의 공정성을 보장하는 핵심 메트릭입니다. 이 섹션에서는 min_vruntime의 진행 방식, 새 태스크의 vruntime 초기화, 그리고 타임라인 전체를 시각적으로 추적합니다.
min_vruntime 진행
min_vruntime은 CFS 런큐에서 모든 태스크의 vruntime 중 최솟값을 단조 증가하며 추적하는 변수입니다. 새로 진입하는 태스크나 슬립(Sleep) 후 복귀하는 태스크의 vruntime 초기화 기준점 역할을 합니다.
/* kernel/sched/fair.c */
static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
struct rb_node *leftmost = rb_first_cached(&cfs_rq->tasks_timeline);
u64 vruntime = cfs_rq->min_vruntime;
if (curr) {
if (curr->on_rq)
vruntime = curr->vruntime;
else
curr = NULL;
}
if (leftmost) {
struct sched_entity *se = rb_entry(leftmost,
struct sched_entity,
run_node);
if (!curr)
vruntime = se->vruntime;
else
vruntime = min_vruntime(vruntime, se->vruntime);
}
/* min_vruntime은 단조 증가만 허용 */
cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
}
새 태스크 vruntime 초기화
fork()로 생성된 새 태스크는 place_entity()에서 vruntime이 초기화됩니다. 기본적으로 min_vruntime을 기준으로 설정하되, sched_child_runs_first 옵션에 따라 부모/자식의 실행 순서가 결정됩니다.
/* kernel/sched/fair.c */
static void place_entity(struct cfs_rq *cfs_rq,
struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
if (initial && sched_feat(START_DEBIT))
/* 새 태스크에 반 주기만큼 패널티 부여 */
vruntime += sched_vslice(cfs_rq, se);
/* 슬립 복귀 시 보상: thresh만큼 뒤로 당김 */
if (!initial) {
unsigned long thresh = sysctl_sched_latency;
if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1; /* 절반만 보상 */
vruntime -= thresh;
}
/* min_vruntime보다 뒤로 가지 않도록 보장 */
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
sched_vslice()는 해당 태스크가 한 번의 스케줄링 주기에서 소비할 vruntime을 예측하여 패널티로 부여합니다. 이로써 새 태스크는 기존 태스크들보다 약간 뒤에 위치하게 됩니다.
슬립/웨이크업 시 vruntime 보상
오랫동안 슬립한 태스크가 깨어났을 때, vruntime이 너무 작으면 다른 태스크를 장시간 선점할 수 있습니다. CFS는 GENTLE_FAIR_SLEEPERS 기능으로 이를 제한합니다.
| 시나리오 | vruntime 초기화 | 설명 |
|---|---|---|
| 새 태스크 (fork) | min_vruntime + sched_vslice() | START_DEBIT 패널티 적용 |
| 슬립 복귀 | max(기존, min_vruntime - latency/2) | GENTLE_FAIR_SLEEPERS 보상 |
| CPU 마이그레이션 | 원본 CPU의 min_vruntime 기준 보정 | CPU 간 vruntime 차이 보정 |
| 그룹 간 이동 | 대상 cfs_rq의 min_vruntime 기준 | 그룹 스케줄링 공정성 유지 |
max_vruntime()으로 보호합니다. 만약 모든 태스크가 디큐(Dequeue)되어 런큐가 비면, 다음에 인큐(Enqueue)되는 태스크의 vruntime이 min_vruntime으로 설정되어 공정성이 유지됩니다.
그룹 스케줄링
CFS 그룹 스케줄링은 task_group 계층 구조를 통해 CPU 시간을 계층적으로 분배합니다. 각 그룹은 독립적인 sched_entity와 cfs_rq를 가지며, 상위 그룹의 런큐에 스케줄링 엔티티로 참여합니다.
task_group 계층 구조
/* kernel/sched/sched.h */
struct task_group {
struct cgroup_subsys_state css;
/* 각 CPU별 스케줄링 엔티티와 CFS 런큐 */
struct sched_entity **se; /* se[cpu] — 상위 cfs_rq에 참여 */
struct cfs_rq **cfs_rq; /* cfs_rq[cpu] — 그룹 내부 런큐 */
unsigned long shares; /* cpu.shares (비율 기반 분배) */
struct rcu_head rcu;
struct list_head list;
struct task_group *parent;
struct list_head siblings;
struct list_head children;
};
shares 분배 알고리즘
cpu.shares 값은 형제 그룹 간의 상대적 CPU 시간 비율을 결정합니다. 기본값은 1024 (= NICE_0_LOAD)입니다.
/* 그룹 A: shares=2048, 그룹 B: shares=1024인 경우 */
/* 그룹 A는 그룹 B보다 2배의 CPU 시간을 할당받음 */
/* kernel/sched/fair.c — shares → weight 변환 */
static void update_cfs_group(struct sched_entity *se)
{
struct cfs_rq *gcfs_rq = group_cfs_rq(se);
unsigned long shares;
if (!gcfs_rq)
return;
shares = calc_group_shares(gcfs_rq);
reweight_entity(cfs_rq_of(se), se, shares);
}
/* shares 계산: 그룹 내 부하 비율에 따라 CPU별로 분배 */
static long calc_group_shares(struct cfs_rq *cfs_rq)
{
long tg_weight, tg_shares, load, shares;
struct task_group *tg = cfs_rq->tg;
tg_shares = READ_ONCE(tg->shares);
load = max(scale_load_down(cfs_rq->load.weight), cfs_rq->avg.load_avg);
tg_weight = atomic_long_read(&tg->load_avg);
shares = (tg_shares * load) / tg_weight;
return clamp_t(long, shares, MIN_SHARES, tg_shares);
}
cgroup v2에서의 그룹 스케줄링 설정
# 그룹 생성 및 shares 설정
mkdir -p /sys/fs/cgroup/webserver
mkdir -p /sys/fs/cgroup/batch
# cpu.weight (cgroup v2, 기본값 100)
echo 200 > /sys/fs/cgroup/webserver/cpu.weight # 높은 비율
echo 50 > /sys/fs/cgroup/batch/cpu.weight # 낮은 비율
# 프로세스 배치
echo $$ > /sys/fs/cgroup/webserver/cgroup.procs
# 현재 weight 확인
cat /sys/fs/cgroup/webserver/cpu.weight
# 200
cpu.shares(기본 1024)는 v2에서 cpu.weight(기본 100)로 변경되었습니다. 내부 동작은 동일하지만, v2는 단일 계층 구조로 관리가 더 직관적입니다. weight = shares * 100 / 1024 공식으로 대략적 변환이 가능합니다.
EEVDF vs CFS 비교
Linux 6.6부터 CFS는 EEVDF(Earliest Eligible Virtual Deadline First) 알고리즘으로 대체되었습니다. EEVDF는 CFS의 공정성 원칙을 유지하면서 지연시간 보장을 추가합니다.
핵심 개념: Eligible과 Virtual Deadline
| 개념 | CFS | EEVDF |
|---|---|---|
| 선택 기준 | 최소 vruntime | eligible 중 최소 virtual deadline |
| 공정성 | vruntime 기반 | lag 기반 (더 정밀) |
| 지연시간 | 보장 없음 | O(1) 지연 보장 |
| 선점 | wakeup_granularity 기반 | deadline 비교 |
| 복잡도 | O(log N) | O(log N) |
| 구현 위치 | kernel/sched/fair.c | kernel/sched/fair.c (동일) |
Lag (지연) 기반 선택
EEVDF에서 lag은 태스크가 받아야 할 CPU 시간과 실제 받은 CPU 시간의 차이입니다.
/* kernel/sched/fair.c (Linux 6.6+) */
/* lag = 기대 서비스 시간 - 실제 서비스 시간 */
/* lag > 0: CPU를 덜 받은 태스크 (eligible) */
/* lag < 0: CPU를 더 받은 태스크 (not eligible) */
static void update_entity_lag(struct cfs_rq *cfs_rq,
struct sched_entity *se)
{
s64 lag, limit;
SCHED_WARN_ON(!se->on_rq);
lag = avg_vruntime(cfs_rq) - se->vruntime;
limit = calc_delta_fair(max_t(u64,
2 * se->slice, TICK_NSEC), se);
se->vlag = clamp(lag, -limit, limit);
}
/* Virtual Deadline 계산 */
/* deadline = vruntime + (slice / weight) * NICE_0_LOAD */
static u64 vruntime_deadline(struct sched_entity *se)
{
return se->vruntime + calc_delta_fair(se->slice, se);
}
EEVDF 선택 과정
/* pick_eevdf(): eligible 태스크 중 가장 이른 deadline 선택 */
static struct sched_entity *
pick_eevdf(struct cfs_rq *cfs_rq)
{
struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
struct sched_entity *best = NULL;
while (node) {
struct sched_entity *se = __node_2_se(node);
/* eligible 조건: vruntime <= avg_vruntime */
if (entity_eligible(cfs_rq, se)) {
/* deadline이 가장 이른 eligible 태스크 선택 */
if (!best || deadline_gt(best, se))
best = se;
node = node->rb_left;
} else {
node = node->rb_right;
}
}
return best;
}
sched_wakeup_granularity_ns는 EEVDF에서 제거되었습니다. 대신 태스크의 slice 값이 deadline 계산에 사용되며, sched_attr.sched_runtime으로 제어할 수 있습니다.
EEVDF 실무 활용
EEVDF에서 태스크의 slice 값을 조정하면 지연시간 특성을 제어할 수 있습니다.
/* EEVDF에서 태스크 slice 설정 (Linux 6.6+) */
#include <sched.h>
#include <linux/sched.h>
struct sched_attr attr = {
.size = sizeof(struct sched_attr),
.sched_policy = SCHED_NORMAL,
.sched_nice = 0,
/* sched_runtime은 EEVDF의 slice로 해석됨 */
.sched_runtime = 3000000, /* 3ms slice → 짧은 deadline */
};
/* 짧은 slice = 짧은 deadline = 더 빠른 스케줄링 */
/* 긴 slice = 긴 deadline = 더 큰 타임슬라이스 */
sched_setattr(0, &attr, 0);
| slice 값 | 동작 | 적합한 워크로드 |
|---|---|---|
| 1ms (작은 값) | 짧은 deadline, 빠른 스케줄링, 잦은 선점 | 대화형 UI, 오디오 렌더링 |
| 3ms (기본) | 균형잡힌 deadline과 타임슬라이스 | 일반 워크로드 |
| 10ms (큰 값) | 긴 deadline, 큰 타임슬라이스, 적은 선점 | 컴파일, 배치 처리 |
| 100ms (매우 큰 값) | 거의 선점되지 않음, 최대 처리량 | HPC, 과학 컴퓨팅 |
sched_latency_ns와 sched_min_granularity_ns는 여전히 존재하지만, 선점 결정에서의 역할이 달라졌습니다. sched_wakeup_granularity_ns는 완전히 제거되어 /proc/sys에서 접근할 수 없습니다. EEVDF에서는 deadline 비교로 선점을 결정하므로 별도의 granularity가 불필요합니다.
CFS 대역폭 제어
CFS Bandwidth Control은 cgroup 단위로 CPU 사용량을 하드 리밋합니다. quota와 period 파라미터로 동작하며, quota가 소진되면 그룹 전체가 스로틀링됩니다.
대역폭 제어 핵심 구조체
/* kernel/sched/sched.h */
struct cfs_bandwidth {
raw_spinlock_t lock;
ktime_t period; /* 주기 (기본 100ms) */
u64 quota; /* 주기당 허용 CPU 시간 */
u64 runtime; /* 남은 런타임 */
u64 burst; /* 버스트 허용량 (Linux 5.14+) */
s64 hierarchical_quota;
u8 idle;
u8 period_active;
struct hrtimer period_timer; /* 주기 리셋 타이머 */
struct hrtimer slack_timer; /* 여유 런타임 회수 */
struct list_head throttled_cfs_rq;
int nr_periods; /* 통계: 총 주기 수 */
int nr_throttled; /* 통계: 스로틀 횟수 */
u64 throttled_time; /* 통계: 스로틀 시간 합 */
};
코드 설명
- 3행
cfs_bandwidth구조체는kernel/sched/sched.h에 정의되며, cgroup의 CPU 대역폭 제어 상태를 관리합니다.task_group구조체의 멤버로 포함됩니다. - 5행
period: 대역폭 측정 주기로 기본 100ms입니다. cgroup v2에서cpu.max의 두 번째 값으로 설정합니다 (예:"50000 100000"은 100ms 주기에 50ms quota). - 6행
quota: 한 주기 내 허용되는 최대 CPU 시간(나노초)입니다. 멀티코어 환경에서quota > period가 가능하며, 이는 복수 CPU 사용을 의미합니다. - 7행
runtime: 현재 주기에서 남은 런타임입니다. 각 CPU의cfs_rq->runtime_remaining이 소진되면 이 글로벌 풀에서 보충받습니다. - 8행
burst: Linux 5.14+에서 추가된 필드로, 사용하지 않은 quota를 다음 주기로 이월할 수 있는 최대량입니다. 버스트 트래픽 시 스로틀링을 줄여줍니다. - 12~13행
period_timer는 주기 만료 시 quota를 리필하고,slack_timer는 CPU에서 사용하지 않은 여유 런타임을 글로벌 풀로 회수합니다.
스로틀링 메커니즘
/* kernel/sched/fair.c — quota 소진 시 스로틀링 */
static bool check_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
if (!cfs_bandwidth_used())
return false;
if (likely(!cfs_rq->runtime_enabled ||
cfs_rq->runtime_remaining > 0))
return false;
/* 글로벌 풀에서 런타임 보충 시도 */
if (cfs_rq_throttled(cfs_rq))
return true;
throttle_cfs_rq(cfs_rq);
return true;
}
/* 스로틀 해제: period 타이머가 런타임 리필 */
static int do_sched_cfs_period_timer(struct cfs_bandwidth *cfs_b,
int overrun)
{
/* 새 period 시작: quota 리필 */
cfs_b->runtime = cfs_b->quota;
/* 스로틀된 cfs_rq에 런타임 분배 */
while (throttled) {
distribute_cfs_runtime(cfs_b);
unthrottle_cfs_rq(cfs_rq);
}
return 0;
}
코드 설명
- 2행
check_cfs_rq_runtime()은update_curr()가 런타임을 소비한 후 호출되어 quota 소진 여부를 확인합니다.kernel/sched/fair.c에 정의되어 있습니다. - 3~4행
cfs_bandwidth_used(): 시스템에 대역폭 제어가 설정된 cgroup이 하나라도 있는지 확인하는 정적 키(static key) 최적화입니다. 없으면 즉시 false를 반환하여 오버헤드를 제거합니다. - 6~8행
runtime_remaining > 0이면 아직 quota가 남아 있으므로 정상 실행을 계속합니다. 이 값은 CPU 로컬 cfs_rq에 분배된 런타임으로, 소진 시 글로벌 풀에서 보충을 시도합니다. - 14행
throttle_cfs_rq(): 런큐를 스로틀 상태로 전환합니다. 해당 cfs_rq의 모든 태스크를 실행 대기열에서 제거하고,cfs_bandwidth.throttled_cfs_rq리스트에 연결합니다. - 19행
do_sched_cfs_period_timer()는period_timerhrtimer가 만료될 때 호출됩니다.runtime을quota값으로 리필하여 새 주기를 시작합니다. - 24~26행
distribute_cfs_runtime()으로 각 CPU의 스로틀된 cfs_rq에 런타임을 분배하고,unthrottle_cfs_rq()로 태스크를 런큐에 재삽입하여 실행을 재개합니다.
멀티코어 환경에서의 Quota 소비
CFS bandwidth의 quota는 모든 CPU에서 공유됩니다. 멀티코어에서 2개 스레드(Thread)가 동시 실행되면 quota가 2배 속도로 소비됩니다.
# 예: cpu.max = "100000 100000" (100ms quota / 100ms period)
# 1 CPU 사용 시: 100ms 동안 실행 가능 → 100% CPU
# 2 CPU 사용 시: 50ms 동안 실행 → 각 CPU 50ms → 합계 100ms = 100% CPU
# 4 CPU 사용 시: 25ms 동안 실행 → 각 CPU 25ms → 합계 100ms = 100% CPU
# 4 CPU를 모두 활용하려면:
echo "400000 100000" > /sys/fs/cgroup/myapp/cpu.max
# quota=400ms → 4코어 × 100ms = 400% CPU (= 4코어 전부 사용 가능)
resources.limits.cpu: "2"는 내부적으로 cpu.max = "200000 100000"으로 변환됩니다. 이는 2코어 분량의 CPU 시간을 의미하며, 단일 코어에서 200ms 실행하거나 2코어에서 100ms씩 실행할 수 있습니다. CPU limit을 정수가 아닌 소수(예: 0.5)로 설정하면 "50000 100000"이 되어 반 코어 분량만 사용 가능합니다.
Burst 기능 (Linux 5.14+)
cpu.max.burst는 사용하지 않은 quota를 누적하여 일시적으로 제한을 초과할 수 있게 합니다.
# 버스트 설정 (미사용 quota를 최대 20ms까지 누적)
echo "50000 100000" > /sys/fs/cgroup/myapp/cpu.max
echo 20000 > /sys/fs/cgroup/myapp/cpu.max.burst
# 통계 확인
cat /sys/fs/cgroup/myapp/cpu.stat
# usage_usec 12345678
# nr_periods 1500
# nr_throttled 42
# throttled_usec 2100000
# nr_bursts 15
# burst_usec 150000
pick_next_task_fair() 코드 워크스루
pick_next_task_fair()는 CFS 스케줄러의 핵심 진입점(Entry Point)으로, 다음에 실행할 태스크를 선택합니다. 이 함수는 최적화 경로(simple path)와 그룹 스케줄링 경로를 분리하여 성능을 극대화합니다.
전체 흐름
/* kernel/sched/fair.c */
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev,
struct rq_flags *rf)
{
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;
int new_tasks;
again:
if (!sched_fair_runnable(rq))
goto idle;
#ifdef CONFIG_FAIR_GROUP_SCHED
if (!prev || prev->sched_class != &fair_sched_class)
goto simple;
/* 이전 태스크가 CFS였으면 put_prev 처리 */
do {
struct sched_entity *curr = cfs_rq->curr;
if (curr && curr->on_rq)
update_curr(cfs_rq);
else
curr = NULL;
if (unlikely(check_cfs_rq_runtime(cfs_rq))) {
cfs_rq = &rq->cfs;
if (!cfs_rq->nr_running)
goto idle;
goto simple;
}
} while (cfs_rq);
p = task_of(se);
simple:
#endif
if (prev)
put_prev_task(rq, prev);
/* rb_first_cached: O(1)으로 leftmost 노드 접근 */
do {
se = pick_next_entity(cfs_rq);
set_next_entity(cfs_rq, se);
cfs_rq = group_cfs_rq(se);
} while (cfs_rq);
p = task_of(se);
done: __maybe_unused;
return p;
idle:
/* CFS 태스크 없음 → 다른 클래스 시도 또는 idle */
new_tasks = newidle_balance(rq, rf);
if (new_tasks > 0)
goto again;
return NULL;
}
코드 설명
- 3행
pick_next_task_fair()는__schedule()에서 호출되는 CFS의 핵심 진입점으로,kernel/sched/fair.c에 정의되어 있습니다.fair_sched_class.pick_next_task함수 포인터에 등록됩니다. - 10~11행
sched_fair_runnable(): CFS 런큐에 실행 가능한 태스크가 있는지 확인합니다. 없으면idle레이블로 분기하여newidle_balance()를 시도합니다. - 13~15행
CONFIG_FAIR_GROUP_SCHED경로: 이전 태스크가 CFS가 아니면simple경로로 건너뜁니다. CFS 태스크였으면 그룹 계층을 순회하며update_curr()를 호출합니다. - 25행
check_cfs_rq_runtime(): 대역폭 제어에 의해 런큐가 스로틀되었는지 확인합니다. 스로틀 시 루트 cfs_rq로 복귀하고, 태스크가 없으면 idle로 분기합니다. - 38~42행
simple경로의 핵심 루프입니다.pick_next_entity()로 leftmost 엔티티를 선택하고,set_next_entity()로 현재 실행 엔티티로 설정합니다. 그룹 엔티티이면group_cfs_rq()가 하위 런큐를 반환하여 루프를 계속합니다. - 51행
newidle_balance(): 현재 CPU에 CFS 태스크가 없을 때 다른 CPU에서 태스크를 pull합니다. 성공하면again으로 돌아가 다시 태스크를 선택합니다. 이는 CPU 유휴 시간을 줄이는 핵심 메커니즘입니다.
skip/next/last 힌트
pick_next_entity()에서는 leftmost 외에 skip, next, last 힌트를 통해 선택을 조정합니다.
/* kernel/sched/fair.c */
static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
struct sched_entity *left = __pick_first_entity(cfs_rq);
struct sched_entity *se;
/* leftmost가 기본 후보 */
se = left;
/* skip: 특정 엔티티를 건너뛰라는 힌트 */
if (cfs_rq->skip == se) {
struct sched_entity *second = __pick_next_entity(se);
if (second && wakeup_preempt_entity(second, left) < 1)
se = second;
}
/* last: 마지막으로 깨운 엔티티 (캐시 친화성) */
if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
se = cfs_rq->last;
/* next: wakeup으로 다음에 실행해야 할 엔티티 */
if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
se = cfs_rq->next;
clear_buddies(cfs_rq, se);
return se;
}
rb_first_cached()는 RB-tree의 leftmost 노드를 캐싱하여 O(1)에 접근합니다. enqueue_entity()에서 삽입 시 leftmost 여부를 판별하여 캐시를 갱신합니다. 따라서 pick_next_entity()의 실질적 시간 복잡도는 O(1)입니다 (트리 탐색 없이 즉시 접근).
set_next_entity
/* 선택된 엔티티를 현재 실행 엔티티로 설정 */
static void set_next_entity(struct cfs_rq *cfs_rq,
struct sched_entity *se)
{
/* RB-tree에서 제거 (현재 실행 중이므로 트리에 없어도 됨) */
if (se->on_rq) {
__dequeue_entity(cfs_rq, se);
update_load_avg(cfs_rq, se, UPDATE_TG);
}
update_stats_curr_start(cfs_rq, se);
cfs_rq->curr = se;
se->prev_sum_exec_runtime = se->sum_exec_runtime;
}
로드 밸런싱
멀티코어 시스템에서 CFS의 로드 밸런싱은 sched_domain 계층 구조를 통해 단계적으로 수행됩니다. CPU 토폴로지(SMT, MC, NUMA)에 따라 밸런싱 주기와 비용이 달라집니다.
sched_domain 계층
/* include/linux/sched/topology.h */
struct sched_domain {
struct sched_domain *parent; /* 상위 도메인 */
struct sched_domain *child; /* 하위 도메인 */
struct sched_group *groups; /* 도메인 내 CPU 그룹 */
unsigned long min_interval; /* 최소 밸런싱 간격 */
unsigned long max_interval; /* 최대 밸런싱 간격 */
unsigned int busy_factor; /* 바쁠 때 간격 확장 배율 */
unsigned int imbalance_pct; /* 불균형 임계값 (%) */
unsigned int cache_nice_tries;
unsigned long flags;
int level; /* 도메인 레벨 */
char *name; /* SMT, MC, NUMA 등 */
};
Pull vs Push 마이그레이션
| 유형 | 트리거 | 동작 | 함수 |
|---|---|---|---|
| Pull | CPU가 유휴 상태 진입 | 바쁜 CPU에서 태스크 가져옴 | newidle_balance() |
| Pull | 주기적 밸런싱 타이머 | 가장 바쁜 그룹에서 태스크 이동 | load_balance() |
| Push | 태스크 wake-up | 최적 CPU 선택하여 배치 | select_task_rq_fair() |
| Push | 태스크 fork/exec | 부하 낮은 CPU로 배치 | wake_up_new_task() |
select_task_rq_fair: 최적 CPU 선택
/* kernel/sched/fair.c */
static int select_task_rq_fair(struct task_struct *p,
int prev_cpu, int wake_flags)
{
int new_cpu = prev_cpu;
int want_affine = 0;
struct sched_domain *sd;
/* 1. 친화성: 깨운 CPU와 같은 도메인 선호 */
if (wake_flags & WF_TTWU) {
int waker_cpu = smp_processor_id();
want_affine = cpumask_test_cpu(waker_cpu,
p->cpus_ptr);
}
/* 2. sched_domain 탐색: 가장 에너지 효율적인 CPU */
for_each_domain(prev_cpu, sd) {
if (want_affine && cpumask_test_cpu(prev_cpu,
sched_domain_span(sd)))
break;
}
/* 3. 유휴 CPU가 있으면 우선 선택 */
new_cpu = find_idlest_cpu(sd, p, prev_cpu, new_cpu);
return new_cpu;
}
select_idle_sibling / Wake-Affine 상세
select_task_rq_fair()가 wake-affine 경로를 선택하면 select_idle_sibling()을 호출해 동일 LLC(Last-Level Cache) 도메인 내에서 유휴 CPU를 찾습니다. 이 함수는 캐시 친화성을 최대로 유지하면서 불필요한 마이그레이션 비용을 줄이는 핵심 로직입니다.
/* kernel/sched/fair.c (단순화) */
static int select_idle_sibling(struct task_struct *p, int prev, int target)
{
struct sched_domain *sd;
int i, recent_used_cpu;
/* ① 이전 CPU가 유휴 상태이면 즉시 반환 (캐시 친화성 최적) */
if (idle_cpu(target) && asym_fits_cpu(p, target))
return target;
/* ② 최근 사용 CPU가 유휴인지 확인 */
recent_used_cpu = p->recent_used_cpu;
if (recent_used_cpu != prev && recent_used_cpu != target
&& cpumask_test_cpu(recent_used_cpu, p->cpus_ptr)
&& idle_cpu(recent_used_cpu)
&& cpus_share_cache(recent_used_cpu, target))
return recent_used_cpu;
/* ③ LLC 공유 도메인 내에서 유휴 CPU 스캔 (SIS_PROP) */
sd = rcu_dereference(per_cpu(sd_llc, target));
if (sd) {
i = select_idle_cpu(p, sd, has_idle_core(sd), target);
if ((unsigned)int i <= (unsigned)nr_cpumask_bits)
return i;
}
return target;
}
SIS_PROP(Proportional Idle Scanning)는 탐색 비용을 최근 유휴 시간에 비례해 제한합니다. CPU가 자주 유휴라면 더 많이 탐색하고, 바쁜 시스템에서는 탐색을 조기 종료해 오버헤드를 줄입니다.
/* select_idle_cpu — SIS_PROP 비례 스캔 */
static int select_idle_cpu(struct task_struct *p, struct sched_domain *sd,
bool has_idle_core, int target)
{
u64 avg_cost, avg_idle, time, cost;
int cpu, nr = 32; /* SIS_PROP 비활성 시 최대 탐색 횟수 */
if (sched_feat(SIS_PROP)) {
avg_idle = this_rq()->avg_idle; /* 평균 유휴 시간 (ns) */
avg_cost = sd->avg_scan_cost + 1; /* 평균 탐색 비용 (ns) */
nr = div64_u64(avg_idle, avg_cost); /* 허용 탐색 횟수 비례 계산 */
nr = clamp(nr, 1U, (unsigned)llc_weight(sd));
}
for_each_cpu_wrap(cpu, sched_domain_span(sd), target + 1) {
if (!--nr)
break;
if (idle_cpu(cpu))
return cpu;
}
return -1;
}
wake_affine()는 태스크를 깨운 CPU(waker)와 이전에 실행되던 CPU(prev) 중 어느 쪽으로 배치할지 결정합니다. 두 CPU의 예상 대기 시간을 비교해 더 빠를 쪽을 선택합니다.
static int wake_affine(struct sched_domain *sd, struct task_struct *p,
int this_cpu, int prev_cpu, int sync)
{
int ret = wake_affine_idle(this_cpu, prev_cpu, sync);
if (ret == nr_cpumask_bits)
ret = wake_affine_weight(sd, p, this_cpu, prev_cpu, sync);
schedstat_inc(sd->ttwu_move_affine);
return (ret == this_cpu) ? WA_AFFINE : WA_IDLE;
}
wake_affine_weight()는 두 CPU의 런큐 부하를 비교해 이 결정을 내립니다.
/proc/sys/kernel/sched_migration_cost_ns(기본 500us)를 높이면 마이그레이션 빈도가 줄어 캐시 친화성이 향상되지만, 부하 불균형 기간이 길어질 수 있습니다.
Energy Aware Scheduling (EAS)
ARM big.LITTLE 같은 이기종 CPU 시스템에서 CFS는 EAS(Energy Aware Scheduling)를 통해 성능과 전력 소비를 최적화합니다.
/* kernel/sched/fair.c — EAS CPU 선택 */
static int find_energy_efficient_cpu(struct task_struct *p,
int prev_cpu)
{
unsigned long best_delta = ULONG_MAX;
int best_cpu = -1;
struct perf_domain *pd;
/* 각 성능 도메인(big/LITTLE)별로 에너지 계산 */
for_each_perf_domain(pd) {
unsigned long cur_delta;
int cpu;
for_each_cpu(cpu, perf_domain_span(pd)) {
/* 태스크 배치 시 에너지 증분 계산 */
cur_delta = compute_energy(p, cpu, pd);
if (cur_delta < best_delta) {
best_delta = cur_delta;
best_cpu = cpu;
}
}
}
return best_cpu;
}
코드 설명
- 2행
find_energy_efficient_cpu()는 EAS(Energy Aware Scheduling)의 핵심 함수로,kernel/sched/fair.c에 정의되어 있습니다.select_task_rq_fair()에서 EAS가 활성이고 시스템이 overutilized 상태가 아닐 때 호출됩니다. - 4행
best_delta = ULONG_MAX: 에너지 증분의 초깃값입니다. 각 CPU에 태스크를 가상 배치했을 때의 에너지 증분과 비교하여 최솟값을 추적합니다. - 8행
for_each_perf_domain(pd): ARM big.LITTLE 시스템에서 성능 도메인(Performance Domain)별로 순회합니다. 각 도메인은 동일 주파수/전압으로 동작하는 CPU 그룹입니다. - 13행
compute_energy(): Energy Model(EM)을 기반으로 해당 CPU에 태스크를 배치했을 때 시스템 전체의 에너지 증분(밀리와트)을 계산합니다. PELT의util_avg값과 CPU 용량, OPP(Operating Performance Point) 테이블을 사용합니다. - 14~17행에너지 증분이 현재까지의 최솟값보다 작으면 해당 CPU를 최적 후보로 갱신합니다. 경량 태스크는 LITTLE 코어가, 중량 태스크는 big 코어가 에너지 효율적으로 선택되는 원리입니다.
| 시나리오 | EAS 비활성 | EAS 활성 |
|---|---|---|
| 경량 태스크 (10% CPU) | 아무 유휴 CPU 배치 | LITTLE 코어 우선 배치 (저전력) |
| 중량 태스크 (80% CPU) | 아무 유휴 CPU 배치 | big 코어 배치 (고성능) |
| 혼합 워크로드 | 부하 균등 분배 | 에너지 최적 배치 |
# EAS 활성화 확인
cat /proc/sys/kernel/sched_energy_aware
# 1 (활성), 0 (비활성)
# 성능 도메인 확인
ls /sys/devices/system/cpu/cpu*/cpufreq/
# 각 CPU의 주파수 도메인 확인
# EAS 비활성화 (서버에서는 불필요)
echo 0 > /proc/sys/kernel/sched_energy_aware
CONFIG_ENERGY_MODEL=y 빌드, (2) Energy Model 등록 (cpufreq 드라이버가 EM 제공), (3) sched_domain이 Overutilized가 아닐 것, (4) sched_energy_aware=1. x86 서버에서는 일반적으로 비활성이며, ARM 모바일/임베디드에서 주로 사용됩니다.
sched_entity/cfs_rq 구조체 분석
CFS 스케줄러의 내부 동작을 이해하려면 sched_entity와 cfs_rq 구조체의 필드별 역할과 상호 관계를 파악해야 합니다.
sched_entity 상세 분석
/* include/linux/sched.h (Linux 6.x) */
struct sched_entity {
/* === 부하/가중치 === */
struct load_weight load; /* nice → weight 변환값 */
struct sched_avg avg; /* PELT 메트릭 */
/* === RB-tree 관련 === */
struct rb_node run_node; /* RB-tree 노드 */
unsigned int on_rq; /* 런큐에 있는지 여부 */
/* === 실행 시간 추적 === */
u64 exec_start; /* 현재 스케줄 시작 시각 */
u64 sum_exec_runtime; /* 누적 실행 시간 */
u64 prev_sum_exec_runtime;
u64 vruntime; /* 가상 런타임 */
/* === EEVDF 관련 (6.6+) === */
s64 vlag; /* 지연 (lag) */
u64 deadline; /* 가상 데드라인 */
u64 slice; /* 요청 슬라이스 */
/* === 그룹 스케줄링 === */
struct sched_entity *parent; /* 상위 그룹 엔티티 */
struct cfs_rq *cfs_rq; /* 소속 런큐 */
struct cfs_rq *my_q; /* 그룹이면 자식 런큐 */
int depth; /* 그룹 계층 깊이 */
};
cfs_rq 구조체 분석
/* kernel/sched/sched.h */
struct cfs_rq {
/* === 부하 정보 === */
struct load_weight load; /* 런큐 내 총 가중치 */
unsigned int nr_running; /* 실행 가능 태스크 수 */
unsigned int h_nr_running; /* 계층적 총 태스크 수 */
/* === 실행 시간 === */
u64 exec_clock; /* 런큐 누적 실행 시간 */
u64 min_vruntime; /* 최소 vruntime 추적 */
/* === RB-tree === */
struct rb_root_cached tasks_timeline; /* 태스크 트리 */
/* === 현재 실행 엔티티 === */
struct sched_entity *curr; /* 현재 실행 중 */
struct sched_entity *next; /* 다음 실행 힌트 */
struct sched_entity *last; /* 마지막 깨운 엔티티 */
struct sched_entity *skip; /* 건너뛸 엔티티 */
/* === PELT === */
struct sched_avg avg;
/* === Bandwidth Control === */
int runtime_enabled;
s64 runtime_remaining; /* 남은 quota */
int throttled; /* 스로틀 여부 */
int throttle_count;
/* === 그룹 스케줄링 === */
struct task_group *tg; /* 소속 task_group */
};
Weight/Load 계산 체계
calc_delta_fair()에서 delta * NICE_0_LOAD / weight 나눗셈을 delta * NICE_0_LOAD * inv_weight >> 32 곱셈+시프트로 변환합니다. 이는 스케줄러 hot path에서 수십 ns의 성능 차이를 만듭니다.
enqueue/dequeue 흐름
태스크가 실행 가능 상태가 되면 enqueue_entity()로 CFS 런큐에 삽입되고, 슬립하거나 종료하면 dequeue_entity()로 제거됩니다.
/* kernel/sched/fair.c — 엔큐 핵심 로직 */
static void enqueue_entity(struct cfs_rq *cfs_rq,
struct sched_entity *se, int flags)
{
bool renorm = !(flags & ENQUEUE_WAKEUP) ||
(flags & ENQUEUE_MIGRATED);
/* 1. vruntime 정규화 (마이그레이션 시) */
if (renorm && curr)
se->vruntime += cfs_rq->min_vruntime;
/* 2. PELT 부하 업데이트 */
update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
update_cfs_group(se);
/* 3. 부하 가중치 업데이트 */
account_entity_enqueue(cfs_rq, se);
/* 4. vruntime 초기화 (새 태스크/슬립 복귀) */
if (flags & ENQUEUE_WAKEUP)
place_entity(cfs_rq, se, 0);
/* 5. RB-tree에 삽입 */
if (se != cfs_rq->curr)
__enqueue_entity(cfs_rq, se);
se->on_rq = 1;
}
/* RB-tree 삽입: vruntime을 키로 정렬 */
static void __enqueue_entity(struct cfs_rq *cfs_rq,
struct sched_entity *se)
{
struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node;
struct rb_node *parent = NULL;
struct sched_entity *entry;
bool leftmost = true;
while (*link) {
parent = *link;
entry = rb_entry(parent, struct sched_entity, run_node);
if (entity_before(se, entry)) {
link = &parent->rb_left;
} else {
link = &parent->rb_right;
leftmost = false; /* leftmost 캐시 갱신 */
}
}
rb_link_node(&se->run_node, parent, link);
rb_insert_color_cached(&se->run_node,
&cfs_rq->tasks_timeline, leftmost);
}
/* 디큐 핵심 로직 */
static void dequeue_entity(struct cfs_rq *cfs_rq,
struct sched_entity *se, int flags)
{
/* 1. vruntime 업데이트 */
update_curr(cfs_rq);
/* 2. PELT 부하 업데이트 */
update_load_avg(cfs_rq, se, UPDATE_TG);
/* 3. 부하 가중치 제거 */
account_entity_dequeue(cfs_rq, se);
/* 4. RB-tree에서 제거 */
if (se != cfs_rq->curr)
__dequeue_entity(cfs_rq, se);
se->on_rq = 0;
/* 5. vruntime 비정규화 (마이그레이션 대비) */
if (!(flags & DEQUEUE_SLEEP))
se->vruntime -= cfs_rq->min_vruntime;
update_min_vruntime(cfs_rq);
update_cfs_group(se);
}
핵심 호출 체인: enqueue_task_fair → update_curr → pick_next_entity
태스크가 런큐에 진입하는 순간부터 다음 실행 태스크가 결정되기까지의 전체 호출 체인을 커널 소스 수준에서 추적합니다. 이 경로는 스케줄러 hot path(자주 실행되는 경로)이므로 성능에 직접 영향을 미칩니다.
호출 체인 개요
/* 태스크 깨어남 → 스케줄링 전체 흐름 */
try_to_wake_up() /* kernel/sched/core.c */
└─ ttwu_do_activate()
└─ activate_task()
└─ enqueue_task()
└─ enqueue_task_fair() /* fair.c: CFS 진입점 */
├─ enqueue_entity()
│ ├─ update_curr() /* vruntime 갱신 */
│ ├─ place_entity() /* 초기 vruntime 배치 */
│ └─ __enqueue_entity() /* RB-tree 삽입 */
└─ hrtick_update() /* 타이머 갱신 */
schedule() /* kernel/sched/core.c */
└─ __schedule()
├─ put_prev_task_fair() /* 현재 태스크 RB-tree 재삽입 */
├─ pick_next_task_fair() /* 다음 태스크 선택 */
│ └─ pick_next_entity() /* leftmost 노드 반환 */
└─ context_switch() /* 실제 전환 */
코드 설명
- 2행
try_to_wake_up()은 슬립 중인 태스크를 깨우는 진입점입니다. 태스크 상태를 TASK_RUNNING으로 전환하고 적절한 CPU의 런큐에 삽입합니다. - 5행
enqueue_task_fair()는 CFS 스케줄러 클래스의 enqueue 콜백입니다.fair_sched_class.enqueue_task함수 포인터에 등록되어 있으며, 그룹 스케줄링 계층을 순회하며 각 레벨의 엔티티를 인큐합니다. - 7행
update_curr()는 인큐 직전에 호출되어 현재 실행 중인 태스크의 vruntime을 최신 시각 기준으로 갱신합니다. 이로써 새 태스크의 vruntime 비교 기준이 정확해집니다. - 8행
place_entity()는 새 태스크 또는 슬립 복귀 태스크의 vruntime을min_vruntime기준으로 초기화합니다. 새 태스크에는 START_DEBIT 패널티, 슬립 복귀 시에는 GENTLE_FAIR_SLEEPERS 보상이 적용됩니다. - 9행
__enqueue_entity()는 RB-tree에 태스크를 vruntime 키로 삽입합니다.leftmost플래그를 추적하여rb_first_cached캐시를 O(1)로 갱신합니다. - 15행
pick_next_entity()는 RB-tree의 leftmost 노드(최소 vruntime)를 반환합니다. EEVDF 모드에서는 eligible 태스크 중 가장 이른 deadline 태스크를 선택합니다. - 16행
context_switch()는 CPU 레지스터, 스택 포인터, TLB를 전환합니다.switch_mm()으로 주소 공간을,switch_to()로 CPU 레지스터를 교체합니다.
enqueue_task_fair() 소스 분석
enqueue_task_fair()는 그룹 스케줄링 계층을 역방향으로 순회하며 각 레벨의 sched_entity를 해당 cfs_rq에 삽입합니다.
/* kernel/sched/fair.c */
static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
int idle_h_nr_running = task_has_idle_policy(p);
int task_new = !(flags & ENQUEUE_WAKEUP);
/* SCHED_IDLE 태스크의 경우 활성화 시 알림 */
if (p->in_iowait)
cpufreq_update_util(rq, SCHED_CPUFREQ_IOWAIT);
/* 그룹 스케줄링 계층을 leaf → root 방향으로 순회 */
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
if (cfs_rq->nr_running) {
update_load_avg(cfs_rq, se, UPDATE_TG);
se_update_runnable(se);
}
enqueue_entity(cfs_rq, se, flags);
cfs_rq->h_nr_running++;
cfs_rq->idle_h_nr_running += idle_h_nr_running;
if (cfs_rq_is_idle(cfs_rq))
idle_h_nr_running = 1;
/* 이미 런큐에 있었다면 상위 계층은 갱신 불필요 */
if (cfs_rq->nr_running == 1)
list_add_leaf_cfs_rq(cfs_rq);
flags = ENQUEUE_WAKEUP;
}
add_nr_running(rq, 1);
hrtick_update(rq);
}
코드 설명
- 6행
se = &p->se:task_struct내에 내장된sched_entity를 사용합니다. 그룹 태스크라면 이 se의parent포인터를 따라 상위 그룹 엔티티를 순회합니다. - 8행
task_new = !(flags & ENQUEUE_WAKEUP):ENQUEUE_WAKEUP플래그가 없으면 새로 생성된 태스크입니다.place_entity()호출 시initial=1을 전달하여 START_DEBIT 패널티를 적용합니다. - 14행
for_each_sched_entity(se): 그룹 스케줄링이 없으면 단일 반복, 그룹이 있으면se = se->parent를 반복하며 루트까지 올라갑니다. 각 레벨에서 해당cfs_rq에 엔티티를 삽입합니다. - 17행
update_load_avg(): PELT 알고리즘으로 엔티티와 런큐의 부하 평균(util_avg,load_avg)을 갱신합니다.UPDATE_TG플래그는 task_group까지 전파함을 의미합니다. - 22행
enqueue_entity(): 실제 RB-tree 삽입과 vruntime 초기화를 담당합니다. wakeup 여부에 따라place_entity()를 호출하고__enqueue_entity()로 트리에 삽입합니다. - 31행
nr_running == 1: 이 레벨의 런큐가 방금 비어있다가 첫 태스크가 들어온 경우입니다.list_add_leaf_cfs_rq()로 리프 런큐 목록에 추가하여 스케줄러가 순회할 수 있게 합니다. - 37행
add_nr_running(rq, 1): 전체 런큐의nr_running을 증가시킵니다. 이 값은__sched_period()에서 타임슬라이스 계산에 사용됩니다.
update_curr() 상세 분석
update_curr()는 스케줄러에서 가장 자주 호출되는 함수 중 하나입니다. 현재 실행 중인 태스크의 실제 실행 시간을 vruntime으로 변환하여 공정성을 유지합니다.
/* kernel/sched/fair.c */
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
if (unlikely(!curr))
return;
delta_exec = now - curr->exec_start;
if (unlikely((s64)delta_exec <= 0))
return;
curr->exec_start = now;
schedstat_set(curr->statistics.exec_max,
max(delta_exec, curr->statistics.exec_max));
curr->sum_exec_runtime += delta_exec;
schedstat_add(cfs_rq->exec_clock, delta_exec);
/* 실제 실행 시간 → 가중 vruntime으로 변환 */
curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
trace_sched_stat_runtime(curtask, delta_exec,
curr->vruntime);
cgroup_account_cputime(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}
account_cfs_rq_runtime(cfs_rq, delta_exec);
}
코드 설명
- 4행
curr = cfs_rq->curr: 현재 CPU에서 실행 중인sched_entity입니다. CFS 런큐에서 실행 중인 태스크가 없으면 NULL이며, 이 경우 즉시 반환합니다. - 5행
rq_clock_task(): 태스크 클록을 반환합니다. 일반rq_clock()과 달리 인터럽트/게스트 OS 실행 시간이 제외된 순수 태스크 실행 시간입니다. - 11행
delta_exec = now - exec_start: 마지막 업데이트 이후 경과한 실제 실행 시간(나노초)입니다. 음수 검사((s64)delta_exec <= 0)는 시계 역전 등 예외 상황을 방어합니다. - 15행
exec_start = now: 기준 시각을 현재로 갱신합니다. 다음update_curr()호출 시 이 시각부터 delta를 계산합니다. - 20행
sum_exec_runtime += delta_exec: 태스크 생성 이후 누적 실행 시간입니다./proc/<pid>/sched의se.sum_exec_runtime필드로 확인할 수 있습니다. - 23행
calc_delta_fair(delta_exec, curr):delta_exec * NICE_0_LOAD / curr->load.weight를 계산합니다. nice가 높을수록(낮은 우선순위) weight가 작아 vruntime이 더 빠르게 증가합니다.inv_weight필드로 나눗셈을 곱셈+시프트로 최적화합니다. - 24행
update_min_vruntime(): 런큐의min_vruntime을 단조 증가 방식으로 갱신합니다. 현재 실행 중인 태스크와 RB-tree leftmost 노드 중 최솟값을 선택합니다. - 30행
cgroup_account_cputime(): CFS Bandwidth Control에서 사용하는 cgroup의 CPU 시간 쿼터를 소비합니다.runtime_remaining이 0 이하가 되면 스로틀링이 발생합니다. - 34행
account_cfs_rq_runtime(): cfs_rq의 남은 런타임을 차감하고, 소진 시throttle_cfs_rq()를 호출하여 런큐를 스로틀합니다.
pick_next_entity() 상세 분석
CFS의 pick_next_entity()는 RB-tree leftmost 노드(최소 vruntime)를 기반으로 next/last/skip 힌트 시스템을 적용하여 최적의 다음 태스크를 선택합니다.
/* kernel/sched/fair.c (Linux 6.x, EEVDF 이전 기준) */
static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
struct sched_entity *left = __pick_first_entity(cfs_rq);
struct sched_entity *se;
/* curr도 후보: leftmost보다 작은 vruntime이면 curr 유지 */
if (!left || (curr && entity_before(curr, left)))
left = curr;
se = left; /* 기본 선택: 최소 vruntime */
/* skip 힌트: sched_yield()로 건너뛰라고 지정된 엔티티 */
if (cfs_rq->skip && cfs_rq->skip == se) {
struct sched_entity *second;
if (se == curr) {
second = __pick_first_entity(cfs_rq);
} else {
second = __pick_next_entity(se);
if (!second || (curr && entity_before(curr, second)))
second = curr;
}
if (second && wakeup_preempt_entity(second, left) < 1)
se = second;
}
/* next 힌트: wakeup preemption이 결정한 다음 실행 엔티티 */
if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
se = cfs_rq->next;
/* last 힌트: 캐시 친화성을 위해 마지막으로 깨운 태스크 선호 */
if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
se = cfs_rq->last;
clear_buddies(cfs_rq, se);
return se;
}
코드 설명
- 5행
__pick_first_entity(cfs_rq):rb_first_cached(&cfs_rq->tasks_timeline)를 호출하여 O(1)으로 leftmost 노드를 반환합니다.rb_first_cached는 leftmost 노드를 별도 포인터로 캐싱하는 구조체입니다. - 8-10행현재 실행 중인
curr가 leftmost보다 vruntime이 작으면curr를 기본 선택으로 사용합니다. 이는 현재 태스크가 공정성 기준으로 계속 실행될 자격이 있을 때 불필요한 컨텍스트 스위치를 줄입니다. - 14-26행
skip힌트:sched_yield()호출 시 현재 엔티티를 건너뛰고 다음 후보를 선택합니다.wakeup_preempt_entity(second, left) < 1조건은 대안이 leftmost보다 vruntime 차이가sched_wakeup_granularity_ns이내일 때만 선택함을 의미합니다. - 28-30행
next힌트:set_next_buddy()로 설정됩니다. wakeup preemption 시 깨어난 태스크가 next로 지정되며, 이후 한 번 우선 실행될 기회를 얻습니다. 공정성 임계값 이내일 때만 적용됩니다. - 32-34행
last힌트: 현재 태스크가 CPU를 내놓을 때set_last_buddy()로 설정됩니다. 동일한 캐시 라인 데이터를 공유하는 패턴(producer-consumer)에서 캐시 친화성을 높입니다. - 36행
clear_buddies(): 선택된 엔티티와 일치하는 skip/next/last 힌트를 NULL로 초기화합니다. 힌트는 일회성이므로 사용 후 즉시 제거합니다.
place_entity() 상세 분석
place_entity()는 태스크가 처음 생성되거나 슬립에서 깨어날 때 vruntime을 공정하게 초기화합니다. 너무 작은 vruntime은 다른 태스크를 장시간 기아 상태로 만들 수 있기 때문에 적절한 패널티/보상 메커니즘이 필요합니다.
/* kernel/sched/fair.c */
static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se,
int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
/* 신규 태스크: START_DEBIT 패널티 — 한 주기분 vruntime 선납 */
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);
/* 슬립 복귀: GENTLE_FAIR_SLEEPERS 보상 — 절반 주기 되돌려줌 */
if (!initial) {
unsigned long thresh;
if (se_is_idle(se))
thresh = sysctl_sched_min_granularity;
else
thresh = sysctl_sched_latency;
if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1; /* 절반만 보상 */
vruntime -= thresh;
}
/* 기존 vruntime과 비교하여 더 큰 값(뒤처지지 않는 값) 사용 */
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
코드 설명
- 6행
vruntime = cfs_rq->min_vruntime: 초기 기준점을 런큐의 최소 vruntime으로 설정합니다. 새 태스크는 이미 실행 중인 태스크들보다 vruntime이 작지 않도록 최솟값에서 출발합니다. - 9-10행
START_DEBIT:sched_feat로 제어되는 기능 플래그입니다. 기본 활성화이며,/sys/kernel/debug/sched/features에서 확인/변경 가능합니다.sched_vslice()는 이 태스크의 이상적인 한 주기 타임슬라이스를 vruntime 단위로 계산합니다. - 16-19행SCHED_IDLE 태스크는 보상 임계값으로 더 작은
sched_min_granularity를 사용하고, 일반 태스크는sched_latency를 사용합니다. SCHED_IDLE은 CPU를 덜 받아야 하므로 보상 폭을 제한합니다. - 21-22행
GENTLE_FAIR_SLEEPERS: 기본 활성화 기능입니다. 비활성화 시 thresh 전체를 빼서 완전한 보상을 주지만, 이는 오랫동안 슬립한 태스크가 순간적으로 매우 낮은 vruntime으로 다른 태스크를 오래 선점하는 문제를 유발합니다. 절반만 보상하여 이 문제를 완화합니다. - 27행
max_vruntime(se->vruntime, vruntime): 부호 있는 비교((s64)(a - b) < 0)로 두 vruntime 중 더 큰 값을 선택합니다. 이로써 마이그레이션 등으로 이미 높은 vruntime을 가진 태스크가 역으로 보상을 받는 일을 방지합니다.
sched_entity 필드별 한국어 주석
CFS 동작의 핵심 자료구조인 sched_entity의 모든 주요 필드를 역할별로 그룹화하여 설명합니다.
/* include/linux/sched.h (Linux 6.x 기준) */
struct sched_entity {
/* ── 부하/가중치 그룹 ── */
struct load_weight load; /* nice → weight 변환값 (weight + inv_weight) */
struct sched_avg avg; /* PELT: util_avg, load_avg, runnable_avg */
/* ── RB-tree 위치 그룹 ── */
struct rb_node run_node; /* RB-tree 삽입 노드 (vruntime 키) */
unsigned int on_rq; /* 1=런큐에 있음, 0=슬립/종료 */
/* ── 시간 추적 그룹 ── */
u64 exec_start; /* update_curr() 마지막 호출 시각 (ns) */
u64 sum_exec_runtime; /* 총 누적 실행 시간 (ns) */
u64 prev_sum_exec_runtime; /* 이전 스케줄 주기 기준값 */
u64 vruntime; /* 가상 런타임 (RB-tree 정렬 키) */
/* ── EEVDF 그룹 (Linux 6.6+) ── */
s64 vlag; /* 지연량 = avg_vruntime - vruntime */
u64 deadline; /* 가상 데드라인 = vruntime + slice/weight */
u64 slice; /* 요청 타임슬라이스 (sched_attr.sched_runtime) */
/* ── 그룹 스케줄링 그룹 ── */
struct sched_entity *parent; /* 상위 그룹의 sched_entity (루트면 NULL) */
struct cfs_rq *cfs_rq; /* 이 엔티티가 속한 cfs_rq */
struct cfs_rq *my_q; /* 그룹이면 내부 cfs_rq, 개별 태스크면 NULL */
int depth; /* 그룹 계층 깊이 (루트=0) */
};
코드 설명
- 4행 loadnice 값을
sched_prio_to_weight[]테이블로 변환한 weight와, 나눗셈 최적화용 inv_weight(2^32/weight)를 포함합니다.calc_delta_fair()에서 inv_weight를 사용해 나눗셈을 곱셈+시프트로 대체합니다. - 5행 avgPELT(Per-Entity Load Tracking) 상태를 담습니다.
util_avg(0~1024 CPU 활용도),load_avg(가중 부하),runnable_avg(런큐 대기 시간),last_update_time(마지막 갱신 시각)을 포함합니다. - 8행 on_rq
enqueue_entity()에서 1로 설정,dequeue_entity()에서 0으로 설정됩니다. 현재 실행 중인 태스크(cfs_rq->curr)는 RB-tree에 없지만 on_rq=1입니다. - 11행 exec_start
update_curr()가 호출될 때마다 현재 시각으로 갱신됩니다.delta_exec = now - exec_start로 구간 실행 시간을 계산하는 기준점입니다. - 14행 vruntimeRB-tree의 정렬 키입니다.
calc_delta_fair()의 반환값(실제시간 × NICE_0_LOAD / weight)만큼 증가합니다. nice가 낮을수록(높은 우선순위) weight가 크고 vruntime 증가율이 느려 CPU를 더 많이 할당받습니다. - 17행 vlagEEVDF의 지연 지표입니다. 양수면 CPU를 덜 받은 상태(eligible), 음수면 더 받은 상태(not eligible)를 의미합니다. eligible 판정 기준은
se->vruntime <= avg_vruntime(cfs_rq)입니다. - 23행 my_q그룹 스케줄링의 핵심 필드입니다. 개별 프로세스의 se에서는 NULL이고, task_group의 대표 se에서는 해당 그룹의 내부 cfs_rq를 가리킵니다.
group_cfs_rq(se)는 이 필드를 반환합니다.
cfs_rq 필드별 한국어 주석
cfs_rq는 CPU별로 존재하는 CFS 런큐입니다. 그룹 스케줄링 시에는 각 task_group마다 CPU별로 추가 런큐가 생성됩니다.
/* kernel/sched/sched.h */
struct cfs_rq {
/* ── 부하 합산 그룹 ── */
struct load_weight load; /* 런큐 내 모든 se의 weight 합 */
unsigned int nr_running; /* 현재 레벨 실행 가능 태스크 수 */
unsigned int h_nr_running; /* 하위 그룹 포함 총 태스크 수 */
unsigned int idle_h_nr_running; /* SCHED_IDLE 태스크 수 (계층 포함) */
/* ── vruntime 기준 그룹 ── */
u64 exec_clock; /* 런큐 누적 실행 시간 (schedstat용) */
u64 min_vruntime; /* 단조 증가 최소 vruntime (새 태스크 기준점) */
u64 avg_vruntime; /* EEVDF eligible 판정 기준선 */
/* ── RB-tree 그룹 ── */
struct rb_root_cached tasks_timeline; /* vruntime 정렬 트리 + leftmost 캐시 */
/* ── 현재 실행 힌트 그룹 ── */
struct sched_entity *curr; /* 현재 실행 중인 엔티티 (트리 밖) */
struct sched_entity *next; /* wakeup preemption 우선 후보 */
struct sched_entity *last; /* 캐시 친화성 우선 후보 */
struct sched_entity *skip; /* sched_yield() 건너뛰기 대상 */
/* ── PELT 그룹 ── */
struct sched_avg avg; /* 런큐 전체 PELT 평균 부하 */
u64 avg_load; /* 런큐 평균 부하 (부하 분산용) */
/* ── Bandwidth Control 그룹 ── */
int runtime_enabled; /* cfs_bandwidth 활성 여부 */
s64 runtime_remaining; /* 남은 실행 쿼터 (소진 시 throttle) */
u64 throttled_clock; /* 스로틀 시작 시각 */
u64 throttled_clock_pelt; /* PELT 기준 스로틀 시각 */
int throttled; /* 현재 스로틀 상태 */
int throttle_count; /* 중첩 스로틀 깊이 */
/* ── 그룹 스케줄링 그룹 ── */
struct task_group *tg; /* 소속 task_group (루트이면 &root_task_group) */
struct list_head leaf_cfs_rq_list; /* 리프 런큐 순회 리스트 */
};
코드 설명
- 4행 load런큐에 있는 모든
sched_entity의 weight 합입니다.sched_slice()에서se->load.weight / cfs_rq->load.weight비율로 타임슬라이스를 계산합니다. - 5-7행 nr_running 계열
nr_running은 이 레벨 런큐의 직접 태스크 수,h_nr_running은 하위 그룹 포함 전체 수입니다.__sched_period()는h_nr_running을 기반으로 스케줄링 주기를 결정합니다. - 11행 min_vruntime
update_min_vruntime()이 호출될 때마다 단조 증가합니다. 새 태스크나 슬립 복귀 태스크의place_entity()에서 기준점으로 사용되어, 이미 오래 실행된 태스크들과 같은 출발선에 두는 역할을 합니다. - 12행 avg_vruntimeEEVDF에서 eligible 판정 기준선입니다. 모든 태스크의 vruntime 가중 평균으로,
se->vruntime <= avg_vruntime이면 eligible(CPU를 덜 받은 상태)로 판정합니다. - 15행 tasks_timeline
rb_root_cached는 표준 RB-tree에rb_leftmost포인터를 추가한 구조체입니다.rb_first_cached()로 O(1) leftmost 접근이 가능합니다. 삽입/삭제 시 leftmost 여부에 따라 캐시를 갱신합니다. - 18-21행 curr/next/last/skip스케줄링 힌트 포인터 그룹입니다.
curr는 실행 중이므로 RB-tree에 없으며, 나머지 세 힌트는pick_next_entity()에서 leftmost 대신 선택될 수 있는 엔티티입니다. 힌트 우선순위: next > last > skip 처리(건너뜀). - 29행 runtime_remainingCFS Bandwidth Control의 남은 쿼터입니다.
account_cfs_rq_runtime()에서delta_exec만큼 차감되며, 0 이하가 되면throttle_cfs_rq()를 호출하여 런큐를 비활성화합니다. - 37행 leaf_cfs_rq_list그룹 스케줄링에서 리프 런큐(직접 태스크를 가진 가장 낮은 레벨)를 연결하는 리스트입니다.
for_each_leaf_cfs_rq()로 순회하여 모든 그룹의 PELT를 효율적으로 업데이트합니다.
컨텍스트 스위치 내부 구조
CFS가 선점 결정을 내린 후 실제 태스크 교체는 context_switch()에서 이루어집니다. 이 함수는 메모리 공간 전환과 CPU 레지스터 교체라는 두 가지 핵심 작업을 수행합니다.
context_switch() 코드 분석
/* kernel/sched/core.c */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next); /* 아키텍처 준비 작업 */
/*
* 커널 스레드(mm == NULL)는 active_mm만 빌려 씀.
* 사용자 태스크는 switch_mm_irqs_off()로 페이지 테이블 전환.
*/
if (!next->mm) {
/* 커널 스레드: lazy TLB 모드 — 현재 mm 유지 */
next->active_mm = prev->active_mm;
if (prev->mm)
mmgrab(prev->active_mm);
enter_lazy_tlb(prev->active_mm, next);
} else {
/* 사용자 태스크: 페이지 테이블 교체 + TLB 플러시 */
switch_mm_irqs_off(prev->active_mm, next->mm, next);
}
rq_unpin_lock(rq, rf);
spin_release(&rq->lock.dep_map, _THIS_IP_);
/* CPU 레지스터(스택 포인터, 명령 포인터) 전환 — 이 줄 이후 prev가 복귀 */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev); /* 전환 완료 정리 */
}
switch_mm_irqs_off() — 페이지 테이블 전환
x86_64에서 페이지 테이블 전환은 CR3 레지스터를 새 태스크의 PGD(Page Global Directory) 물리 주소로 덮어쓰는 것으로 이루어집니다. CR3 쓰기는 자동으로 TLB 전체 플러시를 유발합니다(PCID 기능이 없을 경우).
/* arch/x86/mm/tlb.c (단순화) */
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
unsigned long new_cr3;
if (prev == next) /* 같은 mm이면 전환 불필요 */
return;
new_cr3 = build_cr3(next->pgd, tsk->mm_cid); /* PCID 포함 CR3 구성 */
/* CR3 재로드 → TLB 플러시 (PCID 없이는 전체 flush) */
write_cr3(new_cr3);
/* mmu_notifier 콜백 및 lazy TLB flush list 처리 */
switch_ldt(prev, next);
}
enter_lazy_tlb()는 현재 CPU를 해당 mm의 cpu_tlbstate에서 제거해, 다른 CPU의 TLB 무효화 IPI 대상에서 빠지게 합니다. 이로써 커널 스레드 전환 시 불필요한 TLB 플러시 비용을 절감합니다.
switch_to() — CPU 레지스터 저장/복원
x86_64에서 switch_to()는 어셈블리 매크로로 구현된 __switch_to_asm()을 호출합니다. 커널 스택 포인터(RSP)를 교체하고 다음 태스크의 saved RIP로 점프합니다.
/* arch/x86/entry/entry_64.S (단순화) */
SYM_FUNC_START(__switch_to_asm)
/* 비-휘발성 레지스터를 prev의 커널 스택에 저장 */
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* prev의 RSP를 task_struct->thread.sp에 저장 */
movq %rsp, TASK_threadsp(%rdi)
/* next의 RSP를 복원 → 스택 전환 완료 */
movq TASK_threadsp(%rsi), %rsp
/* next의 저장된 레지스터 복원 */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
/* __switch_to() C 함수 호출 후 next 태스크의 저장 RIP로 ret */
jmp __switch_to
SYM_FUNC_END(__switch_to_asm)
finish_task_switch() — 전환 완료 정리
switch_to()에서 반환된 후에는 next 태스크의 컨텍스트에서 실행되고 있습니다. finish_task_switch()는 이전 태스크(prev)에 대한 정리를 수행합니다.
static struct rq * finish_task_switch(struct task_struct *prev)
{
struct rq *rq = this_rq();
struct mm_struct *mm = rq->prev_mm;
long prev_state;
rq->prev_mm = NULL;
/* prev의 mm 참조 카운트 해제 (커널 스레드가 빌렸던 경우) */
if (mm)
mmdrop_sched(mm);
prev_state = READ_ONCE(prev->__state);
/* prev가 TASK_DEAD이면 task_struct 메모리 해제 */
if (unlikely(prev_state == TASK_DEAD)) {
if (prev->sched_class->task_dead)
prev->sched_class->task_dead(prev);
put_task_struct_rcu_user(prev);
}
tick_nohz_task_switch(); /* NO_HZ 상태 갱신 */
finish_lock_switch(rq); /* rq 락 재획득 */
perf_event_task_sched_in(prev, current());
return rq;
}
ftrace/perf/bpftrace 스케줄러 분석
CFS 스케줄러의 동작을 실시간으로 분석하는 세 가지 핵심 도구를 다룹니다.
ftrace sched 이벤트
ftrace의 sched 관련 tracepoint를 활용하여 스케줄링 이벤트를 추적합니다.
# 사용 가능한 sched 이벤트 확인
ls /sys/kernel/debug/tracing/events/sched/
# sched_switch — 컨텍스트 스위치
# sched_wakeup — 태스크 wake-up
# sched_wakeup_new — 새 태스크 wake-up
# sched_migrate_task — CPU 마이그레이션
# sched_stat_runtime — 실행 시간 통계
# sched_stat_wait — 런큐 대기 시간
# sched_switch 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
cat /sys/kernel/debug/tracing/trace_pipe | head -20
# 출력 예:
# <idle>-0 [002] 1234.567: sched_switch:
# prev_comm=swapper/2 prev_pid=0 prev_state=R ==>
# next_comm=my_app next_pid=1234 next_prio=120
# 특정 PID만 필터링
echo 'common_pid == 1234' > /sys/kernel/debug/tracing/events/sched/sched_switch/filter
# 스케줄 지연 측정 (wakeup → switch 시간)
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_stat_wait/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_stat_runtime/enable
perf sched 분석
# 10초간 스케줄링 이벤트 기록
perf sched record -- sleep 10
# 태스크별 지연시간 분석
perf sched latency --sort max
# ─────────────────────────────────────────────────────
# Task │ Runtime ms │ Switches │ Max delay ms
# ─────────────────────────────────────────────────────
# my_app:1234 │ 3456.12 │ 2048 │ 15.234
# worker:5678 │ 123.45 │ 512 │ 3.456
# CPU별 타임라인 시각화
perf sched timehist --summary
# ─────────────────────────────────────────────────────
# CPU 0: busy 78.5% idle 21.5% migrations: 42
# CPU 1: busy 65.2% idle 34.8% migrations: 38
# 마이그레이션 추적
perf sched map
bpftrace 런큐 모니터링
# 런큐 대기 시간 히스토그램
bpftrace -e '
tracepoint:sched:sched_switch
{
@runq_lat[args->next_comm] = hist(nsecs - @start[args->next_pid]);
@start[args->next_pid] = nsecs;
}
tracepoint:sched:sched_wakeup,
tracepoint:sched:sched_wakeup_new
{
@start[args->pid] = nsecs;
}
END { clear(@start); }
'
# CFS vruntime 분포 추적 (kprobe)
bpftrace -e '
kprobe:update_curr
{
$se = (struct sched_entity *)arg1;
@vruntime = hist($se->vruntime);
}
'
# 컨텍스트 스위치 빈도 (초당)
bpftrace -e '
tracepoint:sched:sched_switch
{
@switches = count();
}
interval:s:1
{
printf("cs/s: %d\n", @switches);
clear(@switches);
}
'
# CPU 마이그레이션 추적
bpftrace -e '
tracepoint:sched:sched_migrate_task
{
printf("%s[%d] CPU %d -> %d\n",
args->comm, args->pid,
args->orig_cpu, args->dest_cpu);
}
'
/proc/schedstat에서 CPU별 스케줄링 통계를 확인할 수 있습니다. 각 줄은 cpu<N> yld_count sched_count sched_goidle ttwu_count ttwu_local 형식입니다. sched_goidle이 높으면 해당 CPU가 자주 유휴 상태에 빠지고 있어 부하 불균형을 의심할 수 있습니다.
스케줄러 디버깅 체크리스트
CFS 관련 성능 문제 발생 시 다음 순서로 조사합니다.
| 단계 | 확인 항목 | 도구 | 정상 기준 |
|---|---|---|---|
| 1 | 런큐 길이 | vmstat 1 (r 컬럼) | CPU 수 이하 |
| 2 | 컨텍스트 스위치 | vmstat 1 (cs 컬럼) | 수천~수만/s |
| 3 | 비자발적 선점 | /proc/PID/status | 낮을수록 좋음 |
| 4 | 마이그레이션 횟수 | /proc/PID/sched | 캐시 민감 작업은 0 권장 |
| 5 | 스케줄 지연 | perf sched latency | 수 ms 이하 |
| 6 | 스로틀링 | cpu.stat (nr_throttled) | 0이면 정상 |
| 7 | NUMA 미스 | /proc/vmstat (numa_miss) | numa_hit의 5% 이하 |
| 8 | PELT 부하 | /proc/PID/sched (avg) | 워크로드 기대치와 일치 |
PSI (Pressure Stall Information)
Linux 4.20+에서 도입된 PSI는 CPU/메모리/IO 자원 부족으로 태스크가 대기한 시간 비율을 제공합니다.
# 시스템 전체 CPU 압력
cat /proc/pressure/cpu
# some avg10=2.45 avg60=3.12 avg300=2.89 total=12345678
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
# cgroup별 CPU 압력
cat /sys/fs/cgroup/myapp/cpu.pressure
# some avg10=15.23 avg60=12.45 avg300=10.89 total=98765432
# 해석:
# some: 하나 이상의 태스크가 CPU를 기다리는 시간 비율 (%)
# full: 모든 태스크가 CPU를 기다리는 시간 비율 (CPU는 some만)
# avg10/60/300: 10초/60초/300초 이동 평균
# PSI 기반 알람 설정 (5초간 CPU 압력 25% 초과 시)
echo "some 250000 5000000" > /proc/pressure/cpu
# poll()로 감시 가능 (systemd-oomd, cgroup PSI 모니터링 등)
sysctl 튜닝 파라미터 상세
CFS의 동작을 세밀하게 조정하는 모든 sysctl 파라미터를 정리합니다. 각 파라미터의 기본값, 효과, 워크로드별 권장값을 포함합니다.
핵심 CFS 파라미터
| 파라미터 | 기본값 | 범위 | 효과 |
|---|---|---|---|
sched_latency_ns | 6,000,000 (6ms) | 100,000~1,000,000,000 | 한 스케줄링 주기 (모든 태스크 1회 실행). 증가 시 처리량 증가, 응답 지연 증가 |
sched_min_granularity_ns | 750,000 (0.75ms) | 100,000~1,000,000,000 | 최소 타임슬라이스. 감소 시 공정성 향상, 컨텍스트 스위치 증가 |
sched_wakeup_granularity_ns | 1,000,000 (1ms) | 0~1,000,000,000 | wake-up 선점 임계값. 감소 시 대화형 응답 향상, 불필요한 선점 증가 |
sched_migration_cost_ns | 500,000 (0.5ms) | 0~100,000,000 | 마이그레이션 비용 추정. 증가 시 캐시 친화성 향상, 부하 분산 지연 |
sched_nr_migrate | 32 | 0~65535 | 한 번의 밸런싱에서 이동할 최대 태스크 수 |
sched_child_runs_first | 0 | 0 또는 1 | 1이면 fork() 시 자식이 먼저 실행 (COW 최적화) |
sched_tunable_scaling | 1 | 0, 1, 2 | CPU 수에 따라 latency 자동 스케일링 (0=비활성, 1=log2, 2=선형) |
워크로드별 튜닝 프로파일
## 프로파일 1: 대화형 데스크톱 (최소 지연)
sysctl -w kernel.sched_latency_ns=3000000 # 3ms
sysctl -w kernel.sched_min_granularity_ns=300000 # 0.3ms
sysctl -w kernel.sched_wakeup_granularity_ns=500000 # 0.5ms
sysctl -w kernel.sched_migration_cost_ns=250000 # 0.25ms
## 프로파일 2: 처리량 최적화 서버 (HPC/배치)
sysctl -w kernel.sched_latency_ns=24000000 # 24ms
sysctl -w kernel.sched_min_granularity_ns=3000000 # 3ms
sysctl -w kernel.sched_wakeup_granularity_ns=4000000 # 4ms
sysctl -w kernel.sched_migration_cost_ns=5000000 # 5ms
sysctl -w kernel.sched_nr_migrate=128 # 대량 마이그레이션
## 프로파일 3: 데이터베이스 서버 (균형)
sysctl -w kernel.sched_latency_ns=12000000 # 12ms
sysctl -w kernel.sched_min_granularity_ns=1500000 # 1.5ms
sysctl -w kernel.sched_wakeup_granularity_ns=2000000 # 2ms
sysctl -w kernel.sched_migration_cost_ns=1000000 # 1ms
## 프로파일 4: 가상화 호스트 (VM 간 공정성)
sysctl -w kernel.sched_latency_ns=10000000 # 10ms
sysctl -w kernel.sched_min_granularity_ns=2000000 # 2ms
sysctl -w kernel.sched_migration_cost_ns=500000 # 0.5ms
sched_autogroup 자동 그룹화
데스크톱 환경에서 CONFIG_SCHED_AUTOGROUP은 TTY 세션별로 자동 task_group을 생성하여, 빌드 같은 CPU 집약 작업이 대화형 세션에 미치는 영향을 최소화합니다.
# autogroup 활성화 여부 확인
cat /proc/sys/kernel/sched_autogroup_enabled
# 1
# 프로세스의 autogroup nice 조정
echo 10 > /proc/self/autogroup # 현재 세션 그룹 우선순위 낮춤
# autogroup 비활성화 (서버 환경 권장)
sysctl -w kernel.sched_autogroup_enabled=0
perf sched latency로 지연시간 변화를 측정하세요. sched_min_granularity_ns를 너무 낮추면 컨텍스트 스위치 오버헤드가 실행 시간보다 커질 수 있습니다.
sched_tunable_scaling 자동 스케일링
sched_tunable_scaling은 CPU 수가 증가할 때 CFS 파라미터를 자동으로 조정합니다.
| 값 | 모드 | 스케일링 공식 | 설명 |
|---|---|---|---|
| 0 | 비활성 | 고정값 사용 | 수동 설정된 값 그대로 사용 |
| 1 | log2 | 기본값 × (1 + log2(N)) | 기본값. CPU 수 증가에 따라 완만하게 증가 |
| 2 | 선형 | 기본값 × N | CPU 수에 비례하여 증가 (대규모 시스템) |
# 현재 스케일링 모드 확인
cat /proc/sys/kernel/sched_tunable_scaling
# 1 (log2 모드)
# 64코어 시스템에서의 자동 스케일링 예:
# log2(64) = 6, factor = 1 + 6 = 7
# sched_latency = 6ms × 7 = 42ms
# sched_min_granularity = 0.75ms × 7 = 5.25ms
# 수동 튜닝 시 자동 스케일링 비활성화 권장
echo 0 > /proc/sys/kernel/sched_tunable_scaling
sched_features — 런타임 기능 토글
/sys/kernel/debug/sched/features 파일은 CFS의 내부 기능 플래그를 런타임에 확인하고 변경할 수 있는 인터페이스입니다. 각 플래그는 SCHED_FEAT(이름, 기본값) 매크로로 정의됩니다.
# 현재 활성화된 features 확인 (대문자 = 활성, NO_ 접두사 = 비활성)
cat /sys/kernel/debug/sched/features
# GENTLE_FAIR_SLEEPERS START_DEBIT NEXT_BUDDY NO_LAST_BUDDY CACHE_HOT_BUDDY
# WAKEUP_PREEMPTION NO_NEXT_BUDDY SIS_PROP ...
# 특정 feature 비활성화
echo NO_WAKEUP_PREEMPTION > /sys/kernel/debug/sched/features
# 다시 활성화
echo WAKEUP_PREEMPTION > /sys/kernel/debug/sched/features
| Feature | 기본값 | 설명 |
|---|---|---|
GENTLE_FAIR_SLEEPERS |
활성 | 슬립에서 깨어난 태스크의 vruntime 패널티를 절반으로 완화해 대화형 태스크의 반응성을 높입니다. |
START_DEBIT |
활성 | 새로 생성된 태스크(fork)의 vruntime을 min_vruntime보다 크게 설정해 즉각 선점을 방지합니다. |
NEXT_BUDDY |
비활성 | 방금 깨어난 태스크를 다음 실행 후보로 힌트해 wakeup 지연을 줄입니다. |
LAST_BUDDY |
활성 | 선점된 태스크를 나중 실행 후보로 힌트해 캐시 친화성을 유지합니다. |
CACHE_HOT_BUDDY |
활성 | 캐시가 뜨거운(hot) 태스크를 buddy로 유지해 마이그레이션을 억제합니다. |
WAKEUP_PREEMPTION |
활성 | wakeup 시 현재 태스크를 선점할 수 있게 합니다. 비활성 시 처리량 향상, 활성 시 반응성 향상. |
NO_PLACE_LAG |
비활성 | EEVDF의 lag 보정을 비활성화합니다. (커널 6.6 이후 EEVDF 관련) |
PLACE_DEADLINE_INITIAL |
활성 | 새 태스크의 deadline을 현재 min_vruntime 기준으로 설정해 공정한 진입 보장. |
SIS_PROP |
활성 | select_idle_sibling의 비례적 스캔 횟수 제한. 바쁜 시스템에서 탐색 오버헤드를 줄입니다. |
UTIL_EST |
활성 | 태스크의 CPU 활용률(utilization)을 지수 이동 평균으로 추정해 EAS에 사용합니다. |
sched_features를 변경하기 전에 반드시 perf sched latency와 cyclictest로 영향을 측정하세요. 변경 사항은 재부팅 시 초기화되므로 /etc/rc.local 또는 systemd unit에 등록해야 영구 적용됩니다.
kernel/sched/features.h에 모든 SCHED_FEAT 정의가 있습니다. 커스텀 기능 플래그를 추가하거나 기존 플래그의 기본값을 변경하려면 이 파일을 수정하고 커널을 재컴파일해야 합니다.
NUMA 밸런싱
CFS의 NUMA 밸런싱은 태스크의 메모리 접근 패턴을 분석하여 태스크를 가장 많이 접근하는 메모리가 있는 NUMA 노드로 자동 마이그레이션합니다.
자동 페이지(Page) 마이그레이션 원리
커널은 주기적으로 태스크의 페이지 테이블(Page Table) 엔트리에서 접근 비트(Accessed bit)를 클리어하여, 이후 접근 시 페이지 폴트(Page Fault)를 유발합니다. 이 폴트 핸들러(Handler)에서 접근 통계(numa_faults)를 수집합니다.
/* kernel/sched/fair.c */
struct task_struct {
/* ... */
int numa_scan_seq;
unsigned int numa_scan_period; /* 스캔 주기 (ms) */
unsigned int numa_scan_period_max;
int numa_preferred_nid; /* 선호 NUMA 노드 */
unsigned long numa_migrate_retry;
u64 node_stamp;
/* NUMA 폴트 통계: [노드][CPU/MEM][PRIVATE/SHARED] */
unsigned long *numa_faults;
unsigned long total_numa_faults;
struct numa_group *numa_group;
};
/* NUMA 폴트 핸들러 */
static void task_numa_fault(int last_cpupid,
int mem_node, int pages, int flags)
{
struct task_struct *p = current;
int cpu_node = task_node(p);
int priv;
/* 로컬 vs 원격 폴트 구분 */
priv = (cpu_node == mem_node);
/* numa_faults 통계 업데이트 */
p->numa_faults[task_faults_idx(NUMA_MEM, mem_node, priv)] += pages;
p->numa_faults[task_faults_idx(NUMA_CPU, cpu_node, priv)] += pages;
/* 선호 노드 재계산 */
task_numa_placement(p);
}
NUMA 밸런싱 결정 과정
/* kernel/sched/fair.c — 선호 노드 결정 */
static void task_numa_placement(struct task_struct *p)
{
int nid, max_nid = NUMA_NO_NODE;
unsigned long max_faults = 0;
/* 각 노드별 폴트 수 비교 */
for_each_online_node(nid) {
unsigned long faults;
faults = p->numa_faults[task_faults_idx(NUMA_MEM, nid, 0)]
+ p->numa_faults[task_faults_idx(NUMA_MEM, nid, 1)];
if (faults > max_faults) {
max_faults = faults;
max_nid = nid;
}
}
/* 선호 노드가 변경되면 마이그레이션 트리거 */
if (max_nid != p->numa_preferred_nid) {
p->numa_preferred_nid = max_nid;
p->numa_migrate_retry = jiffies + HZ;
}
}
NUMA 밸런싱 제어
# NUMA 밸런싱 활성화/비활성화
echo 1 > /proc/sys/kernel/numa_balancing # 활성화
echo 0 > /proc/sys/kernel/numa_balancing # 비활성화
# 스캔 주기 (ms) — 폴트 빈도 조절
cat /proc/sys/kernel/numa_balancing_scan_period_min_ms # 기본: 1000
cat /proc/sys/kernel/numa_balancing_scan_period_max_ms # 기본: 60000
# 한 번에 스캔할 페이지 크기 (MB)
cat /proc/sys/kernel/numa_balancing_scan_size_mb # 기본: 256
# NUMA 통계 확인
numastat -p 1234
# Per-node process memory usage (in MBs)
# Node 0: 512.00 Node 1: 1024.00
# NUMA 폴트 카운터 확인
grep numa /proc/vmstat
# numa_hit 15234567
# numa_miss 234567
# numa_foreign 234567
# numa_interleave 12345
# numa_local 14567890
# numa_other 456789
numactl --membind로 메모리 정책(Memory Policy)을 명시적으로 설정한 경우- 가상화(Virtualization) 환경에서 게스트 OS가 자체 NUMA 밸런싱을 수행하는 경우
- 메모리 크기가 작아 NUMA 힌트 폴트 오버헤드가 마이그레이션 이득보다 큰 경우
- 접근 패턴이 균일하여 특정 노드 선호가 없는 경우
numa_group: 그룹 단위 마이그레이션
공유 메모리를 사용하는 태스크들은 numa_group으로 자동 묶여, 그룹 단위로 최적 노드를 결정합니다. 이는 멀티스레드 애플리케이션의 NUMA 성능을 크게 개선합니다.
/* kernel/sched/fair.c */
struct numa_group {
refcount_t refcount;
spinlock_t lock;
int nr_tasks;
pid_t gid; /* 그룹 대표 PID */
int active_nodes; /* 사용 중인 노드 수 */
/* 그룹 전체의 NUMA 폴트 통계 */
unsigned long total_faults;
unsigned long max_faults_cpu;
unsigned long faults[]; /* [노드 수 * 2] */
};
/* 같은 페이지에 접근하는 태스크를 자동으로 그룹화 */
static void task_numa_group(struct task_struct *p,
int cpupid, int flags,
int *priv)
{
struct task_struct *grp_leader = find_task_by_vpid(cpupid);
if (grp_leader && grp_leader->numa_group) {
/* 같은 페이지에 접근 → 그룹 합류 */
do_numa_group_merge(p, grp_leader->numa_group);
}
}
perf stat -e 'sched:sched_move_numa' -a sleep 60으로 마이그레이션 횟수를, numastat -m으로 노드별 메모리 분포를 확인하세요. numa_miss 대비 numa_hit 비율이 높아져야 효과가 있습니다.
NUMA 밸런싱 파라미터 종합
| 파라미터 | 경로 | 기본값 | 설명 |
|---|---|---|---|
numa_balancing | /proc/sys/kernel/ | 1 | NUMA 밸런싱 전체 활성화/비활성화 |
numa_balancing_scan_period_min_ms | /proc/sys/kernel/ | 1000 | 최소 스캔 주기 (ms). 작을수록 빠른 감지, 높은 오버헤드 |
numa_balancing_scan_period_max_ms | /proc/sys/kernel/ | 60000 | 최대 스캔 주기 (ms). 접근 패턴 안정 시 자동 증가 |
numa_balancing_scan_delay_ms | /proc/sys/kernel/ | 1000 | 태스크 시작 후 첫 스캔까지 지연 |
numa_balancing_scan_size_mb | /proc/sys/kernel/ | 256 | 한 번에 스캔할 메모리 크기 (MB) |
NUMA 밸런싱 실전 사례
# 사례 1: 데이터베이스 서버 — NUMA 밸런싱 최적화
# 빠른 감지 + 작은 스캔 크기 (오버헤드 최소화)
echo 500 > /proc/sys/kernel/numa_balancing_scan_period_min_ms
echo 30000 > /proc/sys/kernel/numa_balancing_scan_period_max_ms
echo 128 > /proc/sys/kernel/numa_balancing_scan_size_mb
# 사례 2: JVM 애플리케이션 — 힙 크기가 큰 경우
# 큰 스캔 크기로 빠른 수렴
echo 2000 > /proc/sys/kernel/numa_balancing_scan_period_min_ms
echo 60000 > /proc/sys/kernel/numa_balancing_scan_period_max_ms
echo 512 > /proc/sys/kernel/numa_balancing_scan_size_mb
# 사례 3: 명시적 NUMA 바인딩 사용 시 — 비활성화
numactl --cpunodebind=0 --membind=0 ./my_app
# 이 경우 자동 밸런싱은 불필요 (명시적 정책 우선)
echo 0 > /proc/sys/kernel/numa_balancing
# NUMA 효과 전후 비교
perf stat -e 'node-loads,node-load-misses,node-stores,node-store-misses' ./benchmark
# node-load-misses가 감소하면 NUMA 밸런싱 효과 있음
# 실시간 NUMA 마이그레이션 모니터링
watch -n 5 'grep -E "numa_(hit|miss|foreign)" /proc/vmstat'
# numa_hit이 증가하고 numa_miss가 감소하면 수렴 중
numa_balancing_scan_period_min_ms를 높이거나, 해당 태스크에 numactl --interleave를 사용하여 균등 분배하는 것이 효율적입니다.
실전 트러블슈팅 사례
CFS 스케줄러와 관련된 실제 운영 환경의 문제를 진단하고 해결하는 방법을 살펴봅니다.
사례 1: Kubernetes CPU 스로틀링(Throttling) 디버깅
container_cpu_cfs_throttled_seconds_total 메트릭이 높습니다.
원인: CFS 대역폭 제어(Bandwidth Control)의 100ms 기간(period) 내에 할당된 quota가 소진되면 파드 내 모든 스레드가 그 기간이 끝날 때까지 강제로 슬립됩니다. CPU 요청량을 낮게 설정하거나 burst 트래픽이 발생할 때 자주 나타납니다.
진단 방법:
# 스로틀 비율 확인 (cgroupv2)
cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/cpu.stat
# nr_throttled: 스로틀 발생 횟수
# throttled_usec: 총 스로틀 시간 (마이크로초)
# 스로틀 비율 계산
awk '/nr_periods/{p=$2} /nr_throttled/{t=$2} END{print t/p*100 "% throttled"}' \
/sys/fs/cgroup/kubepods/.../cpu.stat
# 실시간 모니터링
watch -n 1 "cat /sys/fs/cgroup/kubepods/burstable/pod*/cpu.stat | grep throttled"
해결책:
# Kubernetes Pod spec — CPU request/limit 조정
resources:
requests:
cpu: "500m" # 실제 평균 사용량에 맞게 증가
limits:
cpu: "2000m" # burst 여유를 위해 limit을 request의 4배로
# CFS bandwidth burst 활성화 (커널 5.14+, cgroupv2)
echo 50000 > /sys/fs/cgroup/kubepods/.../cpu.max.burst
# burst 허용량: 50ms — 순간적 스파이크를 budget에서 빌려 처리
# 또는 period를 줄여 granularity 향상 (기본 100ms → 10ms)
echo "200000 10000" > /sys/fs/cgroup/.../cpu.max
# quota 200ms / period 10ms → 20코어 상당
사례 2: 대화형 애플리케이션 지연 스파이크 분석
원인: 배치 작업(Batch job)의 vruntime이 충분히 쌓여 대화형 태스크가 스케줄링되지 못하는 경우, 또는 wakeup preemption이 너무 소극적으로 설정된 경우 발생합니다.
진단 방법:
# p99 스케줄링 지연 측정
perf sched latency -p <pid> -- sleep 10
# Task | Runtime ms | Switches | Average delay ms | Maximum delay ms
# 런큐 대기 시간 분포 (bpftrace)
bpftrace -e '
tracepoint:sched:sched_wakeup { @ts[args->pid] = nsecs; }
tracepoint:sched:sched_switch {
if (@ts[args->next_pid]) {
@us = hist((nsecs - @ts[args->next_pid]) / 1000);
delete(@ts[args->next_pid]);
}
}'
# 비자발적 컨텍스트 스위치 빈도 확인
pidstat -w -p <pid> 1
# nvcswch/s 열: 높으면 배치 작업에 의해 선점 빈번
해결책:
# 대화형 태스크를 배치 작업보다 우선: nice 값 조정
renice -n -10 -p <interactive-pid>
renice -n 19 -p <batch-pid>
# 또는 cgroup을 이용해 CPU weight 설정
echo 1024 > /sys/fs/cgroup/interactive/cpu.weight # 기본 100의 10배
echo 1 > /sys/fs/cgroup/batch/cpu.weight # 최소 weight
# wakeup granularity 감소 → 더 적극적 선점 (기본 1ms → 0.5ms)
echo 500000 > /proc/sys/kernel/sched_wakeup_granularity_ns
사례 3: NUMA 불균형 해결
numastat에서 특정 NUMA 노드의 메모리 접근이 압도적으로 많고, CPU 사용률이 노드 간 불균형하며, numa_miss 카운터가 빠르게 증가합니다.
진단 방법:
# NUMA 통계 확인
numastat -p <pid>
# numa_hit: 로컬 노드에서 할당된 페이지 수
# numa_miss: 원격 노드에서 할당된 페이지 수 (높으면 문제)
# 프로세스별 NUMA 밸런싱 통계
grep numa /proc/<pid>/sched
# numa_migrations: NUMA 마이그레이션 횟수
# node_0, node_1: 각 노드에서의 실행 시간
# NUMA 밸런싱 이벤트 추적
perf stat -e sched:sched_migrate_task,migrate:mm_migrate_pages -- sleep 10
해결책:
# 특정 NUMA 노드에 프로세스 고정
numactl --cpunodebind=0 --membind=0 ./my_app
# 실행 중인 프로세스의 NUMA 친화성 변경
migratepages <pid> 1 0 # 노드 1 메모리를 노드 0으로 이동
# NUMA 자동 밸런싱 스캔 주기 조정 (ms)
echo 5000 > /proc/sys/kernel/numa_balancing_scan_period_min_ms
echo 60000 > /proc/sys/kernel/numa_balancing_scan_period_max_ms
# 메모리 접근 패턴이 균일하면 interleave 정책 사용
numactl --interleave=all ./my_app
사례 4: CFS 대역폭 과도한 스로틀링
원인: CFS 대역폭 제어는 런큐 단위로 quota를 분배합니다. 많은 CPU를 가진 시스템에서 각 CPU가 조금씩 소비하면 전체 quota가 빠르게 소진될 수 있습니다(bandwidth slack 문제).
진단 방법:
# cgroup별 CPU 대역폭 설정 확인 (cgroupv2)
cat /sys/fs/cgroup/<group>/cpu.max
# "200000 100000" → 100ms 기간에 200ms quota (2 CPU 상당)
# 스로틀 상세 통계
cat /sys/fs/cgroup/<group>/cpu.stat
# nr_periods 1000
# nr_throttled 450 ← 45% 스로틀링 발생!
# throttled_usec 22500000
# 스로틀링 이벤트 실시간 추적
perf trace -e sched:sched_cfs_throttle_cpu -- sleep 5
해결책:
# 1. quota 증가 — 평균 사용량의 1.5~2배 설정 권장
echo "400000 100000" > /sys/fs/cgroup/<group>/cpu.max
# 2. period 단축 — 스로틀 지속 시간 감소 (100ms → 10ms)
echo "40000 10000" > /sys/fs/cgroup/<group>/cpu.max
# 3. burst 허용 (커널 5.14+) — 순간 스파이크를 budget에서 차용
echo 100000 > /sys/fs/cgroup/<group>/cpu.max.burst
# 4. cgroup quota slack 최소화 — bandwidth_slice 크기 축소
echo 1000 > /proc/sys/kernel/sched_cfs_bandwidth_slice_us
# 기본 5000us → 1000us: 런큐당 미리 할당하는 slice 감소
# 효과: quota가 더 균등하게 분배되지만 락 경합 약간 증가
nr_throttled / nr_periods 비율을 모니터링하고, 이 값이 5% 이하가 되도록 quota와 burst를 조정하세요. 가장 근본적인 해결책은 실제 CPU 사용 패턴을 프로파일링(Profiling)해 적정 request/limit 값을 찾는 것입니다.
참고 링크
커널 문서
- CFS Scheduler Design — CFS의 설계 철학, vruntime 개념, 공정성 원칙을 설명하는 커널 공식 문서입니다
- Scheduler 문서 인덱스 — 리눅스 커널 스케줄러 관련 공식 문서 전체 목록입니다
- Nice Design — nice 값과 가중치(weight) 매핑 설계를 설명하는 공식 문서입니다
- CFS Bandwidth Control — cgroup 기반 CFS 대역폭 제어(quota, period, burst)의 공식 문서입니다
- Scheduler Statistics — /proc/schedstat 및 스케줄러 통계 수집 방법을 설명하는 공식 문서입니다
- Scheduler Domains — NUMA 토폴로지 기반 로드 밸런싱 도메인 구조를 설명하는 공식 문서입니다
- Energy Aware Scheduling — CFS와 연동되는 에너지 인식 스케줄링(EAS) 공식 문서입니다
LWN 기사
- LWN: Schedulers — the plot thickens — Ingo Molnár가 CFS를 처음 제안한 배경과 O(1) 스케줄러와의 비교를 다루는 기사입니다
- LWN: An EEVDF CPU scheduler for Linux — CFS를 대체하는 EEVDF 알고리즘 도입 과정과 기존 CFS와의 차이를 분석합니다
- LWN: Per-entity load tracking — PELT(Per-Entity Load Tracking) 도입으로 CFS 로드 밸런싱이 개선된 과정을 설명합니다
- LWN: CFS group scheduling — cgroup 기반 그룹 스케줄링과 공정성 계층 구조를 다루는 기사입니다
- LWN: CFS bandwidth control — CFS 대역폭 제어 메커니즘의 설계와 구현을 설명하는 기사입니다
- LWN: Latency-nice as a replacement for autogroups — latency-nice 파라미터를 통한 CFS 지연 시간 튜닝을 논의합니다
- LWN: Fixing the CFS scheduler — CFS 초기 도입 이후 발견된 공정성 문제와 수정 사항을 다룹니다
- LWN: The many faces of "latency nice" — CFS 태스크의 지연 시간 우선순위 제어 논의를 정리합니다
- LWN: Core scheduling — SMT 환경에서 CFS의 코어 스케줄링 보안 기능을 설명합니다
커널 소스
- kernel/sched/fair.c — CFS 핵심 구현 코드입니다. enqueue/dequeue, pick_next_task, vruntime 계산 등이 포함됩니다
- kernel/sched/core.c — 스케줄러 코어 로직으로 schedule(), context_switch() 등이 구현되어 있습니다
- kernel/sched/sched.h — cfs_rq, sched_entity, rq 등 핵심 자료구조가 정의되어 있습니다
- kernel/sched/pelt.c — PELT(Per-Entity Load Tracking) 부하 추적 알고리즘 구현 코드입니다
- include/linux/sched.h — task_struct와 스케줄링 관련 필드 정의가 포함된 헤더 파일입니다