프로세스 스케줄러 심화 (Process Scheduler)
Linux 커널 프로세스 스케줄러의 전체 아키텍처를 심층적으로 다룹니다. sched_class 계층 구조, CFS와 EEVDF 알고리즘, 실시간 스케줄링(SCHED_FIFO/RR/DEADLINE), per-CPU 런큐, 로드 밸런싱, BPF 기반 sched_ext, 선점 모델, 그리고 스케줄러 디버깅 기법까지 포괄적으로 분석합니다.
핵심 요약
- CFS / EEVDF — 일반 프로세스용 공정 스케줄러. 가상 런타임(vruntime)으로 공평성을 유지합니다.
- sched_class — 스케줄링 정책의 플러그인 구조. dl → rt → fair → idle 우선순위입니다.
- 런큐(runqueue) — 각 CPU마다 하나씩 존재하며, 실행 대기 중인 태스크를 관리합니다.
- 선점(preemption) — 커널이 현재 실행 중인 태스크를 강제로 중단하고 다른 태스크를 실행합니다.
- 로드 밸런싱 — CPU 간에 태스크를 이동시켜 부하를 균등하게 분배합니다.
단계별 이해
- 스케줄러의 역할 — "다음에 어떤 프로세스를 실행할까?"를 결정합니다.
schedule()함수가 핵심입니다.타이머 인터럽트마다 현재 태스크의 시간을 업데이트하고 선점 여부를 판단합니다.
- CFS 동작 이해 — 각 태스크의 vruntime(가상 실행 시간)을 추적하여, vruntime이 가장 작은 태스크를 다음에 실행합니다.
Red-Black 트리로 관리되어 O(log N)에 다음 태스크를 선택합니다.
- 우선순위 확인 —
nice(-20~19) 값으로 프로세스 우선순위를 조정합니다. 낮을수록 더 많은 CPU 시간을 받습니다.ps -eo pid,ni,comm으로 현재 프로세스의 nice 값을 확인할 수 있습니다. - 실시간 스케줄링 —
SCHED_FIFO/SCHED_RR은 일반 태스크보다 항상 우선합니다.chrt -f 50 ./app으로 실시간 우선순위를 지정할 수 있습니다.
스케줄러 개요 (Scheduler Overview)
Linux 커널 스케줄러는 시스템의 모든 CPU에서 어떤 태스크를 언제, 얼마나 실행할지 결정하는 핵심 서브시스템입니다. 스케줄러의 주요 목표는 다음과 같습니다:
| 목표 | 설명 | 관련 메트릭 |
|---|---|---|
| 공정성 (Fairness) | 모든 태스크가 가중치에 비례하는 CPU 시간을 받도록 보장 | vruntime 편차, lag |
| 응답성 (Responsiveness) | 대화형 태스크의 스케줄링 지연 최소화 | wakeup-to-run latency |
| 처리량 (Throughput) | 단위 시간당 최대 작업 완료량 | IPC, context switch 빈도 |
| 실시간 보장 (RT Guarantee) | 실시간 태스크의 데드라인 충족 | worst-case latency |
| 에너지 효율 (Energy Efficiency) | 불필요한 CPU 활성화 최소화 | idle residency, C-state 진입률 |
| 확장성 (Scalability) | 수천 CPU NUMA 시스템에서도 효율적 동작 | lock contention, 밸런싱 오버헤드 |
커널 스케줄러의 핵심 진입점은 kernel/sched/core.c의 __schedule() 함수입니다. 이 함수는 현재 태스크를 중단하고 다음에 실행할 태스크를 선택하여 컨텍스트 스위치를 수행합니다.
/* kernel/sched/core.c — 스케줄러 핵심 진입점 (간략화) */
static void __sched notrace __schedule(unsigned int sched_mode)
{
struct task_struct *prev, *next;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu); /* per-CPU 런큐 획득 */
prev = rq->curr; /* 현재 실행 중인 태스크 */
rq_lock(rq, &rf);
/* 이전 태스크의 상태 갱신 (dequeue 여부 결정) */
if (!(sched_mode & SM_MASK_PREEMPT) && prev_state) {
if (signal_pending_state(prev_state, prev)) {
WRITE_ONCE(prev->__state, TASK_RUNNING);
} else {
prev->sched_contributes_to_load =
(prev_state & TASK_UNINTERRUPTIBLE) &&
!(prev_state & TASK_NOLOAD);
deactivate_task(rq, prev, DEQUEUE_SLEEP);
}
}
/* 다음 태스크 선택 — sched_class 계층을 순회 */
next = pick_next_task(rq, prev, &rf);
if (likely(prev != next)) {
rq->nr_switches++;
rq_set_curr(rq, next);
/* 컨텍스트 스위치 수행 */
rq = context_switch(rq, prev, next, &rf);
}
rq_unlock_irq(rq, &rf);
balance_callback(rq);
}
__schedule()은 다음 경로들에서 호출됩니다: (1) 태스크가 자발적으로 슬립할 때 (schedule()), (2) 인터럽트/시스템 콜 복귀 시 TIF_NEED_RESCHED 플래그가 설정된 경우, (3) cond_resched() 호출 시 선점 필요한 경우, (4) 선점형 커널에서 선점 카운터가 0이 될 때.
sched_class 계층 구조
Linux 스케줄러는 모듈화된 클래스 기반 설계를 채택합니다. 각 스케줄링 정책은 struct sched_class를 구현하며, 우선순위가 높은 클래스부터 순서대로 탐색합니다.
/* include/linux/sched.h — sched_class 인터페이스 (주요 콜백) */
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 (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
/* 태스크가 CPU를 양보할 때 */
void (*put_prev_task)(struct rq *rq, struct task_struct *p);
/* 주기적 타이머 틱 처리 */
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
/* 태스크 웨이크업 시 CPU 선택 */
int (*select_task_rq)(struct task_struct *p, int prev_cpu, int wake_flags);
/* 로드 밸런싱 관련 */
int (*balance)(struct rq *rq, struct task_struct *prev, struct rq_flags *rf);
void (*task_woken)(struct rq *rq, struct task_struct *p);
};
클래스 우선순위 체인
스케줄러가 pick_next_task()를 호출하면, 가장 높은 우선순위의 클래스부터 순차적으로 실행 가능한 태스크를 찾습니다.
| 우선순위 | sched_class | 소스 파일 | 용도 | 정책 |
|---|---|---|---|---|
| 1 (최고) | stop_sched_class |
kernel/sched/stop_task.c |
CPU 핫플러그, active migration | 내부 전용 (사용자 설정 불가) |
| 2 | dl_sched_class |
kernel/sched/deadline.c |
하드 실시간 태스크 | SCHED_DEADLINE (EDF/CBS) |
| 3 | rt_sched_class |
kernel/sched/rt.c |
소프트 실시간 태스크 | SCHED_FIFO, SCHED_RR |
| 4 | fair_sched_class |
kernel/sched/fair.c |
일반 태스크 (대부분의 프로세스) | SCHED_NORMAL, SCHED_BATCH |
| 5 (최저) | idle_sched_class |
kernel/sched/idle.c |
CPU idle 태스크 | SCHED_IDLE (per-CPU swapper) |
/* kernel/sched/core.c — pick_next_task 최적 경로 */
static inline struct task_struct *
__pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
/*
* 최적화: 런큐의 모든 태스크가 fair 클래스이면
* (nr_running == cfs_rq.h_nr_running) fair만 검사
*/
if (likely(rq->nr_running == rq->cfs.h_nr_running)) {
p = pick_next_task_fair(rq, prev, rf);
if (unlikely(p == RETRY_TASK))
goto restart;
return p;
}
restart:
put_prev_task_balance(rq, prev, rf);
/* 우선순위 높은 클래스부터 순회 */
for_each_class(class) {
p = class->pick_next_task(rq);
if (p)
return p;
}
/* idle 클래스는 항상 태스크를 반환 (per-CPU idle thread) */
BUG();
}
stop_sched_class → dl_sched_class → rt_sched_class → fair_sched_class → idle_sched_class 순서로 순회합니다. 각 클래스의 pick_next_task()가 NULL을 반환하면 다음 클래스로 넘어갑니다.
CFS (Completely Fair Scheduler)
CFS는 커널 2.6.23에서 Ingo Molnar가 도입한 스케줄러로, 이상적인 멀티태스킹 프로세서(Ideal Multi-Tasking CPU)를 근사(approximate)합니다. 이상적인 프로세서에서는 N개의 태스크가 각각 1/N의 CPU 시간을 동시에 받지만, 실제 하드웨어에서는 한 CPU에 하나의 태스크만 실행할 수 있으므로, CFS는 vruntime(가상 실행 시간)을 사용하여 공정성을 추적합니다.
vruntime (가상 실행 시간)
vruntime은 태스크가 실제로 소비한 CPU 시간을 가중치로 보정한 값입니다. nice 값이 낮은(우선순위 높은) 태스크는 vruntime이 느리게 증가하고, nice 값이 높은(우선순위 낮은) 태스크는 빠르게 증가합니다.
/* kernel/sched/fair.c — vruntime 갱신 (CFS, v6.5 이전) */
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->sum_exec_runtime += delta_exec;
/*
* vruntime 계산:
* delta_vruntime = delta_exec * (NICE_0_LOAD / weight)
*
* nice 0 (weight 1024): delta_vruntime = delta_exec * 1.0
* nice -5 (weight 3121): delta_vruntime = delta_exec * 0.328
* nice 5 (weight 335): delta_vruntime = delta_exec * 3.057
*/
curr->vruntime += calc_delta_fair(delta_exec, curr);
/* 런큐의 min_vruntime 갱신 */
update_min_vruntime(cfs_rq);
}
Red-Black Tree 기반 태스크 관리
CFS는 모든 실행 가능한(runnable) 태스크를 vruntime을 키로 하는 Red-Black Tree에 관리합니다. 트리의 가장 왼쪽(최소 vruntime) 노드가 다음 실행 대상입니다. 이 구조로 삽입/삭제/탐색이 모두 O(log N)이며, 가장 왼쪽 노드는 rb_leftmost에 캐싱되어 O(1) 접근이 가능합니다.
/* include/linux/sched.h — CFS 런큐 구조 */
struct cfs_rq {
struct load_weight load; /* 런큐 전체 가중치 합 */
unsigned int nr_running; /* 실행 가능 태스크 수 */
u64 min_vruntime; /* 런큐 내 최소 vruntime */
struct rb_root_cached tasks_timeline; /* RB-Tree (leftmost 캐싱) */
struct sched_entity *curr; /* 현재 실행 중인 엔티티 */
struct sched_entity *next; /* 다음 실행 힌트 */
struct sched_entity *skip; /* 건너뛸 엔티티 (yield) */
};
/* include/linux/sched.h — 스케줄 엔티티 */
struct sched_entity {
struct load_weight load; /* nice에 따른 가중치 */
struct rb_node run_node; /* RB-Tree 노드 */
unsigned int on_rq; /* 런큐에 있는지 여부 */
u64 exec_start; /* 현재 실행 시작 시각 */
u64 sum_exec_runtime; /* 누적 실행 시간 */
u64 vruntime; /* 가상 실행 시간 */
u64 prev_sum_exec_runtime; /* 이전 누적값 (preempt 체크용) */
};
타임 슬라이스 계산
CFS는 고정 타임 슬라이스를 사용하지 않습니다. 대신, 타겟 레이턴시(sched_latency_ns)를 런큐의 태스크 수로 나누고 가중치를 반영합니다.
/*
* 타임 슬라이스 계산 공식:
*
* sched_latency_ns * weight_i
* timeslice_i = ─────────────────────────────────
* total_weight
*
* 예: sched_latency = 6ms, 태스크 A(nice 0, w=1024), B(nice 5, w=335)
*
* A의 슬라이스 = 6ms * 1024 / (1024+335) = 4.52ms
* B의 슬라이스 = 6ms * 335 / (1024+335) = 1.48ms
*
* 태스크가 nr_latency(기본 8)개를 초과하면:
* sched_min_granularity_ns(기본 0.75ms) * nr_running 사용
*/
/* kernel/sched/fair.c — sysctl 기본값 */
unsigned int sysctl_sched_latency = 6000000ULL; /* 6ms */
unsigned int sysctl_sched_min_granularity = 750000ULL; /* 0.75ms */
unsigned int sysctl_sched_wakeup_granularity = 1000000ULL; /* 1ms */
next/last 힌트 기반의 임시방편적 wakeup 선점 로직, (3) sched_latency 보장이 확률적(best-effort)이라는 점이었습니다.
EEVDF (Earliest Eligible Virtual Deadline First)
EEVDF는 커널 v6.6에서 Peter Zijlstra에 의해 CFS를 대체한 fair 스케줄링 알고리즘입니다. 1995년 Stoica와 Abdel-Wahab이 제안한 이론적 모델을 기반으로 하며, CFS의 단순한 "최소 vruntime" 선택 대신 적격 시각(eligible time)과 가상 데드라인(virtual deadline)을 사용하여 지연 시간 보장을 강화합니다.
핵심 개념
| 개념 | 기호 | 설명 |
|---|---|---|
| 가상 시간 (Virtual Time) | V(t) | 이상적 프로세서에서 각 태스크가 받았어야 할 시간의 기준점 |
| 적격 시각 (Eligible Time) | ei | 태스크 i가 실행 권리를 갖는 가상 시각. lag ≥ 0이면 적격 |
| 가상 데드라인 (Virtual Deadline) | di | ei + (요청 슬라이스 / 가중치). 작을수록 긴급 |
| 래그 (Lag) | lagi | 이상적 서비스량 - 실제 서비스량. 양수면 서비스 부족(eligible) |
| 요청 슬라이스 (Request/Slice) | ri | 태스크가 요청한 실행 시간 단위 (커널의 slice 필드) |
알고리즘 동작 원리
EEVDF의 태스크 선택 과정은 두 단계입니다:
- 적격성(Eligibility) 필터링:
vruntime ≤ V(t)(즉, lag ≥ 0)인 태스크만 후보로 선별 - 데드라인 기반 선택: 적격 태스크 중
virtual deadline이 가장 빠른 태스크를 선택
/* kernel/sched/fair.c — EEVDF pick_eevdf() (v6.6+, 간략화) */
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);
/*
* 적격성 검사: entity의 vruntime이 cfs_rq의 avg_vruntime 이하인지
* (lag >= 0 ⟺ vruntime <= V(t) ⟺ eligible)
*/
if (entity_eligible(cfs_rq, se)) {
/* 적격 태스크 중 deadline이 가장 빠른 것 선택 */
if (!best || deadline_gt(deadline, best, se))
best = se;
node = node->rb_left; /* 더 빠른 vruntime (eligible) 탐색 */
} else {
node = node->rb_right; /* 아직 eligible 아님 → 오른쪽 */
}
}
return best;
}
/* 적격성 판단 (O(1)) */
static int entity_eligible(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
/*
* avg_vruntime은 런큐의 가중 평균 vruntime = V(t)
* se->vruntime <= V(t) 이면 eligible (서비스 부족 상태)
*/
return vruntime_eligible(cfs_rq, se->vruntime);
}
슬라이스와 데드라인
EEVDF에서 각 태스크의 가상 데드라인은 eligible time + slice / weight로 계산됩니다. slice는 태스크가 한 번에 요청하는 실행 시간 단위입니다.
/* kernel/sched/fair.c — 데드라인 갱신 */
static void update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
if ((s64)(se->vruntime - se->deadline) >= 0) {
/*
* 현재 슬라이스 소진 → 새 데드라인 설정
* deadline = vruntime + calc_delta_fair(slice, se)
*
* calc_delta_fair: slice를 weight로 보정
* nice 0: deadline = vruntime + slice
* nice -5: deadline = vruntime + slice * 0.328
*/
se->deadline = se->vruntime +
calc_delta_fair(se->slice, se);
/* 선점 검사: 새 태스크의 deadline이 현재보다 빠르면 resched */
if (cfs_rq->nr_running > 1)
resched_curr(rq_of(cfs_rq));
}
}
avg_vruntime (가중 평균 가상 시간)
EEVDF는 런큐의 "현재 가상 시간" V(t)를 추적하기 위해 avg_vruntime을 유지합니다. 이는 모든 실행 가능한 엔티티의 vruntime 가중 평균입니다.
/* kernel/sched/fair.c — 가중 평균 vruntime 계산 */
static u64 avg_vruntime(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
s64 avg = cfs_rq->avg_vruntime;
long load = cfs_rq->avg_load;
if (curr && curr->on_rq) {
unsigned long weight = scale_load_down(curr->load.weight);
avg += entity_key(cfs_rq, curr) * weight;
load += weight;
}
if (load)
avg = div_s64(avg, load);
return cfs_rq->min_vruntime + avg;
}
실시간 스케줄링 (Real-Time Scheduling)
Linux는 POSIX.1b 실시간 스케줄링 정책을 지원합니다. RT 태스크는 fair 클래스보다 항상 높은 우선순위를 가지므로, RT 태스크가 실행 가능한 한 일반 태스크는 CPU를 받지 못합니다.
RT 정책: SCHED_FIFO vs SCHED_RR
| 속성 | SCHED_FIFO | SCHED_RR |
|---|---|---|
| 타임 슬라이스 | 무한 (자발적 양보까지 계속 실행) | 고정 (기본 100ms, sched_rr_timeslice_ms) |
| 같은 우선순위 내 동작 | FIFO 순서, 선점 없음 | 라운드 로빈, 슬라이스 소진 시 큐 뒤로 |
| 우선순위 범위 | 1 ~ 99 (99가 최고) | 1 ~ 99 (99가 최고) |
| 선점 | 더 높은 우선순위 RT에 의해서만 | 더 높은 우선순위 + 슬라이스 만료 |
| 용도 | 지연 민감 제어 루프 | 균등 시분할이 필요한 RT 태스크 |
/* RT 스케줄링 설정 예제 (사용자 공간) */
#include <sched.h>
struct sched_param param;
param.sched_priority = 80; /* 1-99, 높을수록 높은 우선순위 */
/* SCHED_FIFO 설정 */
sched_setscheduler(pid, SCHED_FIFO, ¶m);
/* SCHED_RR 설정 (타임 슬라이스 포함) */
sched_setscheduler(pid, SCHED_RR, ¶m);
/* 현재 RR 타임 슬라이스 확인 */
struct timespec ts;
sched_rr_get_interval(pid, &ts); /* 기본 100ms */
RT 스케줄러 내부 구현
RT 스케줄러는 우선순위별 연결 리스트 배열을 사용합니다. 비트맵으로 비어 있지 않은 우선순위를 O(1)에 찾습니다.
/* kernel/sched/rt.c — RT 런큐 구조 */
struct rt_prio_array {
DECLARE_BITMAP(bitmap, MAX_RT_PRIO + 1); /* 100비트 비트맵 */
struct list_head queue[MAX_RT_PRIO]; /* 우선순위별 리스트 */
};
struct rt_rq {
struct rt_prio_array active; /* 활성 태스크 배열 */
unsigned int rt_nr_running; /* RT 태스크 수 */
unsigned int rr_nr_running; /* SCHED_RR 태스크 수 */
struct {
int curr; /* 현재 최고 우선순위 */
int next; /* 다음 최고 우선순위 */
} highest_prio;
};
/* pick_next_task_rt — O(1) 최고 우선순위 태스크 선택 */
static struct task_struct *
pick_next_task_rt(struct rq *rq)
{
struct rt_rq *rt_rq = &rq->rt;
struct rt_prio_array *array = &rt_rq->active;
int idx;
idx = sched_find_first_bit(array->bitmap); /* 비트맵에서 최고 우선순위 */
if (idx >= MAX_RT_PRIO)
return NULL;
return list_first_entry(&array->queue[idx],
struct sched_rt_entity, run_list);
}
RT 쓰로틀링 (RT Throttling)
RT 태스크가 CPU를 독점하여 일반 태스크가 기아(starvation) 상태에 빠지는 것을 방지하기 위해, 커널은 RT 대역폭 제한(RT bandwidth throttling)을 적용합니다.
# RT 쓰로틀링 기본값 확인
cat /proc/sys/kernel/sched_rt_period_us # 1000000 (1초)
cat /proc/sys/kernel/sched_rt_runtime_us # 950000 (950ms)
# 의미: 1초 주기 중 RT 태스크는 최대 950ms만 실행 가능
# 나머지 50ms는 일반(fair) 태스크에 할당
# RT 쓰로틀링 비활성화 (위험! 프로덕션에서 사용 금지)
echo -1 > /proc/sys/kernel/sched_rt_runtime_us
sched_rt_runtime_us = -1), 무한 루프 버그가 있는 RT 태스크가 시스템을 완전히 멈출 수 있습니다. 반드시 개발/테스트 환경에서만 사용하고, 프로덕션 시스템에서는 적절한 RT 대역폭 제한을 유지하십시오.
SCHED_DEADLINE
SCHED_DEADLINE은 커널 3.14에서 도입된 CBS(Constant Bandwidth Server) + EDF(Earliest Deadline First) 기반 스케줄링 정책입니다. RT 클래스보다도 높은 우선순위를 가지며, 태스크에 명시적인 실행 예산(runtime), 주기(period), 데드라인(deadline)을 할당합니다.
SCHED_DEADLINE 파라미터
| 파라미터 | 필드 | 설명 |
|---|---|---|
| Runtime (Q) | sched_runtime |
한 주기 내 최대 실행 시간 (ns) |
| Deadline (D) | sched_deadline |
주기 시작부터 runtime을 소진해야 하는 시한 (ns) |
| Period (T) | sched_period |
태스크 활성화 주기 (ns). D ≤ T 필수 |
/* SCHED_DEADLINE 설정 예제 (사용자 공간) */
#include <sched.h>
#include <linux/sched/types.h>
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10 * 1000 * 1000, /* 10ms 실행 예산 */
.sched_deadline = 30 * 1000 * 1000, /* 30ms 데드라인 */
.sched_period = 50 * 1000 * 1000, /* 50ms 주기 */
};
/* sched_setattr 시스템 콜 사용 (glibc 래퍼 없음 → syscall 직접) */
syscall(SYS_sched_setattr, pid, &attr, 0);
/*
* 대역폭 비율: Q/T = 10ms/50ms = 20%
* 즉, 이 태스크는 CPU의 20%를 보장받으며,
* 매 주기 시작 후 30ms 이내에 10ms를 실행
*/
CBS (Constant Bandwidth Server)
CBS는 각 DEADLINE 태스크를 독립적인 "서버"로 취급하여, 한 태스크의 과도한 실행이 다른 태스크에 영향을 미치지 않도록 격리합니다.
/* kernel/sched/deadline.c — CBS 서버 보충 로직 (간략화) */
static void replenish_dl_entity(struct sched_dl_entity *dl_se)
{
/*
* 새 주기 시작 → 실행 예산(runtime) 보충
* deadline = 현재시각 + relative_deadline
*/
dl_se->runtime = dl_se->dl_runtime; /* Q 보충 */
dl_se->deadline = rq_clock(rq) + dl_se->dl_deadline;
/*
* 과거 deadline이 아직 유효하면 (서버가 idle 상태였으면)
* deadline을 현재 기준으로 리셋하여 다른 태스크에 피해 방지
*/
if (dl_time_before(dl_se->deadline, rq_clock(rq))) {
dl_se->deadline = rq_clock(rq) + dl_se->dl_deadline;
dl_se->runtime = dl_se->dl_runtime;
}
}
/* EDF 기반 태스크 선택: deadline이 가장 빠른 태스크 */
static struct task_struct *
pick_next_task_dl(struct rq *rq)
{
struct dl_rq *dl_rq = &rq->dl;
struct rb_node *left = rb_first_cached(&dl_rq->root);
if (!left)
return NULL;
return dl_task_of(__node_2_dle(left)); /* 최소 deadline */
}
입장 제어 (Admission Control)
SCHED_DEADLINE은 시스템의 총 대역폭이 초과되지 않도록 입장 제어를 수행합니다.
/*
* 입장 제어 조건 (간략화):
*
* 모든 DEADLINE 태스크 i에 대해:
* Σ(Q_i / T_i) <= total_bw
*
* total_bw = 각 CPU의 (sched_rt_runtime / sched_rt_period)
* 기본값: 950ms / 1000ms = 0.95 (95%)
*
* 즉, 새 DEADLINE 태스크 추가 시
* 기존 대역폭 합 + 새 대역폭이 95%를 초과하면
* sched_setattr()은 -EBUSY를 반환
*/
런큐 (Per-CPU Runqueue)
각 CPU에는 고유한 런큐(struct rq)가 있습니다. 이는 스케줄러의 핵심 자료구조로, 해당 CPU에서 실행 가능한 모든 태스크를 관리합니다. per-CPU 설계 덕분에 대부분의 스케줄링 결정이 로컬 락만으로 가능합니다.
/* kernel/sched/sched.h — per-CPU 런큐 구조 (주요 필드) */
struct rq {
/* --- 글로벌 상태 --- */
raw_spinlock_t __lock; /* 런큐 락 */
unsigned int nr_running; /* 총 실행 가능 태스크 수 */
unsigned int nr_switches; /* 컨텍스트 스위치 카운터 */
/* --- 현재 실행 중 --- */
struct task_struct *curr; /* 현재 태스크 */
struct task_struct *idle; /* per-CPU idle 태스크 */
struct task_struct *stop; /* stop 태스크 (migration) */
/* --- 클래스별 서브 런큐 --- */
struct cfs_rq cfs; /* CFS/EEVDF 런큐 */
struct rt_rq rt; /* RT 런큐 */
struct dl_rq dl; /* DEADLINE 런큐 */
/* --- 시간 관리 --- */
u64 clock; /* 런큐 시계 (ns) */
u64 clock_task; /* irq 시간 제외 태스크 시계 */
/* --- 로드 밸런싱 --- */
struct sched_domain *sd; /* 스케줄링 도메인 */
unsigned long cpu_capacity; /* CPU 용량 (freq 반영) */
/* --- 선점/리스케줄 --- */
int skip_clock_update; /* 시계 갱신 건너뛰기 */
unsigned long nr_uninterruptible; /* load avg 보정용 */
/* --- NUMA 밸런싱 --- */
unsigned int nr_preferred_running; /* NUMA 선호 CPU 태스크 */
struct cpu_stop_work active_balance_work; /* 능동 밸런싱 */
int active_balance;
int push_cpu;
};
컨텍스트 스위치 경로
컨텍스트 스위치는 현재 태스크의 CPU 상태(레지스터, 스택 포인터)를 저장하고 다음 태스크의 상태를 복원하는 과정입니다.
/* 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)
{
/* 1단계: 메모리 컨텍스트 전환 (mm_struct) */
if (!next->mm) {
/* 커널 스레드 → 이전 태스크의 mm 빌려씀 (lazy TLB) */
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
} else {
/* 사용자 프로세스 → 페이지 테이블 전환 */
switch_mm_irqs_off(prev->active_mm, next->mm, next);
}
/* 2단계: CPU 레지스터/스택 전환 (아키텍처 의존) */
switch_to(prev, next, prev);
/* === 이 시점에서 CPU는 'next' 태스크를 실행 중 === */
return finish_task_switch(prev);
}
switch_to(prev, next, last)는 아키텍처별로 구현됩니다. x86에서는 RSP(스택 포인터)를 교체하고 RIP(명령어 포인터)를 __switch_to_asm에서 전환합니다. 세 번째 인자 last는 스위치 후 "이전에 실행 중이던 태스크"를 반환받기 위한 것으로, A→B→A 순환에서 A가 복귀했을 때 B를 정리하기 위해 필요합니다.
로드 밸런싱 (Load Balancing)
SMP 시스템에서 CPU 간 부하를 균등하게 분배하는 것은 성능의 핵심입니다. Linux 스케줄러는 sched_domain 계층 구조를 통해 토폴로지 인식 로드 밸런싱을 수행합니다.
sched_domain 계층 구조
스케줄링 도메인은 CPU의 물리적 토폴로지를 반영합니다. SMT(하이퍼스레딩), MC(멀티코어), NUMA 노드 수준으로 계층화됩니다.
예: 2-NUMA-node, 각 4코어 8스레드 시스템
NUMA Domain (SD_NUMA)
├── Node 0: MC Domain (SD_MC)
│ ├── Core 0: SMT Domain (SD_SMT)
│ │ ├── CPU 0 (thread 0)
│ │ └── CPU 1 (thread 1)
│ ├── Core 1: SMT Domain
│ │ ├── CPU 2
│ │ └── CPU 3
│ ├── Core 2: SMT Domain
│ │ ├── CPU 4
│ │ └── CPU 5
│ └── Core 3: SMT Domain
│ ├── CPU 6
│ └── CPU 7
└── Node 1: MC Domain (SD_MC)
├── Core 4: SMT Domain
│ ├── CPU 8
│ └── CPU 9
├── Core 5: SMT Domain
│ ├── CPU 10
│ └── CPU 11
├── Core 6: SMT Domain
│ ├── CPU 12
│ └── CPU 13
└── Core 7: SMT Domain
├── CPU 14
└── CPU 15
로드 밸런싱 알고리즘
로드 밸런싱은 주기적으로(sched_balance_softirq) 또는 idle CPU가 작업을 찾을 때(idle balancing) 트리거됩니다.
/* kernel/sched/fair.c — 로드 밸런싱 핵심 흐름 (간략화) */
static int sched_balance_rq(struct rq *this_rq,
struct sched_domain *sd,
enum cpu_idle_type idle)
{
struct lb_env env = {
.sd = sd,
.dst_cpu = this_rq->cpu,
.dst_rq = this_rq,
.idle = idle,
};
/* 1단계: 가장 바쁜 그룹 찾기 */
struct sched_group *busiest_group =
find_busiest_group(&env, &sds);
if (!busiest_group)
goto out_balanced;
/* 2단계: 그룹 내 가장 바쁜 런큐 찾기 */
struct rq *busiest =
find_busiest_queue(&env, busiest_group);
if (!busiest)
goto out_balanced;
/* 3단계: 태스크 이주 (busiest → this_rq) */
detach_tasks(&env);
if (env.imbalance && !env.loop_break) {
attach_tasks(&env);
}
return 0;
out_balanced:
return 0;
}
밸런싱 메트릭
| 메트릭 | 설명 | 용도 |
|---|---|---|
cpu_load |
PELT 기반 CPU 부하 (지수 감쇠 평균) | idle balancing 판단 |
runnable_avg |
태스크의 실행 가능 비율 (0~1024) | 태스크 가중치 산출 |
util_avg |
태스크의 실제 CPU 활용도 (0~1024) | CPU 용량 대비 활용도 비교 |
nr_running |
런큐의 실행 가능 태스크 수 | 불균형 감지 |
cpu_capacity |
CPU 용량 (주파수, 열 제한 반영) | 비대칭(big.LITTLE) 밸런싱 |
CPU 마이그레이션
태스크가 한 CPU에서 다른 CPU로 이주하는 경로는 크게 세 가지입니다:
/*
* 1. Pull migration (주기적 밸런싱)
* - 바쁜 CPU에서 idle CPU로 태스크를 "당겨옴"
* - softirq 컨텍스트에서 실행
* - sched_balance_rq() → detach_tasks() → attach_tasks()
*
* 2. Push migration (RT/DL 전용)
* - 높은 우선순위 태스크가 깨어나면 적합한 CPU로 "밀어냄"
* - push_rt_task() / push_dl_task()
*
* 3. Wake-up migration
* - 태스크가 깨어날 때 select_task_rq()에서 최적 CPU 선택
* - 캐시 친화성 vs 부하 분산 트레이드오프
*/
/* kernel/sched/fair.c — select_task_rq_fair (웨이크업 CPU 선택, 간략화) */
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
struct sched_domain *sd;
int new_cpu = prev_cpu;
int want_affine = 0;
/* 깨운 CPU가 prev_cpu와 같은 도메인이면 affinity 선호 */
if (cpumask_test_cpu(smp_processor_id(), p->cpus_ptr))
want_affine = 1;
/* 도메인 계층을 올라가며 가장 idle한 CPU/그룹 선택 */
for_each_domain(smp_processor_id(), sd) {
if (want_affine && (sd->flags & SD_WAKE_AFFINE)) {
new_cpu = select_idle_sibling(p, prev_cpu, new_cpu);
break;
}
}
return new_cpu;
}
sched_ext (BPF 기반 확장 스케줄링, v6.12+)
sched_ext는 커널 v6.12에서 도입된 BPF 기반 확장 가능 스케줄링 프레임워크입니다. 커널을 재컴파일하지 않고도 BPF 프로그램을 통해 스케줄링 정책을 커스터마이징할 수 있습니다. ext_sched_class는 fair와 idle 사이에 위치합니다.
아키텍처
/* sched_ext 클래스 위치 (우선순위 순) */
/*
* stop_sched_class (최고)
* dl_sched_class
* rt_sched_class
* fair_sched_class
* ext_sched_class ← sched_ext (v6.12+)
* idle_sched_class (최저)
*/
/* include/linux/sched/ext.h — sched_ext_ops 구조체 (주요 콜백) */
struct sched_ext_ops {
/* 태스크가 런큐에 삽입될 때 */
void (*enqueue)(struct task_struct *p, u64 enq_flags);
/* 태스크가 런큐에서 제거될 때 */
void (*dequeue)(struct task_struct *p, u64 deq_flags);
/* 다음 실행 태스크 선택 (핵심 콜백) */
struct task_struct *(*dispatch)(s32 cpu, struct task_struct *prev);
/* 태스크가 실행을 시작/종료할 때 */
void (*running)(struct task_struct *p);
void (*stopping)(struct task_struct *p, bool runnable);
/* CPU 선택 (wakeup 시) */
s32 (*select_cpu)(struct task_struct *p, s32 prev_cpu, u64 wake_flags);
/* 초기화/종료 */
s32 (*init)(void);
void (*exit)(struct scx_exit_info *info);
/* 스케줄러 이름 */
char name[SCX_OPS_NAME_LEN];
};
sched_ext BPF 스케줄러 예제
/* 간단한 sched_ext BPF 스케줄러 예제 (scx_simple) */
#include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
/* 글로벌 FIFO 디스패치 큐 사용 */
s32 BPF_STRUCT_OPS(simple_select_cpu,
struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
/* idle CPU가 있으면 직접 디스패치 (fast path) */
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) {
scx_bpf_dispatch(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
}
return cpu;
}
void BPF_STRUCT_OPS(simple_enqueue,
struct task_struct *p, u64 enq_flags)
{
/* 글로벌 FIFO 큐에 삽입 */
scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}
SEC(".struct_ops.link")
struct sched_ext_ops simple_ops = {
.select_cpu = (void *)simple_select_cpu,
.enqueue = (void *)simple_enqueue,
.name = "simple",
};
# sched_ext 스케줄러 로드/관리
# scx_simple 스케줄러 로드 (커널 tools/sched_ext/에 포함)
sudo ./scx_simple
# 현재 활성 sched_ext 스케줄러 확인
cat /sys/kernel/sched_ext/root/ops
# 스케줄러 통계
cat /sys/kernel/sched_ext/root/stats
# sched_ext 비활성화 (fair로 복귀)
# → 스케줄러 프로세스를 종료하면 자동으로 fair 복귀
선점 모델 (Preemption Models)
Linux 커널의 선점(preemption)은 실행 중인 커널 코드를 중단하고 더 높은 우선순위의 태스크로 전환할 수 있는 메커니즘입니다. 유저 공간에서 실행 중인 프로세스는 항상 선점 가능하지만, 커널 공간에서의 선점 가능 여부는 커널 설정에 따라 달라집니다. 선점 모델은 응답성(latency)과 처리량(throughput) 사이의 근본적인 트레이드오프를 결정하는 핵심 설계 선택입니다.
TIF_NEED_RESCHED가 설정되어 있으면 schedule()이 호출됩니다. 선점 모델 간의 차이는 오직 커널 선점(Kernel Preemption)에서 발생합니다: 커널 코드를 실행하는 도중 스케줄러가 개입할 수 있는 시점이 달라집니다.
선점 모델 비교
| 모델 | CONFIG 옵션 | 커널 선점 시점 | 특성 |
|---|---|---|---|
| PREEMPT_NONE | CONFIG_PREEMPT_NONE |
시스템 콜/인터럽트 복귀 시에만 | 최대 처리량, 서버 워크로드에 적합 |
| PREEMPT_VOLUNTARY | CONFIG_PREEMPT_VOLUNTARY |
명시적 선점 포인트 (might_sleep(), cond_resched()) |
데스크톱 기본, 적절한 응답성과 처리량 균형 |
| PREEMPT (Full) | CONFIG_PREEMPT |
preempt_count == 0인 모든 구간 |
저지연 데스크톱, 오디오/게이밍 워크로드 |
| PREEMPT_RT | CONFIG_PREEMPT_RT |
거의 모든 커널 코드에서 (spinlock도 선점 가능) | 하드 실시간, 결정론적 지연 시간 |
비선점 모드 상세 (PREEMPT_NONE)
PREEMPT_NONE은 커널 코드 실행 중 선점을 허용하지 않는 가장 보수적인 모델입니다. 커널에 진입한 태스크는 자발적으로 CPU를 양보하거나, 유저 공간으로 복귀하거나, 명시적으로 schedule()을 호출할 때까지 계속 실행됩니다.
/* PREEMPT_NONE에서의 선점 동작 흐름 */
/* 시나리오: 태스크 A가 시스템 콜 처리 중, 태스크 B(높은 우선순위)가 깨어남 */
/* 1. 타이머 tick이 발생하여 TIF_NEED_RESCHED 설정 */
scheduler_tick() → task_tick_fair()
→ set_tsk_need_resched(curr); /* 플래그만 설정, 즉시 선점 안 함 */
/* 2. 태스크 A는 시스템 콜 처리를 계속 진행 (수 밀리초~수십 밀리초) */
/* → PREEMPT_NONE에서는 커널 코드 실행 중 선점 검사를 하지 않음 */
/* 3. 시스템 콜이 완료되고 유저 공간으로 복귀하는 시점에서야 선점 발생 */
syscall_exit_to_user_mode()
→ exit_to_user_mode_prepare()
→ if (test_thread_flag(TIF_NEED_RESCHED))
schedule(); /* 여기서야 비로소 태스크 B로 전환 */
비선점의 장점:
- 최대 처리량: 컨텍스트 스위칭 오버헤드 최소화, 긴 커널 경로를 중단 없이 실행
- 락 경합 감소: 선점에 의한 불필요한 락 경합이 없으므로 멀티스레드 워크로드에서 유리
- 캐시 효율: 태스크 전환이 적어 CPU 캐시, TLB 히트율이 높음
- 예측 가능한 배치 처리: 데이터베이스, 파일 서버 등 throughput-critical 워크로드에 적합
비선점의 단점:
- 긴 지연 시간: 커널 내 긴 코드 경로(예: 대용량 메모리 할당, 긴 파일시스템 연산)에서 수 밀리초 이상의 스케줄링 지연 발생 가능
- 대화형 응답성 저하: 데스크톱 환경에서 UI 반응이 느려질 수 있음
- 실시간 태스크 불리: RT 태스크도 커널 경로가 끝날 때까지 대기해야 함
PREEMPT_NONE 또는 PREEMPT_VOLUNTARY를 사용합니다. 서버 워크로드는 대화형 응답성보다 초당 요청 처리 수(throughput)가 중요하며, 선점 오버헤드를 제거하면 CPU-bound 작업에서 2~5%의 성능 이득을 얻을 수 있습니다.
자발적 선점 포인트 (Voluntary Preemption Points)
PREEMPT_NONE과 PREEMPT_VOLUNTARY 모델에서는 커널 코드 내 명시적으로 배치된 자발적 선점 포인트가 유일한 커널 내 스케줄링 기회입니다. 커널 개발자는 긴 실행 경로에 이러한 포인트를 적절히 배치해야 합니다.
| 함수 | 동작 | 사용 조건 | 비고 |
|---|---|---|---|
cond_resched() |
TIF_NEED_RESCHED 설정 시 schedule() 호출 |
선점 비활성화 구간 밖, 슬립 가능 컨텍스트 | 가장 일반적인 자발적 양보 포인트 |
cond_resched_lock() |
spinlock을 해제하고 스케줄링 후 다시 획득 | spinlock 보유 중 | 긴 루프에서 spinlock 보유 시간 제한 |
might_sleep() |
PREEMPT_VOLUNTARY에서 선점 포인트로 동작, 디버그 빌드에서 atomic 컨텍스트 검증 |
슬립 가능 컨텍스트 | CONFIG_DEBUG_ATOMIC_SLEEP에서 BUG 검출 |
schedule() |
명시적으로 스케줄러 호출, CPU 즉시 양보 | 슬립 가능 컨텍스트 | 대기 루프, 커널 스레드 메인 루프에서 사용 |
yield() |
현재 태스크를 런큐 맨 뒤로 이동 | 유저/커널 모두 | 비권장: 예측 불가능한 동작, sched_yield(2) |
/* 자발적 선점 포인트 사용 패턴 */
/* 1. cond_resched() — 긴 커널 루프에서 주기적 양보 */
static int do_large_scan(struct address_space *mapping)
{
struct folio *folio;
unsigned long index = 0;
while ((folio = find_get_folio(mapping, index++))) {
process_folio(folio);
folio_put(folio);
/* 긴 루프에서 주기적으로 CPU 양보 기회 제공 */
if (need_resched())
cond_resched(); /* TIF_NEED_RESCHED 설정 시 schedule() 호출 */
}
return 0;
}
/* 2. cond_resched_lock() — spinlock 보유 중 양보 */
static void flush_all_entries(struct list_head *head, spinlock_t *lock)
{
struct entry *e, *tmp;
spin_lock(lock);
list_for_each_entry_safe(e, tmp, head, node) {
list_del(&e->node);
process_entry(e);
cond_resched_lock(lock); /* lock 해제 → schedule() → lock 재획득 */
}
spin_unlock(lock);
}
/* 3. might_sleep() — 슬립 가능 컨텍스트 검증 */
void *kmalloc(size_t size, gfp_t flags)
{
if (flags & __GFP_DIRECT_RECLAIM)
might_sleep(); /* atomic 컨텍스트에서 GFP_KERNEL 사용 시 경고 */
/* ... */
}
yield()는 현재 태스크를 런큐 맨 뒤로 보내지만, 다른 실행 가능한 태스크가 없으면 즉시 다시 실행됩니다. CFS에서는 vruntime 기반 스케줄링으로 인해 yield()의 동작이 직관적이지 않을 수 있습니다. 커널 코드에서는 cond_resched()를, 유저 공간에서는 적절한 동기화 프리미티브(mutex, futex, condition variable)를 사용하세요.
preempt_count 중첩 동작 상세
preempt_count는 각 태스크(스레드)에 연결된 32비트 정수로, 현재 실행 컨텍스트와 선점 비활성화 상태를 추적합니다. 이 값이 0이 아니면 커널 선점이 불가합니다.
/* include/linux/preempt.h — preempt_count 필드 정의 */
#define PREEMPT_BITS 8
#define SOFTIRQ_BITS 8
#define HARDIRQ_BITS 4
#define NMI_BITS 1
#define PREEMPT_SHIFT 0
#define SOFTIRQ_SHIFT (PREEMPT_SHIFT + PREEMPT_BITS) /* 8 */
#define HARDIRQ_SHIFT (SOFTIRQ_SHIFT + SOFTIRQ_BITS) /* 16 */
#define NMI_SHIFT (HARDIRQ_SHIFT + HARDIRQ_BITS) /* 20 */
/* 중첩 동작 예시 */
spin_lock(&lock_a); /* preempt_count: 0 → 1 (PREEMPT 필드) */
spin_lock(&lock_b); /* preempt_count: 1 → 2 (중첩 가능) */
local_bh_disable(); /* preempt_count: 2 → 2 + 0x100 (SOFTIRQ 필드 증가) */
local_bh_enable(); /* preempt_count: SOFTIRQ 필드 감소 */
spin_unlock(&lock_b); /* preempt_count: 2 → 1 */
spin_unlock(&lock_a); /* preempt_count: 1 → 0 → 선점 검사! */
/* 선점 가능 여부 확인 */
#define preemptible() (preempt_count() == 0 && !irqs_disabled())
선점 메커니즘
/* include/linux/preempt.h — 선점 비활성화/활성화 */
#define preempt_disable() \
do { preempt_count_inc(); barrier(); } while (0)
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); /* count==0 && need_resched → schedule() */ \
} while (0)
/* TIF_NEED_RESCHED 설정 (스케줄러가 재스케줄 요청) */
static inline void set_tsk_need_resched(struct task_struct *tsk)
{
set_tsk_thread_flag(tsk, TIF_NEED_RESCHED);
}
/* preempt_schedule — 커널 선점 진입점 (CONFIG_PREEMPT) */
asmlinkage void __preempt_schedule(void)
{
if (likely(!preemptible()))
return;
do {
preempt_disable();
__schedule(SM_PREEMPT);
preempt_enable_no_resched();
} while (need_resched());
}
선점 흐름 비교 다이어그램
아래 다이어그램은 동일한 시나리오에서 세 가지 선점 모델의 동작 차이를 보여줍니다. 태스크 A(일반 우선순위)가 긴 시스템 콜을 처리하는 도중, 태스크 B(높은 우선순위)가 깨어나는 상황입니다.
CONFIG_PREEMPT_DYNAMIC (런타임 전환)
커널 5.12에서 도입되고 6.x에서 안정화된 CONFIG_PREEMPT_DYNAMIC은 재부팅 없이 런타임에 선점 모델을 전환할 수 있게 합니다. 내부적으로 static_call 메커니즘을 사용하여 함수 포인터 오버헤드 없이 선점 포인트의 동작을 전환합니다.
# 현재 선점 모델 확인
cat /sys/kernel/debug/sched/preempt
# 출력 예: "full" 또는 "voluntary" 또는 "none"
# 런타임 선점 모델 전환 (root 필요)
echo none > /sys/kernel/debug/sched/preempt
echo voluntary > /sys/kernel/debug/sched/preempt
echo full > /sys/kernel/debug/sched/preempt
# 부팅 파라미터로 초기 선점 모델 지정
# GRUB_CMDLINE_LINUX에 추가:
preempt=none # 서버 워크로드
preempt=voluntary # 데스크톱 기본
preempt=full # 저지연 워크로드
/* kernel/sched/core.c — PREEMPT_DYNAMIC 구현 원리 */
/* static_call로 선점 포인트 동작을 런타임에 전환 */
DEFINE_STATIC_CALL(preempt_schedule, __preempt_schedule_func);
DEFINE_STATIC_CALL(cond_resched, __cond_resched_func);
/* preempt=none 모드: cond_resched()와 preempt_schedule() 모두 NOP */
static int __cond_resched_none(void) { return 0; }
static void __preempt_schedule_none(void) { }
/* preempt=voluntary 모드: cond_resched()만 활성화 */
static int __cond_resched_voluntary(void)
{
if (need_resched()) {
preempt_schedule_common();
return 1;
}
return 0;
}
/* preempt=full 모드: cond_resched() + preempt_enable() 경로 모두 활성화 */
/* → __preempt_schedule()이 실제 schedule() 호출 */
/* 모델 전환 시 static_call 업데이트 */
void sched_dynamic_update(int mode)
{
switch (mode) {
case preempt_dynamic_none:
static_call_update(cond_resched, __cond_resched_none);
static_call_update(preempt_schedule, __preempt_schedule_none);
break;
case preempt_dynamic_voluntary:
static_call_update(cond_resched, __cond_resched_voluntary);
static_call_update(preempt_schedule, __preempt_schedule_none);
break;
case preempt_dynamic_full:
static_call_update(cond_resched, __cond_resched_voluntary);
static_call_update(preempt_schedule, __preempt_schedule_func);
break;
}
}
CONFIG_PREEMPT_DYNAMIC=y로 빌드하면, 동일한 커널 바이너리로 서버(none)와 데스크톱(voluntary/full) 환경을 모두 최적화할 수 있습니다. static_call 메커니즘 덕분에 런타임 오버헤드는 사실상 0입니다 — 함수 포인터 대신 직접 호출(direct call) 명령어가 패치됩니다.
PREEMPT_LAZY (커널 6.12+)
커널 6.12에서 도입된 PREEMPT_LAZY는 Full Preemption과 Voluntary Preemption 사이의 새로운 중간 모델입니다. 핵심 아이디어는 선점 요청을 즉시 처리하지 않고, 다음 자연적 선점 포인트(타이머 tick, 시스템 콜 복귀 등)까지 지연하는 것입니다.
/* PREEMPT_LAZY 핵심 메커니즘 */
/* 기존 선점: TIF_NEED_RESCHED만 사용 */
/* PREEMPT_LAZY: 두 가지 플래그 사용 */
#define TIF_NEED_RESCHED 3 /* 즉시 선점 필요 */
#define TIF_NEED_RESCHED_LAZY 4 /* 지연 선점 (다음 자연 포인트에서 처리) */
/*
* PREEMPT_LAZY 동작 흐름:
*
* 1. scheduler_tick() → resched_curr() 호출 시:
* - 즉시 선점 대신 TIF_NEED_RESCHED_LAZY만 설정
* - preempt_enable() 경로에서는 이 플래그를 검사하지 않음
*
* 2. 자연적 선점 포인트(tick, syscall return)에서:
* - TIF_NEED_RESCHED_LAZY 검사 → TIF_NEED_RESCHED 설정 → schedule()
*
* 3. 긴급 선점이 필요한 경우 (RT 태스크 wakeup 등):
* - TIF_NEED_RESCHED를 직접 설정 → 즉시 선점
*/
/* 일반 wakeup: lazy 선점 */
static void resched_curr_lazy(struct rq *rq)
{
struct task_struct *curr = rq->curr;
if (test_tsk_need_resched(curr))
return; /* 이미 즉시 선점 예약됨 */
if (test_tsk_thread_flag(curr, TIF_NEED_RESCHED_LAZY))
return; /* 이미 lazy 선점 예약됨 */
set_tsk_thread_flag(curr, TIF_NEED_RESCHED_LAZY);
/* preempt_enable()에서는 검사 안 함 → 다음 tick에서 처리 */
}
/* 긴급 wakeup (RT 태스크 등): 즉시 선점 */
static void resched_curr(struct rq *rq)
{
set_tsk_need_resched(rq->curr); /* TIF_NEED_RESCHED 직접 설정 */
}
| 구분 | PREEMPT (Full) | PREEMPT_LAZY |
|---|---|---|
| 일반 선점 요청 | preempt_enable() 시 즉시 선점 |
다음 tick/syscall 복귀까지 지연 |
| RT 태스크 wakeup | 즉시 선점 | 즉시 선점 (동일) |
| 처리량 | 기준 | ~5% 향상 (벤치마크 의존) |
| 일반 지연시간 | 낮음 | 약간 높음 (최대 1 tick) |
| RT 지연시간 | 낮음 | 동일 (즉시 선점) |
| 장점 | 최소 지연시간 | 처리량 향상 + RT 지연시간 유지 |
PREEMPT_RT 상세
PREEMPT_RT 패치(v5.15부터 메인라인 통합 진행, 6.x에서 대부분 완료)는 Linux 커널을 하드 실시간 시스템으로 변환합니다. 최악 지연 시간(worst-case latency)을 100μs 이하로 보장하기 위해 커널의 동기화 메커니즘을 근본적으로 변경합니다.
/* PREEMPT_RT에서의 주요 변경점 */
/*
* 1. spinlock → rt_mutex로 대체
* - spin_lock()이 실제로는 슬립 가능한 rt_mutex 획득
* - 우선순위 상속(priority inheritance) 자동 지원
* - 선점 가능 → 장시간 lock 보유가 다른 태스크를 블록하지 않음
*/
typedef struct rt_mutex spinlock_t; /* CONFIG_PREEMPT_RT일 때 */
/*
* 2. softirq의 스레드화
* - softirq가 전용 커널 스레드(ksoftirqd)에서 실행
* - softirq 처리 중에도 선점 가능
*/
/*
* 3. 하드 인터럽트의 스레드화 (threaded IRQ)
* - 인터럽트 핸들러가 커널 스레드로 실행
* - 인터럽트 처리 중 선점 및 스케줄링 가능
* - 최악 지연 시간 100μs 이하 달성
*/
Priority Inheritance (우선순위 상속) 프로토콜
우선순위 역전(Priority Inversion) 문제는 높은 우선순위 태스크가 낮은 우선순위 태스크가 보유한 락을 기다리는 동안, 중간 우선순위 태스크가 낮은 우선순위 태스크를 선점하여 간접적으로 높은 우선순위 태스크를 지연시키는 현상입니다. PREEMPT_RT의 rt_mutex는 이를 자동으로 해결합니다.
/* rt_mutex의 Priority Inheritance 동작 */
/* 시나리오: 태스크 H(높은 prio), M(중간), L(낮은)
*
* 1. L이 rt_mutex 획득
* 2. H가 같은 rt_mutex를 기다림
* → L의 우선순위를 H 수준으로 일시적으로 상승 (boost)
* 3. M이 L을 선점하려 하지만, L의 부스트된 우선순위가 더 높으므로 실패
* 4. L이 rt_mutex 해제 → H가 즉시 실행
* → L의 우선순위 원복 (deboost)
*/
struct rt_mutex {
raw_spinlock_t wait_lock;
struct rb_root_cached waiters; /* 대기자 RB 트리 (우선순위 정렬) */
struct task_struct *owner; /* 현재 소유자 */
};
/* 우선순위 상속 체인: 중첩된 락에서도 전파 */
/* H → mutex_A(소유: M) → M → mutex_B(소유: L) → L
* → L은 H의 우선순위로 부스트됨 (체인 전파) */
raw_spinlock vs spinlock 사용 가이드라인
| 타입 | PREEMPT_RT 동작 | 사용처 |
|---|---|---|
spinlock_t |
rt_mutex (슬립 가능, 선점 가능) | 대부분의 커널 코드 |
raw_spinlock_t |
진정한 스핀락 (슬립 불가, 선점 불가) | 스케줄러, 인터럽트 코드, 타이머 코어 |
local_lock_t |
per-CPU 데이터 보호 (RT에서 슬립 가능) | per-CPU 데이터 접근 시 |
/* local_lock — per-CPU 데이터 보호 (PREEMPT_RT 호환) */
/* 비-RT 커널: local_lock은 preempt_disable/enable으로 최적화 */
/* RT 커널: per-CPU spinning lock으로 변환 (마이그레이션 방지) */
DEFINE_PER_CPU(struct local_lock, mydata_lock);
DEFINE_PER_CPU(struct mydata, mydata);
void update_mydata(void)
{
local_lock(&mydata_lock); /* 비-RT: preempt_disable(), RT: spin_lock */
this_cpu_inc(mydata.counter);
local_unlock(&mydata_lock); /* 비-RT: preempt_enable(), RT: spin_unlock */
}
raw_spinlock_t는 진정한 스핀락으로 남습니다. 인터럽트 컨텍스트나 스케줄러 자체에서 사용하는 락에 필요합니다. raw_spin_lock()으로 보호되는 크리티컬 섹션은 가능한 짧게 유지해야 합니다. 일반 드라이버에서는 raw_spinlock 대신 spinlock_t를 사용하세요.
인터럽트 컨텍스트와 선점
인터럽트 컨텍스트에서의 선점 동작은 선점 모델에 따라 크게 달라집니다. 하드 인터럽트 핸들러 실행 중에는 preempt_count의 hardirq 비트가 설정되어 있으므로 선점이 불가합니다.
/* 인터럽트 진입/탈출 시 preempt_count 조작 */
/* 하드 인터럽트 진입 */
void irq_enter(void)
{
preempt_count_add(HARDIRQ_OFFSET); /* hardirq 카운터 +1 */
/* → in_hardirq() == true, 선점 불가 */
}
/* 하드 인터럽트 탈출 */
void irq_exit(void)
{
preempt_count_sub(HARDIRQ_OFFSET); /* hardirq 카운터 -1 */
if (!in_interrupt() && local_softirq_pending())
invoke_softirq(); /* 보류된 softirq 처리 */
/* CONFIG_PREEMPT: hardirq count가 0이 되면 선점 검사 */
preempt_check_resched();
}
/* 인터럽트 복귀 시 선점 검사 (arch 코드) */
/*
* 비-RT 커널:
* 인터럽트 → 하드웨어 핸들러(hardirq context) → irq_exit
* → softirq 처리(softirq context) → 선점 검사
*
* PREEMPT_RT 커널:
* 인터럽트 → 최소한의 top-half만 → threaded IRQ 핸들러(프로세스 context)
* → softirq도 ksoftirqd 스레드에서 처리
* → 모두 스케줄링 가능한 프로세스 컨텍스트에서 실행
*/
| 컨텍스트 | 비-RT 커널 | PREEMPT_RT |
|---|---|---|
| 하드 IRQ | 선점 불가 (hardirq context) | top-half: 선점 불가 (최소 코드만) threaded handler: 선점 가능 |
| softirq | 선점 불가 (softirq context) | ksoftirqd 스레드에서 실행, 선점 가능 |
| tasklet | 선점 불가 (softirq에서 실행) | 선점 가능 (스레드화) |
| 타이머 콜백 | softirq context에서 실행 | 별도 스레드에서 실행, 선점 가능 |
성능 영향 분석
선점 모델 선택은 시스템의 지연시간(latency)과 처리량(throughput)에 직접적인 영향을 미칩니다. 워크로드 특성에 맞는 모델을 선택하는 것이 중요합니다.
| 선점 모델 | 스케줄링 지연 | 처리량 | 컨텍스트 스위칭 빈도 | 권장 워크로드 |
|---|---|---|---|---|
| PREEMPT_NONE | ~10ms (최악) | 최고 (기준) | 최소 | 서버, 데이터베이스, HPC, 배치 처리 |
| PREEMPT_VOLUNTARY | ~2ms (최악) | 높음 (~98%) | 낮음 | 데스크톱, 범용 서버, 개발 환경 |
| PREEMPT_LAZY | ~1ms (일반), ~100μs (RT) | 높음 (~97%) | 보통 | 혼합 워크로드 (서버 + 실시간) |
| PREEMPT | ~500μs (최악) | 보통 (~95%) | 높음 | 오디오/비디오 제작, 게이밍 |
| PREEMPT_RT | <100μs (보장) | 낮음 (~85-90%) | 최대 | 산업 제어, 로봇, 의료 장비, 금융 |
cyclictest --mlockall -t1 -p80 -i250 -l10000— 스케줄링 지연시간 측정 (RT 환경 표준)hackbench -s512 -l200 -g15 -f25— 스케줄러 처리량 벤치마크latencytop— 프로세스별 지연 원인 분석perf sched latency— 스케줄링 지연시간 히스토그램perf sched timehist— 시간별 스케줄링 이벤트 기록
선점 관련 디버깅
선점 동작 문제를 디버깅하기 위한 커널 내장 도구들입니다. 주로 ftrace 기반 트레이서를 사용하여 선점/인터럽트 비활성화 구간을 추적합니다.
# === preemptoff tracer: 선점 비활성화 구간 추적 ===
# 가장 긴 선점 비활성화 구간을 찾아 보고
echo preemptoff > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/tracing_on
# 워크로드 실행 후:
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace
# 출력 예시:
# # tracer: preemptoff
# # => started at: _raw_spin_lock_irqsave
# # => ended at: _raw_spin_unlock_irqrestore
# # latency: 152 us, #4/4, CPU#0
# === irqsoff tracer: 인터럽트 비활성화 구간 추적 ===
echo irqsoff > /sys/kernel/debug/tracing/current_tracer
# === preemptirqsoff: 선점 + 인터럽트 비활성화 모두 추적 ===
echo preemptirqsoff > /sys/kernel/debug/tracing/current_tracer
# === 최대 지연시간 리셋 ===
echo 0 > /sys/kernel/debug/tracing/tracing_max_latency
# === 프로세스별 자발적/비자발적 컨텍스트 스위칭 통계 ===
cat /proc/<PID>/sched | grep nr_switches
# nr_voluntary_switches: 1523 ← 자발적 (sleep, wait)
# nr_involuntary_switches: 47 ← 비자발적 (선점)
# 비율로 선점 빈도 파악:
# involuntary가 높으면 → 선점이 빈번 → PREEMPT 모델 또는 높은 경쟁
# voluntary가 높으면 → 정상적인 I/O 대기 패턴
# === perf를 이용한 컨텍스트 스위칭 분석 ===
perf stat -e context-switches,cpu-migrations -- <command>
# === ftrace로 선점 이벤트 추적 ===
echo sched_switch > /sys/kernel/debug/tracing/set_event
echo 1 > /sys/kernel/debug/tracing/tracing_on
# 워크로드 실행
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace | head -50
# 출력에서 prev_state 필드로 선점 여부 판별:
# prev_state=R → Running 상태에서 선점됨 (비자발적)
# prev_state=S → Sleep 상태로 전환 (자발적)
preemptoff, irqsoff, preemptirqsoff 트레이서는 CONFIG_PREEMPTIRQ_TRACEPOINTS=y 또는 CONFIG_IRQSOFF_TRACER=y가 활성화된 커널에서만 사용 가능합니다. 이 트레이서들은 약간의 오버헤드를 추가하므로, 프로덕션 환경에서는 측정 후 즉시 비활성화하세요.
스케줄러 디버깅 (Scheduler Debugging)
스케줄러의 동작을 분석하고 성능 문제를 진단하기 위한 다양한 도구와 인터페이스가 있습니다.
/proc/sched_debug
# 전체 스케줄러 상태 덤프
cat /proc/sched_debug
# 출력 내용:
# - 글로벌 스케줄러 파라미터 (sched_latency, min_granularity 등)
# - per-CPU 런큐 상태 (nr_running, load, curr 태스크 등)
# - 각 CPU의 CFS 런큐 트리 (vruntime, deadline, weight 등)
# - RT 런큐 상태
# 특정 프로세스의 스케줄링 정보
cat /proc/<pid>/sched
# 출력 예:
# se.vruntime : 12345.678901
# se.sum_exec_runtime : 987654.321098
# se.nr_migrations : 42
# nr_switches : 1234
# nr_voluntary_switches : 987
# nr_involuntary_switches : 247
# prio : 120
# policy : 0 (SCHED_NORMAL)
perf sched
# 스케줄링 이벤트 기록 (10초간)
sudo perf sched record -- sleep 10
# 스케줄링 지연 분석 (wakeup → actually running)
sudo perf sched latency
# 출력:
# Task | Runtime ms | Switches | Avg delay ms |
# ------------------------------------------------------------------
# kworker/0:1-mm_per | 0.293 ms | 12 | avg: 0.012 ms |
# bash:1234 | 125.432 ms | 89 | avg: 0.845 ms |
# CPU별 타임라인 (ASCII art)
sudo perf sched map
# 출력:
# *A0 0.000 ms | 0: migration/0
# A0 *B0 0.105 ms | 1: ksoftirqd/1
# A0 B0 *C0 0.230 ms | 2: bash:1234
# 스케줄링 통계 요약
sudo perf sched timehist
# 각 컨텍스트 스위치의 상세 타임스탬프, wait/run 시간 출력
# 스케줄링 이벤트 기반 통계
sudo perf stat -e 'sched:sched_switch,sched:sched_wakeup' -- sleep 5
ftrace 스케줄러 트레이싱
# ftrace로 스케줄러 이벤트 트레이싱
cd /sys/kernel/debug/tracing
# 사용 가능한 스케줄러 이벤트 확인
ls events/sched/
# sched_switch sched_wakeup sched_wakeup_new sched_migrate_task
# sched_process_fork sched_process_exec sched_process_exit
# sched_stat_wait sched_stat_sleep sched_stat_runtime
# 스케줄러 스위치 이벤트 활성화
echo 1 > events/sched/sched_switch/enable
echo 1 > events/sched/sched_wakeup/enable
# 트레이싱 시작
echo 1 > tracing_on
sleep 2
echo 0 > tracing_on
# 결과 확인
cat trace
# 출력 예:
# bash-1234 [002] 1234.567890: sched_switch: prev_comm=bash prev_pid=1234
# prev_prio=120 prev_state=S ==> next_comm=kworker/2:1 next_pid=567
# next_prio=120
#
# <idle>-0 [001] 1234.567895: sched_wakeup: comm=bash pid=1234
# prio=120 target_cpu=002
# 특정 프로세스의 wakeup 지연 측정 (wakeup latency tracer)
echo wakeup > current_tracer
echo 1 > tracing_on
# ... 워크로드 실행 ...
echo 0 > tracing_on
cat trace
# 최대 wakeup 지연 시간 표시
스케줄러 디버그 기능 (sched_features)
# 현재 활성화된 스케줄러 기능 확인
cat /sys/kernel/debug/sched/features
# 출력 예:
# GENTLE_FAIR_SLEEPERS (슬리퍼에 대한 보상 제한)
# START_DEBIT (새 태스크에 vruntime 불이익)
# NEXT_BUDDY (wakeup 시 next 힌트)
# LAST_BUDDY (yield 시 last 힌트)
# CACHE_HOT_BUDDY (캐시 친화적 wakeup)
# WAKEUP_PREEMPTION (wakeup 시 선점 허용)
# PLACE_LAG (EEVDF: lag 기반 배치)
# PLACE_DEADLINE_INITIAL (EEVDF: 초기 deadline 설정)
# 기능 토글 (런타임)
echo WAKEUP_PREEMPTION > /sys/kernel/debug/sched/features # 활성화
echo NO_WAKEUP_PREEMPTION > /sys/kernel/debug/sched/features # 비활성화
schedstat 통계
# schedstat 활성화 (CONFIG_SCHEDSTATS 필요)
echo 1 > /proc/sys/kernel/sched_schedstats
# CPU별 스케줄링 통계
cat /proc/schedstat
# cpu<N> <domain> <9개 필드>
# 필드: yld_count, sched_switch, sched_goidle, ttwu_count,
# ttwu_local, rq_cpu_time, run_delay, pcount
# 프로세스별 스케줄링 통계
cat /proc/<pid>/schedstat
# 3개 필드: run_time(ns) wait_time(ns) nr_timeslices
# 모든 CPU의 로드 밸런싱 통계 확인
cat /proc/schedstat | grep domain
디버깅 도구 요약
| 도구 | 용도 | 오버헤드 |
|---|---|---|
/proc/sched_debug |
스케줄러 전체 상태 스냅샷 | 낮음 (읽기만) |
/proc/<pid>/sched |
개별 프로세스 스케줄링 정보 | 낮음 |
perf sched |
스케줄링 지연/타임라인 분석 | 중간 |
ftrace sched events |
상세 스케줄링 이벤트 트레이싱 | 중간~높음 |
schedstat |
스케줄링 통계 집계 | 낮음 |
sched_features |
스케줄러 동작 런타임 튜닝 | 없음 |
trace-cmd |
ftrace 프론트엔드 (기록/분석) | 중간 |
kernelshark |
trace-cmd 데이터 GUI 시각화 | 없음 (오프라인) |
주요 커널 설정 옵션
| 설정 | 설명 | 기본값 |
|---|---|---|
CONFIG_PREEMPT_NONE |
서버용 비선점 커널 | - |
CONFIG_PREEMPT_VOLUNTARY |
데스크톱 자발적 선점 | 대부분의 배포판 기본 |
CONFIG_PREEMPT |
저지연 선점형 커널 | - |
CONFIG_PREEMPT_RT |
하드 실시간 선점 | - |
CONFIG_SCHED_EXT |
BPF sched_ext 지원 | n (v6.12+) |
CONFIG_SCHEDSTATS |
스케줄링 통계 수집 | n |
CONFIG_SCHED_DEBUG |
스케줄러 디버그 인터페이스 | y |
CONFIG_NO_HZ_FULL |
단일 태스크 CPU에서 틱 제거 | n |
CONFIG_HZ |
타이머 틱 빈도 (100/250/300/1000) | 250 |
CONFIG_NUMA_BALANCING |
NUMA 자동 메모리/태스크 밸런싱 | y (NUMA 시스템) |
CONFIG_CGROUP_SCHED |
cgroup 기반 스케줄링 지원 | y |
CONFIG_FAIR_GROUP_SCHED |
CFS/EEVDF 그룹 스케줄링 | y |
CONFIG_RT_GROUP_SCHED |
RT 그룹 스케줄링 | n |
sysctl 튜닝 파라미터
# === CFS/EEVDF 파라미터 ===
# 타겟 레이턴시 (ns): 태스크들이 한 번씩 실행되는 목표 주기
sysctl kernel.sched_latency_ns=6000000 # 6ms (기본)
# 최소 그래뉼래리티 (ns): 태스크당 최소 실행 시간
sysctl kernel.sched_min_granularity_ns=750000 # 0.75ms (기본)
# EEVDF 기본 슬라이스 (ns, v6.6+)
sysctl kernel.sched_base_slice_ns=3000000 # 3ms (기본)
# 웨이크업 그래뉼래리티: 깨어난 태스크의 선점 임계값
sysctl kernel.sched_wakeup_granularity_ns=1000000 # 1ms (기본)
# === RT 파라미터 ===
# RT 대역폭 제한 (us)
sysctl kernel.sched_rt_period_us=1000000 # 1초 주기
sysctl kernel.sched_rt_runtime_us=950000 # 주기당 최대 950ms
# === 마이그레이션 비용 ===
sysctl kernel.sched_migration_cost_ns=500000 # 0.5ms (기본)
# 이 시간 이내에 실행된 태스크는 "캐시 핫"으로 간주, 마이그레이션 억제
# === CPU Affinity (명령줄) ===
taskset -c 0,1 ./my_app # CPU 0,1에만 바인딩
taskset -p -c 2-5 1234 # PID 1234를 CPU 2-5로 변경
# === isolcpus (커널 부트 파라미터) ===
# 특정 CPU를 스케줄러에서 격리 (RT 워크로드 전용)
# GRUB: isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3
sched_latency_ns 줄이기, CONFIG_PREEMPT 사용. (2) 서버/배치 워크로드: sched_min_granularity_ns 늘리기, CONFIG_PREEMPT_NONE 사용. (3) RT 워크로드: isolcpus + SCHED_FIFO + CONFIG_PREEMPT_RT. (4) 컨테이너 환경: cgroup cpu.max로 대역폭 제한, CONFIG_FAIR_GROUP_SCHED 필수.