CFS 스케줄러(Scheduler) 상세

Linux 커널의 CFS(Completely Fair Scheduler)를 vruntime 수식과 실행 큐(Runqueue) 동작 기준으로 심층 분석합니다. nice 가중치 기반 시간 분배, Red-Black Tree 스케줄링, wakeup preemption, PELT 부하 추적, cgroup 대역폭(Bandwidth) 제한, EEVDF와의 연계 변화, 성능 관측 지표와 튜닝 전략까지 실무 관점에서 다룹니다.

전제 조건: 커널 아키텍처 문서를 먼저 읽으세요. CPU, 메모리, 인터럽트(Interrupt)의 기본 흐름을 알고 있으면 본 문서를 더 빠르게 이해할 수 있습니다.
일상 비유: CFS는 대기 시간이 기록되는 공정한 번호표 창구와 비슷합니다. 오래 기다린 손님에게 먼저 기회를 주듯이, 태스크의 가상 실행 시간(vruntime)을 비교해 CPU 시간을 균형 있게 배분합니다.

핵심 요약

  • sched_entity — 태스크 하나를 CFS가 관리하는 기본 단위입니다. vruntime·deadline·load.weight·run_node(RB-tree 노드)를 포함합니다.
  • cfs_rq — CPU별 CFS 런큐(Runqueue)입니다. Red-Black Tree(tasks_timeline)와 가중 평균 vruntime(sum_w_vruntime/sum_weight)을 관리합니다.
  • vruntime — 실제 실행 시간을 nice 가중치(weight)로 정규화한 "공정 시계"입니다. Linux 6.6 이후에는 vruntime에 요청 크기를 더한 가상 마감(deadline)과 적격성(Eligibility)을 함께 사용해 다음 태스크를 고릅니다.
  • deadline — vruntime과 이번 요청 크기로 계산한 가상 마감 시각입니다. EEVDF는 적격한 태스크 중 deadline이 가장 이른 태스크를 선택합니다.
  • 운영 포인트 — /proc/schedstat, perf sched, bpftrace로 스케줄링 지연(Latency)·런큐 길이를 관측합니다.

단계별 이해

  1. 태스크 생성 → 인큐(Enqueue)
    fork() 후 enqueue_task_fair()가 호출되어 sched_entity의 vruntime/deadline을 런큐 기준으로 초기화한 뒤 Red-Black Tree에 삽입합니다.
  2. 타이머(Timer) 틱 → 선점(Preemption) 검사
    매 HZ마다 scheduler_tick()→task_tick_fair()가 현재 태스크의 vruntime을 갱신하고, 타임슬라이스(Time Slice)를 소진했으면 TIF_NEED_RESCHED 플래그를 설정합니다.
  3. 다음 태스크 선택
    pick_next_task_fair()→pick_next_entity()가 pick_eevdf()를 통해 적격 엔티티 중 deadline이 가장 이른 엔티티를 선택합니다.
  4. 컨텍스트 스위치(Context Switch)
    context_switch()가 CPU 레지스터와 페이지 테이블(Page Table)을 교체하여 실제 CPU 실행 흐름을 전환합니다.

개요

CFS(Completely Fair Scheduler)는 2007년 Linux 2.6.23에서 기존 O(1) 스케줄러를 대체하며 도입된 프로세스(Process) 스케줄러입니다.

스케줄링이 필요한 이유

현대 OS는 수십~수백 개의 프로세스가 동시에 실행 대기 중인 환경에서 물리 CPU 코어는 소수에 불과합니다. 스케줄러(Scheduler)가 없다면 한 프로세스가 CPU를 독점하여 다른 프로세스들이 영원히 실행되지 못하는 기아(Starvation) 현상이 발생합니다. 과거 O(1) 스케줄러는 "대화형 태스크인지 판단하는 휴리스틱(Heuristic)"이 복잡하고 오류가 많아 공정성을 보장하지 못했습니다. CFS는 이를 해결하기 위해 모든 프로세스에게 이상적인 멀티태스킹(Multitasking)을 수학적으로 보장하는 구조를 도입하였습니다.

프로세스 상태와 스케줄러 역할

스케줄러는 아래 세 가지 상태 사이에서 태스크를 전환합니다. CFS가 실질적으로 관리하는 대상은 Runnable(실행 대기) 상태의 태스크들입니다.

Running CPU에서 실제 실행 중 Runnable 실행 대기, 런큐에 등록됨 RB-tree(tasks_timeline)에 정렬 CFS 관리 영역 Sleeping 이벤트 대기, 슬립 상태 트리에 없으므로 vruntime 정지 pick_next_task() 선점 요청 / 타임슬라이스 소진 I/O 대기, sleep() wakeup / I/O 완료 → enqueue_task_fair()
그림: 프로세스 상태 전이. CFS가 실제로 관리하는 것은 Runnable 상태이며, 런큐의 RB-tree(tasks_timeline)에 정렬해 두었다가 스케줄러가 하나를 골라 Running으로 옮깁니다. Sleeping 태스크는 트리에서 빠져 있으므로 실행 시간 회계가 멈춥니다.

CPU 런큐(Runqueue) 기본 개념

각 CPU 코어는 자신만의 런큐(Runqueue, struct rq)를 가집니다. 런큐는 현재 그 CPU에서 실행되기를 기다리는 태스크들의 집합입니다. CFS는 이 런큐 안에서 cfs_rq라는 별도 자료구조로 자신이 관리하는 태스크들을 Red-Black Tree에 정렬하여 유지합니다.

개념커널 구조체역할
CPU 런큐struct rqCPU별 전체 스케줄러 상태 (CFS, RT, Deadline 포함)
CFS 런큐struct cfs_rqCFS가 관리하는 Runnable 태스크들의 RB-tree
스케줄 엔티티struct sched_entity태스크 1개(또는 그룹)의 vruntime·가중치·RB-tree 노드

핵심 원칙

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에 처음 도입한 이후 꾸준히 발전해 왔습니다. 아래 타임라인은 주요 이정표를 정리한 것입니다.

2.6.23 · 2007 CFS 도입 (Ingo Molnár) 3.8 · 2013 PELT 도입 3.10 · 2013 NO_HZ_FULL 지원 3.16 · 2014 capacity-aware 4.7 · 2016 schedutil 거버너 5.0 · 2019 EAS 도입 5.14 · 2021 대역폭 burst 제어 6.6 · 2023 EEVDF 전환
그림: CFS 관련 주요 도입 이정표(2007–2023). 축 위·아래 레이블은 커널 버전과 병합 연도를 함께 표기하며, 마지막 6.6에서 선택 알고리즘이 EEVDF로 바뀝니다.
참고: 커널 6.6부터 CFS 내부 스케줄링 알고리즘은 EEVDF(Earliest Eligible Virtual Deadline First)로 대체되었습니다. 그러나 cfs_rq, sched_entity 등 자료구조와 로드 밸런싱 프레임워크는 그대로 유지되므로 CFS에 대한 이해는 여전히 필수적입니다.

가상 런타임 (vruntime)

vruntime은 CFS의 핵심 메트릭으로, 실제 실행 시간을 nice 값(우선순위)으로 가중한 값입니다.

vruntime이 없다면 어떤 문제가 생기는가

단순히 "실제 실행 시간이 가장 짧은 태스크에게 CPU를 준다"면 어떻게 될까요? nice -20(고우선순위) 태스크와 nice +19(저우선순위) 태스크가 경쟁할 때, 두 태스크에게 같은 실제 시간을 주면 고우선순위 설정 자체가 무의미해집니다. 반대로 "우선순위에 따라 고정 시간 비율"을 주면 nice +19 태스크는 수십 배의 대기 후에야 CPU를 받게 되어 응답성이 극도로 저하됩니다.

vruntime은 이 문제를 "모든 태스크의 가상 시계를 같은 속도로 맞추는" 방식으로 해결합니다. nice 가중치가 높을수록 vruntime이 천천히 증가하므로, vruntime 최솟값을 고르면 자동으로 고우선순위 태스크가 더 자주 선택됩니다.

수치 예시 — nice 0 vs nice +10 태스크가 100ms 경쟁하면:
태스크nice 값weight실제 실행 시간vruntime 증가량선택 비율
태스크 A01024약 90ms90ms × (1024/1024) = 90ms약 90%
태스크 B+10110약 10ms10ms × (1024/110) ≈ 93ms약 10%

100ms 경과 후 두 태스크의 vruntime은 거의 같은 수준이 됩니다. 즉 CFS는 vruntime을 통해 "우선순위에 비례한 CPU 시간 배분"을 공정하게 달성합니다. vruntime이 낮은 쪽을 항상 먼저 선택하므로, 두 태스크가 번갈아 실행되는 주기에서 A가 약 9배 더 많이 실행됩니다.

vruntime 계산

vruntime 갱신의 핵심 식은 버전과 무관하게 동일합니다: vruntime += calc_delta_fair(delta_exec, se). 아래 첫 번째 블록은 CFS(Linux 6.5 이하) 형태이고, 6.6 이후의 실제 구현은 update_curr() (v7.3.1)에서 다룹니다.

/* kernel/sched/fair.c — Linux 6.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->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에 정의되어 있습니다. 타이머(Timer) 틱, 태스크 인큐(Enqueue)/디큐(Dequeue), 선점(Preemption) 검사 등 다양한 경로에서 호출됩니다.
  • 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(): 런큐(Runqueue)의 min_vruntime을 단조 증가 방식으로 갱신합니다. 새 태스크 인큐 시 vruntime 초기화의 기준점으로 사용됩니다.
  • 15행calc_delta_fair()에서 se->load.weight == NICE_0_LOAD이면 가중 계산을 생략하여 성능을 최적화합니다. 대부분의 태스크가 nice 0이므로 이 최적화의 효과가 큽니다.
  • 17행__calc_delta()는 64비트 나눗셈을 inv_weight를 이용한 곱셈+시프트 연산으로 대체하여 정수 나눗셈의 높은 비용을 회피합니다.

Nice 값과 Weight 테이블

NiceWeightvruntime 증가율설명
-20887610.012x최고 우선순위 (약 1/87 속도로 증가)
-1095480.11x높은 우선순위
010241.0x기본 우선순위
+101109.3x낮은 우선순위
+191568x최저 우선순위 (68배 빠르게 증가)
nice 1024 / weight -20 88761 0.012x -10 9548 0.11x 0 1024 1.0x +10 110 9.3x +19 15 68x 1 10 100 1K 10K 100K weight (로그 눈금) vruntime 증가 배율
그림: nice 값과 weight, 그리고 vruntime 증가 배율의 관계(막대는 로그 눈금). nice가 1 낮아질 때마다 weight가 약 1.25배씩 커지므로 선형 눈금에서는 차이가 보이지 않습니다. 오른쪽 열은 1024/weight로 계산한 상대 배율입니다.

vruntime Overflow 처리

vruntime/deadline은 u64이지만, 두 값을 직접 대소 비교하면 랩어라운드(Wraparound) 순간에 잘못된 판정이 나옵니다. 커널은 부호 있는 차이(signed difference)로 이 문제를 해결합니다. v7.3.1에서는 비교 연산자가 매크로(Macro)로 일반화되어 있습니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
#define vruntime_cmp(A, CMP_STR, B) ({             \
    int __res = 0;                              \
                                                    \
    if (!__builtin_strcmp(CMP_STR, "<")) {        \
        __res = ((s64)((A)-(B)) < 0);          \
    } else if (!__builtin_strcmp(CMP_STR, "<=")) {   \
        __res = ((s64)((A)-(B)) <= 0);         \
    } else if (!__builtin_strcmp(CMP_STR, ">")) {    \
        __res = ((s64)((A)-(B)) > 0);          \
    } else if (!__builtin_strcmp(CMP_STR, ">=")) {   \
        __res = ((s64)((A)-(B)) >= 0);         \
    } else {                                     \
        /* 알 수 없는 연산자는 링커 에러 */          \
        __BUILD_BUG_vruntime_cmp();              \
    }                                               \
                                                    \
    __res;                                          \
})

static inline bool entity_before(const struct sched_entity *a,
                                 const struct sched_entity *b)
{
    return vruntime_cmp(a->deadline, "<", b->deadline);
}

두 값의 차이가 s64 범위(약 ±292년) 안에 있으면 부호만으로 순서를 판정할 수 있습니다. 커널이 292년 이상 재부팅 없이 돌지 않는다는 전제에 기반한 기법입니다. 주의할 점은 비교 대상이 vruntime이 아니라 deadline이라는 것입니다(6.6 이후).

Nice 값 설정 주의사항

  • Nice 값은 상대적: nice -5는 "빠름"이 아니라 "nice 0보다 더 많은 CPU"를 의미합니다. 시스템에 프로세스가 1개뿐이면 nice 값은 무의미합니다.
  • 루트 권한 필요: 음수 nice (높은 우선순위)는 CAP_SYS_NICE capability가 필요합니다. 일반 사용자는 자신의 프로세스 nice 값을 증가(우선순위 낮춤)만 가능합니다.
  • 실시간(Real-time) 아님: nice -20도 SCHED_FIFO RT 태스크보다 낮은 우선순위입니다. 엄격한 지연시간 보장이 필요하면 실시간 스케줄링 정책을 사용하세요.
  • I/O 대기는 무관: Nice 값은 CPU 경쟁 시에만 영향을 줍니다. 디스크 I/O나 네트워크 대기 중인 시간은 vruntime에 포함되지 않습니다.

Red-Black Tree 구조

CFS는 각 CPU의 실행 큐(runqueue)에서 가상 시각을 키로 하는 Red-Black Tree를 관리합니다. 정렬 키는 CFS(6.5 이하)에서 vruntime, EEVDF(6.6 이상)에서 deadline입니다. 아래 설명은 이해를 돕기 위해 CFS 기준으로 서술하고, EEVDF와 다른 지점은 따로 표시합니다.

왜 Red-Black Tree인가

CFS가 매 스케줄링 사이클마다 수행하는 핵심 연산은 세 가지입니다: ① 다음 실행 태스크 선택(최솟값 조회), ② 태스크 인큐(삽입), ③ 태스크 디큐(삭제). 이 세 연산을 동시에 효율적으로 처리하려면 자료구조 선택이 중요합니다.

자료구조최솟값 조회삽입삭제CFS 적합성
정렬 배열(Sorted Array)O(1)O(N)O(N)삽입·삭제 비용이 N에 비례 — 태스크 수 증가 시 급격히 저하
힙(Heap)O(1)O(log N)O(log N)임의 노드 삭제가 O(log N)이지만, 정렬된 순서 순회가 O(N log N)으로 비효율
Red-Black TreeO(log N)
+ leftmost 캐시로 O(1)
O(log N)O(log N)세 연산 모두 O(log N) 보장 + leftmost 포인터 캐시로 최솟값은 O(1) 달성
leftmost 캐시의 핵심 역할: Linux 커널의 rb_root_cached 구조체는 RB-tree 루트 외에 rb_leftmost 포인터를 별도로 관리합니다. 즉 pick_next_entity()에서 "가장 작은 vruntime 태스크"를 선택할 때 트리 순회 없이 O(1)로 접근합니다. 삽입·삭제 시 leftmost가 변경되면 포인터만 갱신합니다.

Red-Black Tree는 자가 균형(Self-balancing) 이진 탐색 트리(BST)로, 어떤 삽입·삭제 순서에도 트리 높이가 O(log N)을 넘지 않도록 회전(Rotation)과 색 변경으로 균형을 유지합니다. 트리 높이가 일정하므로 태스크 수가 수백 개로 늘어도 스케줄링 오버헤드(Overhead)가 예측 가능합니다.

sched_entity 구조체(Struct)

아래는 Linux v7.3.1의 실제 정의입니다. struct sched_entity는 CFS 시절과 마찬가지로 include/linux/sched.h에 그대로 남아 있고(태스크 구조체가 직접 포함), EEVDF 도입으로 deadline/vlag/vprot/slice 계열 필드가 추가되었습니다. 스케줄러 내부 전용 구조체인 struct cfs_rq는 kernel/sched/sched.h에 있습니다.

/* include/linux/sched.h — Linux v7.3.1 */
struct sched_entity {
    /* 부하 분산용 */
    struct load_weight  load;        /* nice 기반 가중치 */
    struct load_weight  h_load;      /* 계층 합산 가중치 */
    struct rb_node      run_node;    /* RB-tree 노드 (키: deadline) */

    u64 deadline;                     /* 가상 마감 시각 (EEVDF) */
    u64 min_vruntime;                  /* 서브트리 최소 vruntime (증강 필드) */
    u64 min_slice;                     /* 서브트리 최소 slice (증강 필드) */
    u64 max_slice;

    struct list_head    group_node;
    unsigned char       on_rq;
    unsigned char       sched_delayed;   /* 지연 디큐 상태 */
    unsigned char       rel_deadline;
    unsigned char       custom_slice;    /* sched_runtime 지정 여부 */

    u64 exec_start;                    /* 마지막 회계 시작 시각 */
    u64 sum_exec_runtime;              /* 누적 실행 시간 */
    u64 prev_sum_exec_runtime;
    u64 vruntime;                      /* 가상 런타임 */

    s64 vlag;                          /* 근사 가상 지연 (EEVDF 적격성) */
    u64 vprot;                         /* 슬라이스 보호 상한 */
    u64 slice;                         /* 이번 요청 크기 */

    u64 nr_migrations;

#ifdef CONFIG_FAIR_GROUP_SCHED
    int depth;
    struct sched_entity *parent;      /* 상위 그룹 */
    struct cfs_rq       *cfs_rq;      /* 소속 런큐 */
    struct cfs_rq       *my_q;        /* 그룹이 소유한 자식 런큐 */
    unsigned long        runnable_weight;
#endif

    struct sched_avg     avg;          /* PELT 부하 추적 (별도 캐시라인) */
};
코드 설명
  • 4~5행load는 nice 값에서 변환된 weight와 나눗셈 회피용 inv_weight를 담습니다. h_load는 cgroup 계층을 따라 합산한 가중치로, 그룹 스케줄링에서 부하 계산에 사용합니다.
  • 6행run_node는 런큐의 RB-tree(tasks_timeline)에 삽입되는 노드입니다. CFS에서는 vruntime이 키였지만 EEVDF는 deadline을 키로 사용합니다.
  • 8행deadline은 EEVDF의 핵심 값으로, vd_i = ve_i + r_i / w_i입니다. update_deadline()이 슬라이스를 소진할 때마다 갱신합니다.
  • 9~11행min_vruntime/min_slice/max_slice는 RB-tree의 증강 필드(augmented field)입니다. 각 서브트리의 최솟값을 유지해 pick_eevdf()가 "적격자가 있을 수 없는 서브트리"를 O(1)에 배제할 수 있게 합니다.
  • 14~17행on_rq는 큐 등록 여부, sched_delayed는 슬립 시 즉시 디큐하지 않고 마감까지 남겨 두는 지연 디큐 상태입니다. custom_slice는 sched_setattr()의 sched_runtime으로 슬라이스를 직접 지정했는지 표시합니다.
  • 26행vlag는 근사 가상 지연으로, "받아야 할 서비스 대비 얼마나 뒤처졌는가"를 나타냅니다. vlag >= 0이면 적격(eligible)입니다.
  • 27~28행vprot는 슬라이스 보호 상한(vruntime < vprot인 동안 계속 실행), slice는 이번 요청 크기 r_i입니다. 두 값이 EEVDF의 "최소 실행 보장"을 담당합니다.
  • 33~38행parent/cfs_rq/my_q/runnable_weight는 CONFIG_FAIR_GROUP_SCHED가 켜졌을 때만 존재합니다. CFS 문서에서 이 필드들을 무조건 나열하는 경우가 많은데, 실제로는 조건부 컴파일 대상입니다.
  • 41행avg는 PELT(struct sched_avg) 상태로, 읽기 위주 필드와 캐시라인을 공유하지 않도록 별도 구획에 배치되어 있습니다.
CFS(6.5 이하) 정의와의 차이:
  • 헤더 위치는 include/linux/sched.h 그대로입니다(struct cfs_rq만 kernel/sched/sched.h로 분리).
  • deadline, min_vruntime, min_slice, max_slice, vlag, vprot, slice, sched_delayed, custom_slice, h_load가 새로 들어왔습니다.
  • CFS에 있던 prev_sum_exec_runtime은 그대로 남아 있지만, runnable_weight는 조건부 필드가 되었고 statistics(CONFIG_SCHEDSTATS) 블록은 이 구조체에서 빠졌습니다.

트리 구조 시각화

정렬 키 = vruntime (6.5 이하) / deadline (6.6 이상) 150 80 200 50 120 180 250 leftmost = 다음 실행 태스크 삽입·삭제는 O(log N), 최솟값 조회는 rb_leftmost 캐시로 O(1)
그림: Red-Black Tree 구조(개념도). 노드 값은 정렬 키이며, 왼쪽 서브트리 값이 항상 작습니다. 커널은 rb_root_cached에 rb_leftmost를 함께 두어 최솟값 노드를 순회 없이 찾고, 그 노드가 다음 실행 태스크가 됩니다.

다음 태스크 선택 (pick_next_task)

스케줄러의 핵심 동작은 다음에 실행할 태스크를 고르는 것입니다. CFS(6.5 이하)는 vruntime이 가장 작은 태스크를, EEVDF(6.6 이상)는 적격한 태스크 중 가상 마감(deadline)이 가장 이른 태스크를 선택합니다.

pick_next_entity와 pick_eevdf (Linux 6.6+)

Linux 6.5 이하 CFS에서 pick_next_entity(cfs_rq, curr)는 RB-tree의 leftmost 엔티티를 기본 후보로 삼은 뒤 cfs_rq->skip, next, last 세 가지 buddy 힌트를 차례로 검사하여 최종 후보를 보정했습니다. Linux 6.6에서 EEVDF가 병합되면서 buddy 힌트는 제거되었고, 현재 안정 커널(v7.3.1)에서는 pick_eevdf()가 적격성(Eligibility)과 가상 마감 시각(Virtual Deadline) 두 기준으로 후보를 고릅니다. 아래 코드는 v7.3.1 소스 그대로입니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static struct sched_entity *
pick_next_entity(struct rq *rq, bool protect)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;

    se = pick_eevdf(cfs_rq, protect);
    if (se->sched_delayed) {
        __dequeue_task(rq, task_of(se), DEQUEUE_SLEEP | DEQUEUE_DELAYED);
        /* Must not reference @se again, see __block_task(). */
        return NULL;
    }

    return se;
}
코드 설명
  • 3행CFS 시절의 pick_next_entity(cfs_rq, curr)와 달리 struct rq를 받고 protect 플래그를 함께 넘깁니다. protect는 "현재 실행 엔티티의 슬라이스 보호(vprot)를 존중할 것인가"를 뜻하며, wakeup 계열 선점 경로에서는 false, 그 외 경로에서는 true가 전달됩니다.
  • 8행실제 후보 선택은 전부 pick_eevdf()로 위임합니다. 이 함수 하나가 next buddy, 현재 엔티티 보호, 적격성 검사, 트리 탐색을 모두 담당하므로 pick_next_entity()는 얇은 래퍼가 되었습니다.
  • 9~12행sched_delayed는 EEVDF의 지연 배치(Delayed Dequeue) 표시입니다. 슬립에 들어가는 엔티티를 즉시 트리에서 빼지 않고 마감까지 남겨 두는 최적화인데, 그 상태로 선택되면 실제 태스크를 디큐한 뒤 NULL을 반환합니다. 주석이 경고하듯 __dequeue_task() 이후에는 se를 다시 참조하면 안 됩니다.
  • 15행정상 후보를 반환하면 호출자인 pick_next_task_fair()가 set_next_entity()로 런큐의 curr/next를 갱신합니다.

pick_eevdf() — 적격성 검사와 트리 탐색

RB-tree는 deadline을 키로 정렬되지만, EEVDF가 골라야 하는 것은 "마감이 가장 이른 태스크"가 아니라 "이미 받아야 할 서비스를 받지 못한(적격한) 태스크 중 마감이 가장 이른 태스크"입니다. 그래서 각 노드는 서브트리의 최소 min_vruntime과 최소 min_slice를 증강 필드로 유지하고, 적격 엔티티가 존재할 수 없는 서브트리는 통째로 건너뜁니다. 이 구조 덕분에 탐색은 여전히 O(log N)입니다.

static struct sched_entity *pick_eevdf(struct cfs_rq *cfs_rq, bool protect)
{
    struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
    struct sched_entity *se = __pick_first_entity(cfs_rq);
    struct sched_entity *curr = cfs_rq->curr;
    struct sched_entity *best = NULL;

    /* 엔티티가 하나뿐이면 적격성 검사를 생략 */
    if (cfs_rq->h_nr_queued == 1)
        return curr && curr->on_rq ? curr : se;

    /* ->next buddy 는 지연시간에만 영향을 주고 공정성은 해치지 않음 */
    if (sched_feat(PICK_BUDDY) && protect &&
        cfs_rq->next && entity_eligible(cfs_rq, cfs_rq->next)) {
        WARN_ON_ONCE(cfs_rq->next->sched_delayed);
        return cfs_rq->next;
    }

    if (curr && (!curr->on_rq || !entity_eligible(cfs_rq, curr)))
        curr = NULL;

    if (curr && protect && protect_slice(curr))
        return curr;

    /* leftmost 엔티티가 적격이면 즉시 확정 */
    if (se && entity_eligible(cfs_rq, se)) {
        best = se;
        goto found;
    }

    /* 적격 엔티티를 찾는 트리 탐색 */
    while (node) {
        struct rb_node *left = node->rb_left;

        /* 좌측 서브트리에 적격 엔티티가 있으면 항상 더 좋은 후보 */
        if (left && vruntime_eligible(cfs_rq,
                    __node_2_se(left)->min_vruntime)) {
            node = left;
            continue;
        }

        se = __node_2_se(node);

        /* 좌측에 적격자가 없으므로 현재 노드를 검사 */
        if (entity_eligible(cfs_rq, se)) {
            best = se;
            break;
        }

        node = node->rb_right;
    }
found:
    if (!best || (curr && entity_before(curr, best)))
        best = curr;

    return best;
}
코드 설명
  • 2~5행tasks_timeline.rb_root.rb_node로 루트부터 내려가고, __pick_first_entity()로 leftmost(최소 deadline) 후보를 미리 확보합니다. cfs_rq->curr는 현재 실행 중인 엔티티로, 아직 런큐에 남아 있으므로 후보에 포함됩니다.
  • 8~9행h_nr_queued는 cgroup 계층을 합산한 큐 길이입니다. 엔티티가 하나뿐이면 비교 대상이 없으므로 적격성 검사를 생략하는 빠른 경로입니다. 단일 태스크 워크로드에서 스케줄러 오버헤드를 줄이는 최적화입니다.
  • 12~16행PICK_BUDDY가 켜져 있고 protect가 참이면 cfs_rq->next buddy를 우선 반환합니다. buddy는 캐시 지역성(Cache Locality)을 위한 힌트로, 공정성 판단 자체는 entity_eligible()이 이미 통과시킨 상태에서만 적용됩니다.
  • 18~22행현재 엔티티가 런큐에 없거나 적격하지 않으면 후보에서 제외합니다. 적격하더라도 protect_slice()가 참이면(즉 vruntime < vprot) 슬라이스 보호가 남아 있으므로 계속 실행시킵니다.
  • 24~28행leftmost 엔티티가 적격이면 곧바로 확정합니다. deadline이 가장 이르면서 서비스를 받을 자격도 있으므로 비교할 필요가 없습니다.
  • 31~47행그 외에는 트리를 내려가며 탐색합니다. 좌측 서브트리의 min_vruntime만 보고 vruntime_eligible()로 "이 서브트리에 적격자가 존재할 수 있는가"를 O(1)에 판정하므로, 후보가 없는 큰 서브트리를 통째로 배제할 수 있습니다.
  • 48~52행마지막으로 현재 엔티티가 best보다 마감이 이르면 현재 엔티티를 유지합니다(entity_before()). 이 비교 덕분에 컨텍스트 스위치 없이 같은 태스크를 계속 실행하는 경우가 많아집니다.

적격성 판정의 수학

적격성은 "가중치 기반 지연(lag)이 0 이상인가"로 정의됩니다. 태스크 i의 지연은 lag_i = w_i * (V - v_i)이며(V는 런큐 평균 가중 vruntime, v_i는 태스크의 vruntime), lag_i >= 0이면 적격입니다. 구현은 나눗셈으로 인한 정밀도 손실을 피하기 위해 부등식을 양변에 sum_weight를 곱한 형태로 바꿔 정수 비교만 수행합니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static int vruntime_eligible(struct cfs_rq *cfs_rq, u64 vruntime)
{
    struct sched_entity *curr = cfs_rq->curr;
    s64 key, avg = cfs_rq->sum_w_vruntime;
    long load = cfs_rq->sum_weight;

    if (curr && curr->on_rq) {
        unsigned long weight = avg_vruntime_weight(cfs_rq, curr->h_load.weight);

        avg += entity_key(cfs_rq, curr) * weight;
        load += weight;
    }

    key = vruntime_op(vruntime, "-", cfs_rq->zero_vruntime);

    return avg >= (__int128)key * load;
}

int entity_eligible(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    return vruntime_eligible(cfs_rq, se->vruntime);
}
코드 설명
  • 4~5행sum_w_vruntime는 런큐 전체의 가중 vruntime 합, sum_weight는 가중치 합입니다. CFS 시절의 min_vruntime 하나만으로는 가중 평균 V를 정확히 구할 수 없어, 커널 6.6 이후 이 두 필드가 추가되었습니다. 7.x에서는 sum_shift로 스케일을 맞춰 s64 범위 안에 유지합니다.
  • 7~12행현재 실행 중인 엔티티는 curr 포인터로만 존재하고 트리에는 없으므로, 평균 계산에 직접 반영합니다. avg_vruntime_weight()는 h_load.weight(계층 합산 가중치)를 사용해 그룹 스케줄링에서도 동일한 기준을 적용합니다.
  • 14행zero_vruntime은 가중 평균 계산의 기준점(영점)입니다. vruntime은 단조 증가하는 64비트 값이므로, 기준점을 빼서 작은 정수로 만든 뒤 곱셈을 수행해야 오버플로를 피할 수 있습니다.
  • 16행최종 비교는 avg >= key * load입니다. CONFIG_ARCH_SUPPORTS_INT128이면 128비트 곱셈으로, 아니면 check_mul_overflow()로 곱셈 오버플로를 감지해 부호만으로 판정합니다. 즉 적격성 검사 자체가 오버플로 안전합니다.
CFS 시절 코드와의 차이:
  • CFS(6.5 이하)의 pick_next_entity(cfs_rq, curr)는 cfs_rq->skip(vruntime이 너무 앞선 태스크 건너뛰기), next(wakeup buddy), last(연속 실행 방지) 세 힌트를 순서대로 검사했습니다. 세 힌트는 6.6에서 모두 제거되었습니다.
  • CFS의 트리 키는 vruntime이었지만, EEVDF는 deadline을 키로 사용합니다. 따라서 "leftmost = 최소 vruntime"이라는 설명은 6.6 이후에는 "leftmost = 최소 deadline"으로 읽어야 합니다.
  • CFS의 check_preempt_wakeup()에 있던 wakeup_granularity 비교는 사라지고, 깨어난 태스크의 deadline과 현재 태스크의 deadline 비교로 대체되었습니다.

스케줄링 흐름

schedule()
  └─ __schedule()
       ├─ pick_next_task()
       │    └─ pick_next_task_fair()  /* CFS */
       │         └─ pick_next_entity()
       │              └─ __pick_first_entity()  /* leftmost */
       └─ context_switch()

타임슬라이스와 지연시간

CFS는 고정 타임슬라이스 대신 동적으로 계산된 시간 할당을 사용합니다.

버전 범위: 이 절의 앞부분(핵심 파라미터, sched_slice() 계산, 시뮬레이터)은 Linux 6.5 이하의 CFS 동작을 설명합니다. sched_latency_ns, sched_min_granularity_ns, sched_wakeup_granularity_ns는 Linux 6.6에서 모두 제거되었고(v6.6 kernel/sched/fair.c에 해당 심볼이 존재하지 않음), 현재는 sched_base_slice를 중심으로 한 EEVDF 방식이 사용됩니다. 6.6 이후 동작은 아래 EEVDF의 타임슬라이스 절에서 다룹니다.
타임슬라이스가 없다면?

타임슬라이스 없이 vruntime이 최솟값인 태스크를 계속 실행하면 어떻게 될까요?

  • 실행 중인 태스크는 vruntime이 계속 증가하므로 결국 다른 태스크보다 커져 교체됩니다. 이론적으로는 괜찮아 보입니다.
  • 그러나 컨텍스트 스위치(Context Switch) 비용이 있습니다. 교체 기준 없이 vruntime만 보면 나노초 단위로 교체가 일어날 수 있어, 오히려 스위치 오버헤드가 실제 실행 시간보다 커질 수 있습니다.
  • 대화형(Interactive) 응답성이 예측 불가능해집니다. 터미널 입력처럼 짧게 깨어나는 태스크가 얼마나 빨리 CPU를 받을지 보장할 수 없습니다.

따라서 CFS는 sched_latency_ns로 "모든 태스크가 이 시간 안에 최소 1회 실행된다"는 상한을 보장하고, sched_min_granularity_ns로 최소 실행 시간을 하한으로 묶어 과도한 컨텍스트 스위치를 방지합니다.

핵심 파라미터 (CFS, Linux 6.5 이하)

아래 세 파라미터는 CFS 시절 /proc/sys/kernel/ 아래에 노출되던 값입니다. 현재 커널에서는 모두 사라졌으므로, 구버전 시스템을 분석하거나 옛 문서를 읽을 때만 참고해야 합니다.

파라미터CFS 기본값설명현재 상태
sched_latency_ns6ms모든 프로세스가 1회 실행되는 기간 (목표 지연)6.6에서 제거
sched_min_granularity_ns0.75ms최소 타임슬라이스 (너무 잦은 전환 방지)6.6에서 제거
sched_wakeup_granularity_ns1ms선점(Preemption) 결정 임계값6.6에서 제거

타임슬라이스 계산 (CFS, Linux 6.5 이하)

/* kernel/sched/fair.c — Linux 6.5 이하 (레거시 CFS) */
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% 할당
 */

Linux 6.6+ EEVDF의 타임슬라이스

EEVDF는 "전체 주기를 태스크 수로 나누는" CFS의 계산을 버리고, 태스크마다 요청 크기(request size) r_i를 부여합니다. 태스크는 r_i만큼 실행할 권리를 갖고, 그 권리를 다 쓰면 가상 마감 시각이 갱신됩니다. 가상 시간의 기울기는 여전히 nice 가중치 w_i가 결정하므로, nice의 의미(가중치 비율)는 그대로 유지됩니다.

구분CFS (6.5 이하)EEVDF (6.6 이상)
슬라이스 결정sched_latency / nr_running을 가중치로 분배태스크별 se->slice (기본 sysctl_sched_base_slice)
다음 태스크 기준최소 vruntime (leftmost)적격(eligibility) + 최소 가상 마감
RB-tree 키vruntimedeadline
슬라이스 소진 판정vruntime - exec_start > slice 비교vruntime >= deadline 비교 후 마감 갱신
튜닝 노브sched_latency_ns, sched_min_granularity_nssched_base_slice (+ sched_runtime sched_attr)

update_deadline() — 슬라이스 소진과 마감 갱신

EEVDF에서 "슬라이스를 다 썼는가"는 현재 vruntime이 마감을 넘었는가로 판정합니다. 넘었다면 슬라이스를 기본값으로 재설정하고 새 마감을 계산합니다. 아래는 v7.3.1의 실제 구현입니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static bool update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    if (vruntime_cmp(se->vruntime, "<", se->deadline))
        return false;

    /* 요청 크기는 base_slice, 커스텀 슬라이스면 그대로 유지 */
    if (!se->custom_slice)
        se->slice = sysctl_sched_base_slice;

    /* EEVDF: vd_i = ve_i + r_i / w_i */
    se->deadline = se->vruntime + calc_delta_fair(se->slice, se);
    avg_vruntime(cfs_rq);

    return true;
}
코드 설명
  • 3~4행vruntime < deadline이면 아직 요청을 소진하지 않았으므로 false를 반환합니다. 호출자는 이 반환값으로 선점 필요 여부를 판단합니다.
  • 6~8행custom_slice는 sched_setattr()의 sched_runtime으로 슬라이스를 직접 지정했는지를 나타냅니다. 지정하지 않았다면 sysctl_sched_base_slice(v7.3.1 기본 700us)를 사용합니다.
  • 11행새 마감은 vruntime + calc_delta_fair(slice, se)입니다. calc_delta_fair()가 slice * NICE_0_LOAD / weight를 계산하므로, 가중치가 큰 태스크는 같은 slice에 대해 작은 가상 시간만 소비하고 마감이 더 늦게 밀립니다.
  • 12행avg_vruntime()는 런큐의 가중 평균 vruntime(sum_w_vruntime, sum_weight, zero_vruntime)을 갱신합니다. 이 값이 다음 적격성 판정의 기준이 됩니다.
  • 14행true를 반환하면 update_curr()가 resched_curr_lazy()로 선점을 요청합니다.

update_curr() — 틱마다 호출되는 vruntime 갱신

/* kernel/sched/fair.c — Linux v7.3.1 */
static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->h_curr;
    struct rq *rq = rq_of(cfs_rq);
    s64 delta_exec;
    bool resched;

    if (unlikely(!curr))
        return;

    delta_exec = update_se(rq, curr);
    if (unlikely(delta_exec <= 0))
        return;

    account_cfs_rq_runtime(cfs_rq, delta_exec);

    if (!entity_is_task(curr))
        return;

    cfs_rq = &rq->cfs;

    curr->vruntime += calc_delta_fair(delta_exec, curr);
    resched = update_deadline(cfs_rq, curr);

    /* 공정 서버(fair_server) 회계 */
    dl_server_update(&rq->fair_server, delta_exec);

    if (cfs_rq->h_nr_queued == 1)
        return;

    if (resched || !protect_slice(curr)) {
        resched_curr_lazy(rq);
        clear_buddies(cfs_rq, curr);
    }
}
코드 설명
  • 3행CFS 시절에는 cfs_rq->curr을 사용했지만, v7.3.1에서는 h_curr를 사용합니다. 프록시 실행(Proxy Execution)이 켜지면 rq->curr(실제 실행 태스크)와 cfs_rq->curr(스케줄러가 선택한 엔티티)가 달라질 수 있기 때문입니다.
  • 11~13행update_se()가 마지막 갱신 이후의 실행 시간을 계산합니다. 시간이 흐르지 않았거나 시계가 역행한 경우(delta_exec <= 0)에는 아무것도 갱신하지 않습니다.
  • 19행curr->vruntime += calc_delta_fair(delta_exec, curr): 실제 실행 시간을 가중치로 나눈 만큼 가상 시계를 진행시킵니다. 이 한 줄이 nice 값이 CPU 배분에 반영되는 지점입니다.
  • 26~27행런큐에 엔티티가 하나뿐이면 선점할 대상이 없으므로 즉시 반환합니다. 단일 태스크가 CPU를 독점하는 상황에서 불필요한 resched IPI를 막는 최적화입니다.
  • 29~32행슬라이스를 소진했거나(resched) 슬라이스 보호가 끝났으면(!protect_slice()) 지연 선점을 요청합니다. resched_curr_lazy()는 즉시 컨텍스트 스위치를 강제하지 않고, 가장 가까운 커널 종료 지점에서 스위치하도록 표시합니다.

슬라이스 보호(vprot) — 잦은 스위치 방지

EEVDF는 "적격하고 마감이 이른 태스크"를 고르지만, 매번 컨텍스트 스위치를 하면 오버헤드가 커집니다. 그래서 현재 엔티티에 vprot(virtual protection)까지의 실행 권한을 부여하고, 그 구간에서는 다른 후보가 나타나도 계속 실행합니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static inline void set_protect_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    u64 slice = normalized_sysctl_sched_base_slice;
    u64 vprot = se->deadline;

    if (sched_feat(RUN_TO_PARITY))
        slice = cfs_rq_min_slice(cfs_rq);

    slice = min(slice, se->slice);

    /* 더 짧은 슬라이스를 가진 엔티티가 있으면 보호 구간을 줄임 */
    if (slice != se->slice) {
        if (sched_feat(PREEMPT_SHORT))
            vprot = min_vruntime(vprot, ineligible_vruntime(cfs_rq));
        else
            vprot = min_vruntime(vprot, se->vruntime + calc_delta_fair(slice, se));
    }

    se->vprot = vprot;
}

static inline bool protect_slice(struct sched_entity *se)
{
    return vruntime_cmp(se->vruntime, "<", se->vprot);
}
코드 설명
  • 5~6행보호 구간의 기본 상한은 deadline입니다. 즉 "이번 요청을 끝낼 때까지"가 기본 보호 범위입니다.
  • 8~9행RUN_TO_PARITY 기능이 켜져 있으면 런큐에서 가장 짧은 슬라이스를 가진 엔티티의 슬라이스까지로 보호 구간을 맞춥니다. 짧은 요청을 가진 태스크가 긴 요청 태스크 뒤에서 오래 기다리는 것을 막기 위한 장치입니다.
  • 14~18행PREEMPT_SHORT가 켜져 있으면 "적격하지 않은 엔티티의 vruntime"을 기준으로 보호 구간을 계산합니다. 꺼져 있으면 단순히 현재 vruntime에 축소된 슬라이스를 더한 지점을 사용합니다.
  • 24~26행protect_slice()는 vruntime < vprot인 동안 참입니다. pick_eevdf()와 update_curr()가 모두 이 함수로 "아직 보호 구간 안인가"를 판정합니다.

base_slice 관측과 조정

v7.3.1에서 sysctl_sched_base_slice의 기본값은 700us(700000ULL)입니다. 6.14까지는 750us였고 6.15에서 700us로 낮아졌습니다. 이 값은 CONFIG_SCHED_DEBUG가 켜진 커널에서 debugfs로 노출됩니다.

# 현재 값 확인 (root, CONFIG_SCHED_DEBUG)
cat /sys/kernel/debug/sched/base_slice_ns

# 1ms로 조정 (지연에 민감한 워크로드)
echo 1000000 > /sys/kernel/debug/sched/base_slice_ns

# 태스크 단위로 슬라이스 직접 지정 (sched_runtime, us)
chrt -d --sched-runtime 2000000 --sched-period 10000000 --sched-deadline 10000000 0 ./workload
왜 700us인가: base_slice는 EEVDF의 요청 크기 r_i 기본값입니다. 값을 키우면 컨텍스트 스위치가 줄어 처리량(Throughput)이 늘지만 개별 태스크의 응답 지연이 커지고, 줄이면 반대가 됩니다. v7.3.1 kernel/sched/debug.c는 이 값을 base_slice_ns로, 스케줄러 통계는 /proc/schedstat과 /proc/sched_debug로 관측할 수 있게 합니다.

부하 분산 (Load Balancing)

멀티코어 시스템에서 CFS는 CPU 간 태스크를 균등하게 분배합니다.

부하 분산 트리거

sched_balance_rq() 핵심 로직 (Linux 6.10+)

CPU 간 부하를 맞추는 본체 함수는 Linux 6.9까지 load_balance()라는 이름이었고, Linux 6.10에서 sched_balance_rq()로 개명되었습니다. 같은 시기 find_busiest_queue()는 sched_balance_find_src_rq(), newidle_balance()는 sched_balance_newidle()로 함께 이름이 바뀌었습니다. 구버전 문서나 트레이스에서 load_balance가 보인다면 6.9 이하 커널입니다.

/* kernel/sched/fair.c — Linux v7.3.1 (일부 오류 처리 생략) */
static int sched_balance_rq(int this_cpu, struct rq *this_rq,
                        struct sched_domain *sd, enum cpu_idle_type idle,
                        int *continue_balancing)
{
    int ld_moved, cur_ld_moved, active_balance = 0;
    struct sched_domain *sd_parent = sd->parent;
    struct sched_group *group;
    struct rq *busiest;
    struct rq_flags rf;
    struct cpumask *cpus = this_cpu_cpumask_var_ptr(load_balance_mask);
    struct lb_env env = {
        .sd       = sd,
        .dst_cpu  = this_cpu,
        .dst_rq   = this_rq,
        .idle     = idle,
        .loop_max = min(sysctl_sched_nr_migrate, 1),
    };

    sched_balance_update_blocked_averages(this_cpu);

    for_each_domain(this_cpu, sd) {
        /* 1. 가장 바쁜 그룹 선택 */
        group = find_busiest_group(&env);
        if (!group)
            continue;

        /* 2. 그룹 안에서 가장 바쁜 런큐 선택 */
        busiest = sched_balance_find_src_rq(&env, group);
        if (!busiest)
            continue;

        env.src_cpu   = busiest->cpu;
        env.src_rq    = busiest;
        env.loop_max  = min(sysctl_sched_nr_migrate, busiest->nr_running);

        /* 3. 태스크 분리(detach) - 반복 이동 */
        cur_ld_moved = detach_tasks(&env);
        ld_moved += cur_ld_moved;
        if (cur_ld_moved)
            break;
    }
    /* 4. 분리한 태스크를 목적 런큐에 부착(attach) */
    if (ld_moved)
        attach_tasks(&env);

    return ld_moved;
}
코드 설명
  • 3~5행시그니처가 CFS 시절의 4인자 형태에서 continue_balancing을 추가로 받는 5인자 형태로 바뀌었습니다. 이 포인터는 "이 도메인에서 계속 균형을 맞춰야 하는가"를 호출자에게 되돌려주는 in/out 파라미터입니다.
  • 11~17행struct lb_env는 이번 밸런싱 반복(iteration)의 상태를 담습니다. dst_cpu/dst_rq는 태스크를 받을 쪽이고, imbalance/src_cpu/src_rq는 반복하면서 채워집니다.
  • 19행sched_balance_update_blocked_averages()가 블로킹된 런큐의 PELT 부하를 먼저 갱신합니다. 부하 값이 낡은 상태에서 균형을 계산하면 잘못된 이동이 발생하기 때문입니다.
  • 23~24행find_busiest_group()은 sched_domain 계층을 따라 내려가며 "이 도메인에서 가장 불균형한 그룹"을 고릅니다. 그룹이 없으면 이 도메인은 균형 상태이므로 상위 도메인으로 넘어갑니다.
  • 28행개명된 sched_balance_find_src_rq()가 그룹 내 CPU 중 가장 바쁜 런큐를 찾습니다. 6.9 이하에서는 find_busiest_queue()였습니다.
  • 33행sysctl_sched_nr_migrate는 한 번의 밸런싱에서 옮길 수 있는 태스크 상한입니다. 무제한으로 옮기면 런큐 락을 오래 잡아 지연이 커지므로 상한을 둡니다.
  • 36~38행detach_tasks()는 목적 CPU가 감당할 만큼만 태스크를 분리합니다. 부하 계산은 can_migrate_task()와 task_h_load()를 사용하며, 캐시 친화성 때문에 migration_cost를 넘지 않는 범위에서만 이동합니다.
  • 42~43행attach_tasks()가 분리한 태스크를 목적 런큐에 넣습니다. 이 단계에서 enqueue_task_fair()가 호출되어 vruntime/deadline이 재계산됩니다.

Load Balancing 오버헤드(Overhead)

부하 분산은 성능 향상에 도움이 되지만, 과도한 분산은 오버헤드를 유발합니다:

  • 캐시(Cache) 미스: 태스크 이동 시 L1/L2 캐시 무효화(Invalidation)
  • Lock contention: 여러 CPU의 runqueue lock 경쟁
  • Migration cost: Context switch + TLB flush

고성능 애플리케이션(Application)은 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;
}
부하 (util_avg, %) 100 75 50 25 0 50% 25% 12.5% 32 64 96 128 160 192 태스크가 멈춘 시점 (t = 0) 시간 (ms)
그림: PELT 부하 추적의 감쇠(계산 예시). 태스크가 실행을 멈추면 util_avg는 32ms마다 절반이 되고, 47742틱(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는 여러 스케줄러 클래스 중 하나입니다.

스케줄러 클래스 우선순위

순위클래스정책설명
1stop_sched_class-CPU 정지 태스크 (최고 우선순위)
2dl_sched_classSCHED_DEADLINEDeadline 스케줄링 (EDF)
3rt_sched_classSCHED_FIFO, SCHED_RR실시간 스케줄링
4fair_sched_classSCHED_NORMAL, SCHED_BATCHCFS (일반 프로세스)
5idle_sched_classSCHED_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는 다음 조건에서 현재 실행 중인 태스크를 선점합니다.

선점 조건

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()이 호출되고, 이 함수가 현재 실행 중인 태스크의 타임슬라이스 소진 여부를 점검합니다. 아래는 전체 호출 경로입니다.

타이머 인터럽트 HZ 주기의 tick scheduler_tick() core.c: sched_class 디스패치 task_tick_fair() for_each_sched_entity 순회 entity_tick() update_curr + update_load_avg update_curr() vruntime += calc_delta_fair() update_deadline() vruntime >= deadline ? resched_curr_lazy() TIF_NEED_RESCHED 설정 schedule() 커널 복귀 지점에서 실행 deadline 미도달이면 슬라이스를 유지하고 그대로 복귀합니다.
그림: tick 한 번의 처리 경로(Linux v7.3.1 기준). 슬라이스 소진 판정은 update_curr()가 호출하는 update_deadline()이 담당하며, 6.6 이전에는 check_preempt_tick()이 이 역할을 했습니다. HRTICK이 켜진 queued tick은 entity_tick()에서 바로 resched_curr()로 분기합니다.

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 스케줄링 클래스(Scheduling Class)의 tick 핸들러(Handler)로, 모든 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을 갱신하고, 이때 update_deadline()이 현재 태스크의 슬라이스(가상 마감) 소진 여부를 검사합니다. 기존 CFS(≤6.5)에서는 check_preempt_tick()이 vruntime 차이로 선점을 결정했습니다.

/* kernel/sched/fair.c — 간략화 */
static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
    /* vruntime 갱신 — 내부에서 update_deadline()이 슬라이스 소진 검사 */
    update_curr(cfs_rq);

    /* PELT: 지수 이동 평균으로 부하 추적 */
    update_load_avg(cfs_rq, curr, UPDATE_TG);
    update_cfs_group(curr);
}
참고: EEVDF(6.6+)에서 선점 판단은 update_curr()이 호출하는 update_deadline()에서 이루어집니다. 현재 태스크의 vruntime이 가상 마감(deadline)에 도달하면 슬라이스를 소진한 것으로 보고 resched_curr()(6.14+에서는 resched_curr_lazy())가 TIF_NEED_RESCHED 플래그를 설정합니다. 실제 스케줄러 호출(schedule())은 인터럽트 반환 시점이나 커널-사용자 공간(User Space) 전환 시점에 일어납니다.

깨우기(Wakeup) 선점(Wakeup Preemption) 상세

태스크가 슬립(Sleep) 상태에서 깨어날 때(wake-up), CFS는 현재 실행 중인 태스크를 선점할지 결정합니다. 전통적 CFS(≤6.5)에서는 wakeup_preempt_entity()가 vruntime 차이로 판단했지만, EEVDF(6.6+)에서는 pick_eevdf()가 고른 가장 이른 deadline(가장 적격, most eligible) 태스크가 깨어난 태스크인지로 판단합니다. 결정 함수 이름은 버전에 따라 check_preempt_wakeup()(≤6.13) → check_preempt_wakeup_fair()(6.14+) → wakeup_preempt_fair()(최신)로 바뀌었으며, 내부 판단 로직은 동일합니다.

try_to_wake_up() ttwu_do_activate() → enqueue_task_fair() wakeup_preempt_fair() wakeup_preempt()이 sched_class로 디스패치 선점 후보에서 제외되는 조건 · fair 클래스가 아니거나 스로틀된 태스크 · sched_delayed 상태이거나 BATCH/IDLE 정책 · WF_FORK로 깨어난 경우(데이터 공유 가능성 낮음) pick_next_entity() == pse ? 가장 적격인 태스크가 깨어난 태스크인가 resched_curr() TIF_NEED_RESCHED 설정 예 아니오: 선점 없이 계속 실행
그림: 깨어난 태스크의 선점 판정(Linux v7.3.1). 제외 조건을 통과하면 pick_next_entity()가 고른 태스크가 깨어난 태스크와 같은지로 결정합니다. PREEMPT_SHORT가 켜져 있으면 더 짧은 슬라이스를 요청한 태스크가 후보로 우선됩니다.
/* kernel/sched/fair.c — wakeup 선점 진입점 (EEVDF, 최신 커널 간략화) */
static void
wakeup_preempt_fair(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;

    if (unlikely(se == pse))
        return;

    /* 이미 선점이 예약되어 있으면 추가 판단 불필요 */
    if (test_tsk_need_resched(curr))
        return;

    /* WAKEUP_PREEMPTION 기능 비활성 시 선점 안 함 */
    if (!sched_feat(WAKEUP_PREEMPTION))
        return;

    find_matching_se(&se, &pse);  /* 같은 cgroup 레벨로 올라감 */

    /* SCHED_IDLE 엔티티는 비유휴 엔티티에게 항상 선점당함 */
    if (se_is_idle(se) && !se_is_idle(pse))
        goto preempt;
    if (se_is_idle(se) != se_is_idle(pse))
        return;

    /* BATCH/IDLE 정책 태스크는 선점하지 않음 (tick이 선점을 담당) */
    if (unlikely(!normal_policy(p->policy)))
        return;

    cfs_rq = cfs_rq_of(se);
    update_curr(cfs_rq);           /* 현재 태스크 vruntime 갱신 */

    /* EEVDF 핵심 판단: 깨어난 태스크가 가장 이른 deadline이면 선점 */
    if (pick_eevdf(cfs_rq) == pse)
        goto preempt;

    return;

preempt:
    resched_curr(rq);  /* TIF_NEED_RESCHED 설정 (6.14+에서는 lazy) */
}

place_entity()는 깨어난 태스크가 enqueue될 때 lag(지연)을 복원하고 가상 마감(deadline)을 설정합니다. EEVDF의 wakeup 선점은 이 deadline을 기준으로, 깨어난 태스크가 현재 실행 중인 태스크보다 가장 이른 deadline(가장 적격)일 때만 선점합니다.

/* kernel/sched/fair.c — EEVDF deadline 계산 (place_entity() 핵심, 간략화) */
se->slice = sysctl_sched_base_slice;       /* base_slice: 기본 750µs (6.15+ 700µs) */
vslice = calc_delta_fair(se->slice, se);   /* r_i / w_i */
se->vruntime = vruntime - lag;             /* vlag(lag) 복원 */
se->deadline = se->vruntime + vslice;      /* vd_i = ve_i + r_i/w_i */

/* wakeup 선점: 깨어난 태스크(pse)가 cfs_rq에서 가장 이른 deadline이면 선점 */
if (pick_eevdf(cfs_rq) == pse)
    resched_curr(rq);  /* TIF_NEED_RESCHED 설정 */
vlag와 deadline: EEVDF(6.6+)에서는 기존 CFS(≤6.5)의 wakeup_preempt_entity()와 sysctl_sched_wakeup_granularity(기본 1ms)가 제거되었습니다. 깨어난 태스크는 슬립 중 보유했던 vlag(가상 지연)를 복원해 공정하게 대열에 합류하며, wakeup 선점은 vruntime 차이가 아니라 가상 마감(deadline) 비교로 판단합니다. base_slice(sysctl_sched_base_slice)가 짧을수록 deadline 경계가 가까워져 선점과 컨텍스트 스위치가 잦아지고, 길수록 반응성은 낮아지지만 컨텍스트 스위치 오버헤드가 줄어듭니다.

모니터링

CFS 스케줄러의 동작을 관찰하고 디버깅(Debugging)하는 방법입니다.

스케줄러 성능 문제 진단 순서

애플리케이션 성능 저하 시 다음 순서로 CFS 관련 문제를 진단하세요:

  1. 컨텍스트 스위치 빈도 확인:
    vmstat 1
    # cs 열이 초당 수만~수십만이면 과도한 전환
  2. 런큐 길이 (load average) 확인:
    uptime
    # load average: 2.50, 3.10, 2.90
    # CPU 4코어 시스템에서 2.5 → 62% 활용 (정상)
  3. 프로세스별 CPU 시간 분포:
    top -H -p <pid>
    # TIME+ 열에서 스레드별 CPU 누적 시간 확인
  4. 비자발적 선점 횟수 (nr_involuntary_switches):
    grep involuntary /proc/<pid>/status
    # 높으면 타임슬라이스 소진으로 자주 선점됨
  5. 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    |

성능 튜닝

스케줄러 파라미터를 조정해 워크로드 특성에 맞출 수 있습니다. 값에 따른 변화 폭은 하드웨어·워크로드마다 다르므로, 아래 수치는 출발점으로만 쓰고 대상 시스템에서 다시 측정합니다.

버전 기준: 이 절은 Linux 6.6 이후 EEVDF 기준입니다(v7.3.1 소스 확인). 6.6에서 sched_latency_ns, sched_min_granularity_ns, sched_wakeup_granularity_ns가 제거되고(kernel/sched/fair.c에 해당 심볼이 없음) 타임슬라이스는 sched_base_slice 하나로 통합되었습니다. 6.5 이하 CFS 기준의 파라미터와 계산식은 타임슬라이스와 지연시간 절에 버전 표기와 함께 남아 있습니다.

튜닝 파라미터

7.x에서 스케줄러 튜닝 값은 /proc/sys/kernel이 아니라 debugfs의 /sys/kernel/debug/sched/ 아래에 있습니다(sched_init_debug(), kernel/sched/debug.c). CONFIG_SCHED_DEBUG가 켜져 있고 debugfs가 마운트(Mount)되어 있어야 합니다.

파라미터노출 경로기본값효과
sched_base_slice/sys/kernel/debug/sched/base_slice_ns700,000 ns (0.7ms)요청 슬라이스 r_i의 기본값. deadline = vruntime + calc_delta_fair(slice)
sched_attr.sched_runtimesched_setattr(2)0 (미지정이면 base_slice)태스크별 슬라이스 요청. 100us~100ms로 클램프됨
sched_migration_cost/sys/kernel/debug/sched/migration_cost_ns500,000 ns (0.5ms)마이그레이션 비용 추정. 키우면 캐시 친화성, 줄이면 분산 우선
sched_nr_migrate/sys/kernel/debug/sched/nr_migrate32 (PREEMPT_RT는 8)한 번의 밸런싱에서 옮길 최대 태스크 수
sched_tunable_scaling/sys/kernel/debug/sched/tunable_scaling1 (log2 스케일)CPU 수에 따라 base_slice를 자동 확대 (0=비활성, 1=log2, 2=선형)
sched_features/sys/kernel/debug/sched/features빌드 기본값스케줄러 기능 플래그 런타임 토글(디버깅용). NO_ 접두사로 개별 비활성
sched_cfs_bandwidth_slice_us/proc/sys/kernel/sched_cfs_bandwidth_slice_us5,000 uscgroup 대역폭 쿼터를 보충하는 단위

이 가운데 sched_cfs_bandwidth_slice_us만 fair.c의 sysctl 테이블(sched_fair_sysctls)에 남아 있고, 나머지는 debugfs 항목입니다. 태스크 단위 슬라이스는 sysctl이 아니라 sched_setattr()로 요청합니다.

워크로드별 권장 설정

대화형 데스크톱

# 짧은 슬라이스 + 낮은 이동 비용 → 응답성 우선
echo 500000 > /sys/kernel/debug/sched/base_slice_ns        # 0.5ms
echo 250000 > /sys/kernel/debug/sched/migration_cost_ns   # 0.25ms

배치 처리 서버

# 긴 슬라이스 + 높은 이동 비용 → 처리량·캐시 친화 우선
echo 3000000 > /sys/kernel/debug/sched/base_slice_ns        # 3ms
echo 5000000 > /sys/kernel/debug/sched/migration_cost_ns   # 5ms
echo 128 > /sys/kernel/debug/sched/nr_migrate

전역 값을 건드리지 않고 특정 태스크에만 슬라이스를 지정하려면 sched_setattr()의 sched_runtime을 사용합니다. 커널은 이 값을 100us~100ms로 클램프하고 se->custom_slice를 세웁니다.

/* Linux 6.6+ EEVDF: 태스크별 슬라이스 요청 */
#include <linux/sched/types.h>
#include <sys/syscall.h>
#include <unistd.h>

struct sched_attr attr = {
    .size          = sizeof(attr),
    .sched_policy  = SCHED_NORMAL,
    .sched_nice    = 0,
    .sched_runtime = 500000,   /* 0.5ms 요청 */
};

syscall(SYS_sched_setattr, 0, &attr, 0);
적용값 확인: 값을 바꾼 뒤 /proc/sched_debug의 sysctl_sched_base_slice 항목으로 실제 적용값을 확인합니다. sched_runtime을 0으로 두면 sched_base_slice를 그대로 쓰며 se->custom_slice는 0이 됩니다.

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 발생

다른 스케줄러와 비교

항목CFSO(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, &param);
  • 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 초기화, 그리고 타임라인 전체를 시각적으로 추적합니다.

버전 구분: 아래 코드는 Linux 6.5 이하 CFS의 구현입니다. v7.3.1에는 cfs_rq->min_vruntime, update_min_vruntime(), sched_feat(START_DEBIT), sched_feat(GENTLE_FAIR_SLEEPERS), sysctl_sched_latency, sched_child_runs_first가 존재하지 않습니다. 6.6 이후의 대체 메커니즘은 이 절 마지막의 zero_vruntime과 avg_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);
}
START_DEBIT 패널티: 새 태스크가 즉시 실행되면 기존 태스크의 공정성이 깨집니다. 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 기준그룹 스케줄링 공정성 유지
vruntime 증가율 = NICE_0_LOAD / weight vruntime nice -5 nice 0 nice +5 시간 복귀 태스크의 lag lag = V - vruntime, V = avg_vruntime() clamp = calc_delta_fair(max_slice + TICK_NSEC) -limit +limit 0 음수 lag: 선택 우선 양수 lag: 뒤로 밀림 잠든 동안 lag은 clamp 범위로 고정 깨어날 때 place_entity()가 vlag 보존
그림: EEVDF의 vruntime/lag 개념(개요도). weight가 큰 태스크는 vruntime이 천천히 늘어 CPU를 더 받고, 태스크가 잠들었다 돌아오면 update_entity_lag()가 lag을 clamp한 뒤 place_entity()가 그 값을 보존해 배치합니다. 6.5 이하 CFS에서는 min_vruntime 기준 보정(min_vruntime - latency/2)을 사용했습니다.

Linux 6.6+ EEVDF의 기준선: zero_vruntime과 avg_vruntime

6.6에서 EEVDF가 병합되면서 cfs_rq->min_vruntime과 update_min_vruntime()은 제거되었습니다. v7.3.1에서 min_vruntime은 sched_entity의 RB-tree 증강 필드로만 남아 있고, 런큐의 시간 기준선은 다음 네 값으로 유지됩니다.

필드의미
zero_vruntime가상 시간의 원점 v0. 모든 편차 계산의 기준입니다.
sum_w_vruntimeΣ(v_i − v0)·w_i. 가중 편차 합으로, 태스크별 vruntime을 순회하지 않고 평균을 구합니다.
sum_weightΣw_i. 가중 평균의 분모입니다.
sum_shift정밀도 유지를 위한 시프트량입니다.

가중 평균 V는 avg_vruntime()이 V = Σ(v_i − v0)·w_i / Σw_i + v0로 계산합니다. vruntime이 d만큼 전진하면 모든 편차가 d만큼 줄어들므로, sum_w_vruntime에서 d × sum_weight를 빼는 O(1) 갱신으로 끝납니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static inline
void update_zero_vruntime(struct cfs_rq *cfs_rq, s64 delta)
{
    /*
     * v' = v + d ==> sum_w_vruntime' = sum_w_vruntime - d*sum_weight
     */
    cfs_rq->sum_w_vruntime -= cfs_rq->sum_weight * delta;
    cfs_rq->zero_vruntime += delta;
}
코드 설명
  • 1행update_zero_vruntime()은 기준선을 delta만큼 옮기는 유일한 지점입니다. 6.5 이하의 update_min_vruntime()과 달리 "최솟값을 따라간다"는 개념이 아니라, 편차 합을 함께 보정해 평균을 정확히 유지합니다.
  • 7~8행sum_w_vruntime -= sum_weight * delta: 원점을 delta만큼 밀면 모든 v_i − v0가 delta만큼 작아지므로, 가중치 합만큼 곱해 빼면 됩니다. 이 성질 덕분에 태스크 수에 무관하게 O(1)로 평균 기준선을 갱신할 수 있습니다.

적격성(Eligibility)은 lag_i = w_i(V − v_i) ≥ 0, 즉 V ≥ v_i입니다. 나눗셈으로 V를 구하면 정밀도가 손실되므로, 커널은 양변에 Σw_i를 곱한 정수 비교를 사용합니다.

/* kernel/sched/fair.c — Linux v7.3.1 */
static int vruntime_eligible(struct cfs_rq *cfs_rq, u64 vruntime)
{
    struct sched_entity *curr = cfs_rq->curr;
    s64 key, avg = cfs_rq->sum_w_vruntime;
    long load = cfs_rq->sum_weight;

    if (curr && curr->on_rq) {
        unsigned long weight = avg_vruntime_weight(cfs_rq, curr->h_load.weight);

        avg += entity_key(cfs_rq, curr) * weight;
        load += weight;
    }

    key = vruntime_op(vruntime, "-", cfs_rq->zero_vruntime);

#ifdef CONFIG_64BIT
#ifdef CONFIG_ARCH_SUPPORTS_INT128
    /* This often results in simpler code than __builtin_mul_overflow(). */
    return avg >= (__int128)key * load;
#else
    s64 rhs;
    /*
     * On overflow, the sign of key tells us the correct answer: a large
     * positive key means vruntime >> V, so not eligible; a large negative
     * key means vruntime << V, so eligible.
     */
    if (check_mul_overflow(key, load, &rhs))
        return key <= 0;

    return avg >= rhs;
#endif
#else /* 32bit */
    return avg >= key * load;
#endif
}

int entity_eligible(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    return vruntime_eligible(cfs_rq, se->vruntime);
}

새 태스크와 슬립 복귀 태스크는 place_entity()가 avg_vruntime()을 기준으로 배치합니다. 슬라이스는 se->slice = sysctl_sched_base_slice(기본 700,000ns)를 calc_delta_fair()로 가상 시간으로 환산하고, 마감은 deadline = vruntime + vslice로 정해집니다. 이때 sched_feat(PLACE_LAG)가 켜져 있으면 디큐 시점의 vlag를 보존하도록 lag을 부풀려 배치하고(가중 평균이 이동하는 효과를 보정), sched_feat(PLACE_DEADLINE_INITIAL)과 ENQUEUE_INITIAL 조합에서는 새 태스크를 vslice/2만큼 앞당겨 초기 응답성을 확보합니다.

상황Linux 6.5 이하 CFSLinux 6.6+ EEVDF
새 태스크 인큐min_vruntime + sched_vslice() 보정avg_vruntime() 기준 배치, 초기 태스크는 vslice/2 앞당김
슬립 복귀GENTLE_FAIR_SLEEPERS로 latency/2 보상vlag 보존 + 가중 평균 이동 보정(PLACE_LAG)
CPU 마이그레이션min_vruntime 기준으로 위치를 다시 계산목적 런큐에서 vlag를 보존해 배치(migrate_entity())
기준선과 래핑: 6.5 이하 CFS에서 min_vruntime은 max_vruntime()으로 단조 증가를 보장했습니다. 7.x EEVDF에서는 같은 역할을 cfs_rq->zero_vruntime이 맡고, avg_vruntime()이 가중 평균을 계산할 때 함께 갱신합니다. vruntime·deadline 비교는 여전히 부호 있는 64비트 차이(s64)로 랩어라운드를 처리합니다.

그룹 스케줄링

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);
}
1단계: 루트 cfs_rq가 그룹 엔티티를 선택 그룹 A 엔티티 (se) weight 2048 2 : 1 그룹 B 엔티티 (se) weight 1024 2단계: 선택된 그룹의 cfs_rq에서 태스크 선택 그룹 A의 cfs_rq P1 vr 100 P2 vr 150 P3 vr 200 vr 최솟값(P1)이 다음 실행 후보 그룹 B의 cfs_rq Q1 vr 80 Q2 vr 120 태스크가 2개뿐이어도 그룹 몫은 1/3
그림: 그룹 스케줄링의 2단계 분배. 루트 cfs_rq는 그룹 엔티티를 weight(2048 : 1024 = 2 : 1) 비율로 선택하고, 선택된 그룹의 cfs_rq가 내부 태스크를 고릅니다. 그룹 B에 태스크가 적어도 그룹 몫은 1/3로 유지됩니다.

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
cgroup v1 vs v2 차이: v1의 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

개념CFSEEVDF
선택 기준최소 vruntimeeligible 중 최소 virtual deadline
공정성vruntime 기반lag 기반 (더 정밀)
지연시간보장 없음O(1) 지연 보장
선점wakeup_granularity 기반deadline 비교
복잡도O(log N)O(log N)
구현 위치kernel/sched/fair.ckernel/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;
}
vruntime 축에서의 eligible 판정과 virtual deadline eligible (lag >= 0, vr <= V) 부적격 (lag < 0) vruntime V = avg_vruntime T3 vr 130 / dl 260 T1 vr 200 / dl 300 T2 vr 300 / dl 420 T4 vr 480 / dl 700 T5 vr 780 / dl 860 선택: eligible 태스크 중 deadline 최솟값 → T3 (dl 260) CFS (6.6 이전) 최소 vruntime 태스크를 고름 오래 잔 태스크가 긴 burst를 받을 수 있음 EEVDF (6.6 이상) eligible 중 최소 deadline 태스크를 고름 deadline이 지연 시간 상한을 만듦
그림: EEVDF 선택 과정(개념도). avg_vruntime()을 기준으로 lag가 0 이상인 태스크만 eligible로 보고, 그중 virtual deadline이 가장 이른 태스크를 고릅니다. CFS처럼 최소 vruntime만 보면 잠에서 막 돌아온 태스크가 긴 burst를 가져갈 수 있습니다.
마이그레이션 가이드: Linux 6.6 이상에서는 EEVDF가 기본입니다. 기존 CFS 튜닝 파라미터 중 sched_wakeup_granularity_ns·sched_latency_ns·sched_min_granularity_ns는 6.6에서 base_slice로 통합·제거되었습니다. 대신 태스크의 slice 값이 deadline 계산에 사용되며, Linux 6.14부터 sched_attr.sched_runtime(0.1ms~100ms 범위)으로 태스크별 slice를 직접 지정할 수 있습니다.

EEVDF 실무 활용

EEVDF에서 태스크의 slice 값을 조정하면 지연시간 특성을 제어할 수 있습니다.

/* EEVDF에서 태스크 slice 설정 (Linux 6.14+ — sched_runtime이 slice로 해석됨) */
#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 값동작적합한 워크로드
0.75ms (기본)EEVDF 기본 base_slice, 균형잡힌 deadline과 타임슬라이스일반 워크로드
1~3ms (짧은 값)짧은 deadline, 빠른 스케줄링, 잦은 선점대화형 UI, 오디오 렌더링
10ms (큰 값)긴 deadline, 큰 타임슬라이스, 적은 선점컴파일, 배치 처리
100ms (매우 큰 값)거의 선점되지 않음, 최대 처리량HPC, 과학 컴퓨팅
EEVDF vs CFS 하위 호환성: EEVDF 도입 시 기존 CFS의 sched_latency_ns·sched_min_granularity_ns·sched_wakeup_granularity_ns는 6.6에서 모두 제거되어 sysctl_sched_base_slice(/sys/kernel/debug/sched/base_slice_ns) 하나로 통합되었습니다. EEVDF에서는 deadline 비교로 선점을 결정하므로 별도의 latency/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_timer hrtimer가 만료될 때 호출됩니다. runtime을 quota 값으로 리필하여 새 주기를 시작합니다.
  • 24~26행distribute_cfs_runtime()으로 각 CPU의 스로틀된 cfs_rq에 런타임을 분배하고, unthrottle_cfs_rq()로 태스크를 런큐에 재삽입하여 실행을 재개합니다.
quota 50ms / period 100ms (cpu.max = "50000 100000") Period 1 (100ms) Period 2 (100ms) Period 3 (100ms) 실행 50ms 스로틀 50ms 실행 50ms 스로틀 50ms 실행 50ms 스로틀 50ms 시간 quota 리필 quota 리필 quota를 모두 쓰면 다음 period 시작까지 태스크가 실행되지 않고, 리필 시점에 다시 실행됩니다.
그림: CFS 대역폭 제어의 반복 사이클. 각 period가 시작될 때 quota가 리필되고, quota를 소진하면 throttle_cfs_rq()로 런큐가 스로틀되어 다음 period까지 실행이 멈춥니다. 값은 cgroup v2의 cpu.max(quota period) 표기입니다.

멀티코어 환경에서의 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코어 전부 사용 가능)
Kubernetes CPU 리밋 매핑: Kubernetes의 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: Linux 커널의 rb_first_cached()는 RB-tree의 leftmost 노드를 캐싱하여 O(1)에 접근합니다. enqueue_entity()에서 삽입 시 leftmost 여부를 판별하여 캐시를 갱신합니다. 따라서 pick_next_entity()의 실질적 시간 복잡도는 O(1)입니다 (트리 탐색 없이 즉시 접근).
pick_task_fair() fair.c의 .pick_task 콜백 cfs_rq->h_nr_queued == 0 ? 실행 가능한 엔티티가 없는가 sched_balance_newidle() 다른 CPU에서 태스크를 pull update_curr_eevdf() curr가 on_rq이면 실행 통계 갱신 pick_next_entity(rq, protect) pick_eevdf(): eligible 중 최소 deadline task_of(se) 선택된 엔티티의 태스크 반환 선택 힌트 (7.x) next: 깨운 태스크 우선 후보 (PICK_BUDDY) skip / last: 7.x에서 제거됨 next도 eligible일 때만 유효 예 아니오
그림: 태스크 선택 경로(Linux v7.3.1). 엔티티가 하나뿐이면 바로 반환하고, 그룹 엔티티는 pick_eevdf()가 eligible 후보를 고른 뒤 task_of()가 실제 태스크를 꺼냅니다. 7.x에서는 skip/last 힌트가 제거되고 next(PICK_BUDDY)만 남았습니다.

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 토폴로지(Topology)(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 등 */
};
NUMA 도메인 (레벨 2) 노드 경계를 넘는 이동 비용이 큼 → 드물게 밸런싱 MC 도메인 (Node 0, 레벨 1) LLC 공유 → 중간 주기 MC 도메인 (Node 1, 레벨 1) LLC 공유 → 중간 주기 SMT (Core 0) 짧은 주기 SMT (Core 1) 짧은 주기 SMT (Core 2) 짧은 주기 SMT (Core 3) 짧은 주기 CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7 밸런싱 원칙 1. 가까운 도메인(캐시 공유)부터 시도해 이동 비용을 최소화 2. 먼 도메인은 앞 단계가 실패했을 때만 시도 (pull migration)
그림: sched_domain 계층(예: 2 NUMA 노드 × 2 코어 × SMT 2). 안쪽 도메인일수록 자주, 바깥 도메인일수록 드물게 밸런싱하며, 유휴 CPU가 바쁜 CPU에서 태스크를 가져오는 pull migration으로 부하를 옮깁니다.

Pull vs Push 마이그레이션

유형트리거동작함수
PullCPU가 유휴 상태 진입바쁜 CPU에서 태스크 가져옴newidle_balance()
Pull주기적 밸런싱 타이머가장 바쁜 그룹에서 태스크 이동sched_balance_rq()
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를 찾습니다. 이 함수는 캐시 친화성을 최대로 유지하면서 불필요한 마이그레이션 비용을 줄이는 핵심 로직입니다.

select_idle_sibling(): 깨운 태스크를 둘 CPU를 정하고, 실패하면 다음 후보로 넘어갑니다 ① target 유휴? choose_idle_cpu(target) asym_fits_cpu() 통과 필요 ② prev 유휴? cpus_share_cache(prev, target) 캐시 공유일 때만 ③ recent_used_cpu 직전에 쓰던 CPU가 유휴? p->recent_used_cpu ④ SIS_PROP 스캔 select_idle_cpu(): LLC 범위 avg_idle 기반 비례 스캔 아니오 아니오 아니오 → 해당 CPU 반환 → prev CPU 반환 → 그 CPU 반환 → 스캔 결과 반환 모두 실패 유휴 CPU 없이 target 유지 없으면
그림: select_idle_sibling() 탐색 순서(Linux v7.3.1). 앞 후보가 유휴가 아니면 다음 후보로 넘어가고, 모두 실패하면 깨운 태스크를 target CPU에 그대로 둡니다. 스캔은 avg_idle과 스캔 비용에 비례해 범위를 줄이는 SIS_PROP 방식을 씁니다.
/* 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의 예상 대기 시간(Latency)을 비교해 더 빠를 쪽을 선택합니다.

/* kernel/sched/fair.c — 간략화 */
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은 캐시 데이터가 waker CPU의 캐시에 남아 있을 가능성이 높을 때 유리합니다. 그러나 waker CPU가 이미 바쁘다면 prev_cpu가 더 나을 수 있습니다. wake_affine_weight()는 두 CPU의 런큐 부하를 비교해 이 결정을 내립니다.
밸런싱 비용: NUMA 노드 간 마이그레이션은 LLC(Last-Level Cache) 전체 무효화와 원격 메모리 접근 지연(100-300ns)을 동반합니다. /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
EAS 활성화 조건: EAS는 다음 조건을 모두 만족해야 동작합니다: (1) 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 상세 분석

/* 개념 요약 — 정확한 필드 순서·타입은 위 "sched_entity 구조체" 절 참조 */
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 구조체 분석

아래는 Linux v7.3.1의 실제 정의(주요 필드)입니다. CFS 시절의 nr_running, exec_clock, min_vruntime은 더 이상 존재하지 않습니다. EEVDF는 가중 평균 vruntime을 정확히 계산하기 위해 sum_w_vruntime/sum_weight/zero_vruntime 세 필드로 대체했고, 지연 디큐와 프록시 실행을 위해 h_curr가 추가되었습니다.

/* kernel/sched/sched.h — Linux v7.3.1 */
struct cfs_rq {
    /* === 부하 정보 === */
    struct load_weight  load;            /* 런큐 내 총 가중치 */
    unsigned int       nr_queued;        /* 직접 큐잉된 엔티티 수 */
    unsigned int       h_nr_queued;      /* 계층 합산 큐 길이 */
    unsigned int       h_nr_runnable;    /* 실행 가능(러너블) 수 */
    unsigned int       h_nr_idle;        /* SCHED_IDLE 수 */

    /* === EEVDF 가중 평균 vruntime === */
    s64                sum_w_vruntime;   /* 가중 vruntime 합 */
    u64                sum_weight;       /* 가중치 합 */
    u64                zero_vruntime;    /* 평균 계산 기준점 */
    unsigned int       sum_shift;        /* 합 스케일 보정 시프트 */
#ifdef CONFIG_SCHED_CORE
    unsigned int       forceidle_seq;
    u64                zero_vruntime_fi; /* force-idle 전용 영점 */
#endif

    /* === RB-tree === */
    struct rb_root_cached tasks_timeline; /* 키: deadline, leftmost 캐시 */

    /* === 현재/다음 엔티티 === */
    struct sched_entity *curr;           /* 스케줄러가 선택한 엔티티 */
    struct sched_entity *next;           /* wakeup buddy 힌트 */
    struct sched_entity *h_curr;         /* 계층 기준 현재 엔티티 */

    /* === PELT === */
    struct sched_avg     avg;
    struct {
        raw_spinlock_t  lock ____cacheline_aligned;
        int             nr;
        unsigned long   load_avg;
        unsigned long   util_avg;
        unsigned long   runnable_avg;
    } removed;                            /* 디큐된 엔티티의 잔여 기여분 */

#ifdef CONFIG_FAIR_GROUP_SCHED
    /* === 그룹 스케줄링 === */
    unsigned long       h_load;
    struct rq          *rq;              /* 소속 CPU 런큐 */
    struct list_head    leaf_cfs_rq_list;
    struct task_group  *tg;              /* 소유 task_group */
    int                idle;

# ifdef CONFIG_CFS_BANDWIDTH
    /* === Bandwidth Control === */
    int                runtime_enabled;
    s64                runtime_remaining; /* 남은 quota */
    bool               throttled;         /* 스로틀 여부 */
    int                throttle_count;
    struct list_head    throttled_list;
# endif
#endif
};
코드 설명
  • 5~8행nr_queued는 이 cfs_rq에 직접 큐잉된 엔티티 수, h_nr_queued는 자식 cfs_rq까지 합산한 수입니다. CFS 시절의 nr_running/h_nr_running이 6.6에서 개명된 것으로, "러너블"과 "큐잉"을 구분하기 위해 h_nr_runnable이 별도로 존재합니다.
  • 11~14행EEVDF의 적격성 판정은 런큐의 가중 평균 vruntime을 필요로 합니다. sum_w_vruntime(가중합)을 sum_weight(가중치 합)로 나누면 평균이 되며, zero_vruntime은 64비트 값의 오버플로를 피하기 위한 기준점입니다. sum_shift는 합이 커질 때 스케일을 맞추는 시프트 값입니다.
  • 15~18행CONFIG_SCHED_CORE가 켜지면 코어 스케줄링(SMT 형제 강제 idle)을 위한 forceidle_seq/zero_vruntime_fi가 추가됩니다. SMT 시스템에서 보안 격리를 위해 형제 스레드를 강제로 쉬게 할 때 사용하는 별도 영점입니다.
  • 21행tasks_timeline은 rb_root_cached로, 루트와 함께 rb_leftmost를 캐시합니다. EEVDF에서 정렬 키는 se->deadline입니다.
  • 24~26행curr는 스케줄러가 선택한 엔티티, h_curr는 계층 순회 기준의 현재 엔티티입니다. 프록시 실행이 켜지면 실제 실행 태스크(rq->curr)와 curr가 달라질 수 있어 둘을 구분합니다.
  • 29~35행avg는 PELT 상태이고, removed는 디큐된 엔티티의 부하 기여분을 일정 시간 유지하는 블록입니다. 태스크가 잠깐 슬립했다 깨어날 때 부하 이력을 잃지 않게 합니다.
  • 40~44행h_load는 계층 부하, tg는 이 런큐를 소유한 task_group입니다. 그룹 스케줄링에서 CPU 시간 배분의 단위가 됩니다.
  • 48~53행CONFIG_CFS_BANDWIDTH가 켜지면 quota 회계용 필드가 추가됩니다. runtime_remaining이 0이 되면 throttled가 서고, throttled_list를 통해 다음 period에 해제됩니다.
CFS(6.5 이하) 정의와의 차이: nr_running은 nr_queued로, h_nr_running은 h_nr_queued로 개명되었습니다. exec_clock은 제거되었고(회계는 update_curr()의 누적 실행 시간으로 처리), min_vruntime 하나로 관리하던 기준선은 sum_w_vruntime/sum_weight/zero_vruntime 3개 필드로 분해되었습니다. last/skip buddy 포인터도 사라지고 next만 남았습니다.

Weight/Load 계산 체계

cfs_rq (루트 런큐) load.weight = 3072 (se1 1024 + se2 2048) nr_running = 2, min_vruntime = 50000 avg_vruntime 계열로 eligible 기준선 유지 tasks_timeline (RB-tree) se1 se2 가상 런타임 순서로 정렬해 최소 deadline 후보를 찾음 sched_entity (se1, 예시 값) load.weight = 1024 (nice 0) vruntime = 50000, on_rq = 1 avg.util_avg = 512, avg.load_avg = 480 my_q = NULL (개별 태스크) cfs_rq = 루트 cfs_rq를 가리킴 my_q != NULL이면 그룹 엔티티 struct load_weight unsigned long weight; /* nice → sched_prio_to_weight[] */ u32 inv_weight; /* 2^32 / weight */ inv_weight를 쓰면 나눗셈을 곱셈 + 시프트로 대체할 수 있음 weight는 nice 값의 상대 비율을 담음 확대
그림: cfs_rq와 sched_entity의 관계, 그리고 weight 계산 구조(개념도, 값은 예시). load.weight는 nice 값에 대응하는 상대 비율이고, inv_weight는 나눗셈을 곱셈으로 바꾸기 위한 역수입니다. my_q가 NULL이면 개별 태스크, 아니면 그룹 엔티티입니다.
inv_weight 최적화: calc_delta_fair()에서 delta * NICE_0_LOAD / weight 나눗셈을 delta * NICE_0_LOAD * inv_weight >> 32 곱셈+시프트로 변환합니다. 이는 스케줄러 hot path의 성능에 영향을 줍니다(정도는 워크로드·하드웨어에 따라 달라짐).

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);
}
vruntime 정규화/비정규화: CPU 간 마이그레이션 시 vruntime은 상대적 값으로 변환(비정규화)되어 전송되고, 도착 CPU에서 해당 CPU의 min_vruntime을 기준으로 다시 절대값(정규화)됩니다. 이로써 서로 다른 CPU의 min_vruntime 차이에 의한 불공정을 방지합니다.

핵심 호출 체인: 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 콜백(Callback)입니다. 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 레지스터(Register), 스택 포인터, TLB를 전환합니다. switch_mm()으로 주소 공간(Address Space)을, 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()로 설정됩니다. 동일한 캐시 라인(Cache Line) 데이터를 공유하는 패턴(producer-consumer)에서 캐시 친화성을 높입니다.
  • 36행clear_buddies(): 선택된 엔티티와 일치하는 skip/next/last 힌트를 NULL로 초기화합니다. 힌트는 일회성이므로 사용 후 즉시 제거합니다.

place_entity() 상세 분석

place_entity()는 태스크가 처음 생성되거나 슬립에서 깨어날 때 vruntime을 공정하게 초기화합니다. 너무 작은 vruntime은 다른 태스크를 장시간 기아(Starvation) 상태로 만들 수 있기 때문에 적절한 패널티/보상 메커니즘이 필요합니다.

/* 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을 가진 태스크가 역으로 보상을 받는 일을 방지합니다.
try_to_wake_up() 또는 fork() enqueue_task_fair() sched_class 디스패치 enqueue_entity() for_each_sched_entity 순회 update_curr() vruntime += calc_delta_fair() place_entity() avg_vruntime - lag __enqueue_entity() RB-tree에 삽입 pick_next_entity() 다음 schedule() 시점 pick_eevdf() eligible 중 최소 deadline place_entity()의 계산 순서 1. slice가 지정되지 않았으면 sysctl_sched_base_slice를 쓰고, vslice = calc_delta_fair(slice, se)로 환산 2. PLACE_LAG: se->vlag를 보존하도록 새 엔티티가 평균에 주는 영향을 보정 3. vruntime = avg_vruntime(cfs_rq) - lag 로 배치 (lag이 음수면 앞자리) 엔티티는 vruntime이 아니라 deadline을 기준으로 RB-tree에 정렬됨
그림: 대기열 삽입 경로와 place_entity() 계산(Linux v7.3.1). 6.5 이하 CFS의 min_vruntime·-thresh/2 보정 대신, EEVDF는 avg_vruntime()과 lag 보존으로 배치합니다. 삽입 후 선택은 다음 schedule()에서 pick_eevdf()가 수행합니다.

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_rqenqueue_entity()에서 1로 설정, dequeue_entity()에서 0으로 설정됩니다. 현재 실행 중인 태스크(cfs_rq->curr)는 RB-tree에 없지만 on_rq=1입니다.
  • 11행 exec_startupdate_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_vruntimeupdate_min_vruntime()이 호출될 때마다 단조 증가합니다. 새 태스크나 슬립 복귀 태스크의 place_entity()에서 기준점으로 사용되어, 이미 오래 실행된 태스크들과 같은 출발선에 두는 역할을 합니다.
  • 12행 avg_vruntimeEEVDF에서 eligible 판정 기준선입니다. 모든 태스크의 vruntime 가중 평균으로, se->vruntime <= avg_vruntime이면 eligible(CPU를 덜 받은 상태)로 판정합니다.
  • 15행 tasks_timelinerb_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를 효율적으로 업데이트합니다.
struct cfs_rq min_vruntime 실행 기준선(단조 증가) avg_vruntime eligible 판정 기준 V load.weight 모든 엔티티 weight의 합 nr_running 실행 가능한 엔티티 수 curr 현재 실행 중인 엔티티 next 우선 후보 힌트(PICK_BUDDY) tasks_timeline RB-tree 루트(rb_root_cached) runtime_remaining 대역폭 쿼터 잔량 tg 소속 task_group avg.util_avg 런큐 PELT 이용률 struct sched_entity vruntime RB-tree 정렬 키 deadline EEVDF 우선순위 기준 slice 요청 슬라이스(기본 base_slice) vlag 가상 lag(배치 시 보존) exec_start delta 계산 시작 시각 sum_exec_runtime 누적 실행 시간 load.weight nice → weight on_rq 런큐 포함 여부 my_q 그룹 엔티티의 하위 cfs_rq avg.util_avg 태스크 PELT 이용률 update_curr() → vruntime, exec_start, sum_exec_runtime, cfs_rq->avg_vruntime __enqueue_entity() → tasks_timeline, deadline / throttle_cfs_rq() → runtime_remaining skip·last 힌트 필드는 7.x에서 제거되고 next(PICK_BUDDY)만 남음
그림: 두 구조체의 핵심 필드와 역할(Linux v7.3.1). 각 필드는 대표적인 갱신 경로를 함께 표시했습니다. EEVDF 전용 필드(deadline, slice, vlag)는 6.6 이후 동작 기준입니다.

컨텍스트 스위치 내부 구조

CFS가 선점 결정을 내린 후 실제 태스크 교체는 context_switch()에서 이루어집니다. 이 함수는 메모리 공간 전환과 CPU 레지스터 교체라는 두 가지 핵심 작업을 수행합니다.

context_switch(rq, prev, next) core.c, rq 잠금을 잡은 상태 런큐 잠금을 보유한 채 호출되며, 반환 시에는 잠금이 풀린 상태 prepare_task_switch() rq->prev = prev, prev->on_cpu = 0 prev의 on_cpu를 내려 다음 실행을 허용 성능 이벤트용 task switch 훅도 호출 arch_start_context_switch(prev) 아키텍처 준비 훅 paravirt 환경에서 switch_to()와 짝을 이뤄 페이지 테이블 교체를 한 번의 호출로 처리 switch_mm_irqs_off(prev->mm, next->mm) 주소 공간 교체 (CR3, TLB) mm이 같으면 재적재를 생략하고, 커널 스레드는 lazy TLB로 이전 mm을 유지 switch_to(prev, next, prev) 레지스터·스택 전환 x86_64는 __switch_to_asm()이 RSP를 바꾸고 저장된 복귀 주소로 점프 finish_task_switch(prev) prev 정리, 통계 마무리 next의 스택에서 이어 실행되며, prev의 mm 해제·rq->curr 갱신을 마무리
그림: 컨텍스트 스위치의 내부 순서(Linux v7.3.1, x86_64 기준). 주소 공간 교체는 mm이 다를 때만 일어나고, 커널 스레드는 직전 mm을 lazy TLB로 재사용합니다. 실제 레지스터·스택 전환은 switch_to()가 담당합니다.

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() — 페이지 테이블(Page Table) 전환

x86_64에서 페이지 테이블 전환은 CR3 레지스터를 새 태스크의 PGD(Page Global Directory) 물리 주소(Physical Address)로 덮어쓰는 것으로 이루어집니다. CR3 쓰기는 자동으로 TLB 전체 플러시(Flush)를 유발합니다(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);
}
Lazy TLB 모드: 커널 스레드(Kernel Thread)는 사용자 공간 주소를 접근하지 않으므로 페이지 테이블 전환이 불필요합니다. enter_lazy_tlb()는 현재 CPU를 해당 mm의 cpu_tlbstate에서 제거해, 다른 CPU의 TLB 무효화 IPI 대상에서 빠지게 합니다. 이로써 커널 스레드 전환 시 불필요한 TLB 플러시 비용을 절감합니다.

switch_to() — CPU 레지스터 저장/복원

x86_64에서 switch_to()는 어셈블리(Assembly) 매크로로 구현된 __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)에 대한 정리를 수행합니다.

/* kernel/sched/core.c — 간략화 */
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);
}
'
schedstat 확인: /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이면 정상
7NUMA 미스/proc/vmstat (numa_miss)numa_hit의 5% 이하
8PELT 부하/proc/PID/sched (avg)워크로드 기대치와 일치
CFS 스케줄러 관측 도구 체계 kernel/sched/fair.c /proc 인터페이스 /proc/PID/sched 태스크 통계 · /proc/sched_debug 런큐 상태 · /proc/schedstat CPU별 통계 ftrace 트레이스포인트 sched_switch 컨텍스트 스위치 · sched_wakeup 깨우기 · sched_migrate_task 마이그레이션 perf sched perf sched record(기록) · latency(지연 분석) · timehist(시간순 타임라인) bpftrace / BPF 런큐 지연 히스토그램 · vruntime 분포 추적 · kprobe 기반 실시간 분석 cgroup 통계 cpu.stat 스로틀링 횟수·시간 · cpu.pressure PSI 지표
그림: CFS 스케줄러를 관측하는 다섯 갈래 도구. 커널 내부 상태(/proc)와 실행 흐름(ftrace, perf, BPF), 그룹 단위 자원 사용(cgroup)을 층위별로 나누어 봅니다.

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 모니터링 등)

스케줄러 튜닝 파라미터 상세

스케줄러 동작을 조정하는 파라미터를 기본값·범위·효과까지 정리합니다.

버전 기준: 이 절은 Linux 6.6 이후 EEVDF 기준입니다(v7.3.1 소스 확인). 6.5 이하 CFS에서 쓰던 sched_latency_ns, sched_min_granularity_ns, sched_wakeup_granularity_ns, sched_child_runs_first는 7.x의 kernel/sched/에 존재하지 않습니다. 6.5 이하 기준 표는 타임슬라이스와 지연시간 절에 남아 있습니다.
노출 위치: base_slice_ns, migration_cost_ns, nr_migrate, tunable_scaling, features는 debugfs(/sys/kernel/debug/sched/)에 있습니다. /proc/sys/kernel에 남은 스케줄러 항목은 sched_cfs_bandwidth_slice_us(기본 5,000us)뿐입니다.

핵심 스케줄러 파라미터

파라미터기본값범위·클램프효과
sched_base_slice700,000 ns (0.7ms)u32(최대 약 4.29s), CPU 수에 따라 스케일요청 슬라이스 기본값. 늘리면 처리량·응답 지연 증가, 줄이면 응답성·전환 증가
sched_runtime (sched_setattr)0 (미지정이면 base_slice)100,000~100,000,000 ns (100us~100ms)태스크별 슬라이스 요청. se->custom_slice = 1
sched_migration_cost500,000 ns (0.5ms)0이면 캐시 핫 판정 비활성, u32 최대값이면 이동 회피마이그레이션 비용 추정. 키우면 캐시 친화성 향상, 부하 분산 지연
sched_nr_migrate32 (PREEMPT_RT 8)u32, 밸런싱 시 busiest->nr_running과 min한 번의 밸런싱에서 이동할 최대 태스크 수
sched_tunable_scaling1 (log2)0/1/2 (none/log/linear)CPU 수에 따른 base_slice 자동 스케일 방식
sched_features빌드 기본값플래그 문자열, NO_ 접두사로 비활성스케줄러 기능 런타임 토글

워크로드별 튜닝 프로파일

아래 값은 출발점 예시입니다. 슬라이스를 늘리면 처리량이, 줄이면 응답성이 유리해지는 방향만 참고하고, 최종 값은 대상 시스템에서 측정해 결정합니다.

## 프로파일 1: 대화형 데스크톱 (최소 지연)
echo 500000 > /sys/kernel/debug/sched/base_slice_ns        # 0.5ms
echo 250000 > /sys/kernel/debug/sched/migration_cost_ns   # 0.25ms

## 프로파일 2: 처리량 최적화 서버 (HPC/배치)
echo 3000000 > /sys/kernel/debug/sched/base_slice_ns        # 3ms
echo 5000000 > /sys/kernel/debug/sched/migration_cost_ns   # 5ms
echo 128 > /sys/kernel/debug/sched/nr_migrate               # 대량 마이그레이션

## 프로파일 3: 데이터베이스 서버 (균형)
echo 1200000 > /sys/kernel/debug/sched/base_slice_ns        # 1.2ms
echo 1000000 > /sys/kernel/debug/sched/migration_cost_ns   # 1ms

## 프로파일 4: 가상화 호스트 (VM 간 공정성)
echo 2000000 > /sys/kernel/debug/sched/base_slice_ns        # 2ms
echo 500000 > /sys/kernel/debug/sched/migration_cost_ns    # 0.5ms (기본값)
파라미터(fair.c 변수) 값을 줄이면 값을 늘리면 기본값 sysctl_sched_base_slice 짧은 슬라이스: 응답성 향상 긴 슬라이스: 처리량 향상 0.7 ms se->slice 요청 단위 컨텍스트 스위치 증가 응답 지연 증가 sysctl_sched_migration_cost 적극적 이동: 부하 균형 이동 억제: 캐시 친화 0.5 ms 마이그레이션 비용 추정 캐시 미스 증가 분산 반응 지연 sysctl_sched_nr_migrate 소량 이동: 균형이 느림 대량 이동: 균형이 빠름 32 한 번의 밸런싱 이동 수 롱테일 실행 편차 긴 런큐 잠금 구간 레거시 파라미터: sched_latency_ns · sched_min_granularity_ns · sched_wakeup_granularity_ns EEVDF 도입(6.6) 이후 제거되었고, 슬라이스는 sched_base_slice로 통합되었습니다. 대화형 / 데스크톱 base_slice를 짧게(0.3~0.5ms) migration_cost를 낮게 → 응답 지연 최소화 GUI · 게임 · 오디오 처리 스위치 비용 증가를 감수 처리량 / 서버 base_slice를 길게(2~4ms) migration_cost를 높게 → 스위치·캐시 미스 감소 HPC · 배치 · 웹 서버 응답 지연 증가를 감수
그림: 값 방향에 따른 트레이드오프와 프로파일 예시(Linux v7.3.1). 기본값과 조정 폭은 출발점이며, 실제 값은 워크로드와 하드웨어에서 측정해 결정합니다. EEVDF 전환으로 사라진 레거시 파라미터를 별도로 표시했습니다.

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로 지연시간 변화를 측정하세요. base_slice_ns를 너무 낮추면 슬라이스 소진이 잦아져 컨텍스트 스위치 오버헤드가 실행 시간보다 커질 수 있습니다.

sched_tunable_scaling 자동 스케일링

sched_tunable_scaling은 CPU 수가 증가할 때 CFS 파라미터를 자동으로 조정합니다.

값모드스케일링 공식설명
0비활성factor = 1수동 설정한 값을 그대로 사용
1log2factor = 1 + ilog2(min(N, 8))기본값. CPU 수가 늘수록 완만하게 증가(최대 4)
2선형factor = min(N, 8)CPU 수에 비례(최대 8). 대규모 시스템용
# 현재 스케일링 모드 확인 (debugfs)
cat /sys/kernel/debug/sched/tunable_scaling
# 1 (log2 모드)

# CPU 8개 이상 + log2 모드일 때: factor = 1 + ilog2(8) = 4
# sched_base_slice = 0.7ms × 4 = 2.8ms

# 수동 튜닝 시 자동 스케일링 비활성화 권장
echo 0 > /sys/kernel/debug/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 정의가 있습니다. 커스텀 기능 플래그를 추가하거나 기존 플래그의 기본값을 변경하려면 이 파일을 수정하고 커널을 재컴파일해야 합니다.

활용도 고정(Utilization Clamping, uclamp)

활용도 고정(Utilization Clamping, uclamp)은 태스크와 cgroup이 "최소한 이만큼의 성능은 보장해 달라"와 "이 이상은 쓰지 말아 달라"는 두 가지 힌트를 스케줄러에 전달하는 인터페이스입니다. nice 값이 태스크 사이의 상대 비중만 조절하는 것과 달리, uclamp는 CPU 용량(Capacity) 대비 절대적인 요구 수준을 표현합니다. Linux 5.3에서 도입되었고, 태스크 단위 sched_setattr(2) 인터페이스와 cgroup v2의 cpu.uclamp.min/cpu.uclamp.max를 함께 제공합니다.

왜 필요한가: PELT 활용도의 한계

PELT로 계산한 util_avg는 "과거에 얼마나 바빴는가"의 지수 감쇠 평균입니다. 따라서 다음과 같은 요구를 표현하지 못합니다.

uclamp는 이 요구를 UCLAMP_MIN/UCLAMP_MAX 두 개의 스칼라(0~1024)로 표현하고, 에너지 인지 스케줄링(Energy Aware Scheduling, EAS)의 util_fits_cpu() 판정과 effective_cpu_util()의 주파수 계산에 반영합니다.

두 개의 값과 유효값 계산

값의미주요 용도
UCLAMP_MIN(0)필요한 최소 CPU 용량지연 민감 태스크의 주파수/코어 승격, 소형 코어 배치 회피
UCLAMP_MAX(1)허용하는 최대 CPU 용량전력/발열 상한, 백그라운드 작업의 주파수 제한

값은 세 계층을 거쳐 유효값으로 정해집니다. 우선순위가 높은(더 제한적인) 쪽이 최종적으로 적용됩니다.

  1. 태스크 요청 — task_struct.uclamp_req[UCLAMP_CNT]. sched_setattr(2)로 설정합니다.
  2. cgroup 제한 — task_group.uclamp[UCLAMP_CNT]. 태스크 요청값을 [tg_min, tg_max] 범위로 clamp()합니다. 루트 태스크 그룹과 autogroup 소속 태스크에는 적용되지 않습니다.
  3. 시스템 기본값 — uclamp_default[UCLAMP_CNT]. kernel.sched_util_clamp_min/max sysctl로 정해지며, 요청값이 이 상한을 넘으면 상한으로 잘립니다.

계산된 유효값은 task_struct.uclamp[UCLAMP_CNT]에 캐시되고, 태스크가 런큐에 등록될 때 버킷(struct uclamp_bucket)에 참조 카운트(Reference Count)로 집계됩니다. 런큐의 struct uclamp_rq는 실행 가능한 태스크들의 값을 MAX 집계하므로, uclamp.min이 높은 태스크가 하나라도 깨어 있으면 그 CPU 전체가 그만큼의 성능을 요구합니다.

설정 입력 유효값 계산 스케줄링 반영 태스크 요청 sched_setattr() + uclamp_req[] cgroup 제한 cpu.uclamp.min / max 시스템 기본값 sched_util_clamp_min / max uclamp_eff_get() 태스크 요청을 cgroup 범위로 clamp 시스템 상한 적용 런큐 집계 (MAX) uclamp_rq.bucket / value 배치 · 주파수 결정 util_fits_cpu() / effective_cpu_util() 런큐 값은 실행 가능 태스크 중 가장 큰 요구를 따릅니다. min이 높으면 해당 CPU는 overutilized로 판정될 수 있습니다.
그림: uclamp 유효값 계산과 반영 경로. 태스크 요청은 cgroup 범위로 잘린 뒤 시스템 상한이 다시 적용되고, 런큐에는 실행 가능한 태스크 값의 최댓값으로 집계됩니다. 이 값이 태스크-코어 적합성 판정과 주파수 계산의 입력이 됩니다.

인터페이스

대상인터페이스값 형식
태스크sched_setattr(2) + sched_util_min/sched_util_max0~1024 정수. SCHED_FLAG_UTIL_CLAMP_MIN/_MAX(0x20/0x40)로 지정
cgroup v2cpu.uclamp.min, cpu.uclamp.max백분율 0.00~100.00 또는 max
시스템kernel.sched_util_clamp_min, sched_util_clamp_max, sched_util_clamp_min_rt_default0~1024 정수

세 인터페이스 모두 CONFIG_UCLAMP_TASK가 필요하고, cgroup 파일은 CONFIG_UCLAMP_TASK_GROUP이 추가로 필요합니다. cgroup v2의 cpu.uclamp.* 파일은 루트 cgroup에는 생성되지 않습니다(CFTYPE_NOT_ON_ROOT).

태스크 단위 설정

#define _GNU_SOURCE
#include <linux/sched/types.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <string.h>

int set_uclamp_min(pid_t pid, unsigned int util_min)
{
    struct sched_attr attr;

    memset(&attr, 0, sizeof(attr));
    attr.size = sizeof(attr);
    attr.sched_flags = SCHED_FLAG_KEEP_PARAMS | SCHED_FLAG_UTIL_CLAMP_MIN;
    attr.sched_util_min = util_min;   /* 0 ~ 1024 */

    return syscall(SYS_sched_setattr, pid, &attr, 0);
}
코드 설명
  • 13행SCHED_FLAG_KEEP_PARAMS는 정책(Policy)과 우선순위를 그대로 두고 파라미터만 갱신하라는 뜻입니다. SCHED_FLAG_UTIL_CLAMP_MIN을 함께 지정하면 sched_util_min만 반영되고 sched_util_max는 변경되지 않습니다.
  • 14행0은 min을 해제(요구 없음), 1024는 CPU 용량 100%를 요구한다는 뜻입니다. util_min이 util_max보다 크면 커널이 유효값을 보정합니다.
  • 16행다른 태스크의 스케줄링 파라미터를 바꾸려면 CAP_SYS_NICE가 필요합니다. 같은 사용자 소유 프로세스이거나 자기 자신이면 권한 없이도 호출할 수 있습니다.

cgroup v2 설정

# cgroup v2 (unified hierarchy) 기준
mkdir -p /sys/fs/cgroup/latency
echo 50.00 > /sys/fs/cgroup/latency/cpu.uclamp.min
echo 80.00 > /sys/fs/cgroup/latency/cpu.uclamp.max
cat /sys/fs/cgroup/latency/cpu.uclamp.min

# 대상 프로세스를 그룹으로 이동
echo $$ > /sys/fs/cgroup/latency/cgroup.procs

cgroup 값은 구성원 태스크의 상한/하한 범위로 동작합니다. 예를 들어 cpu.uclamp.min=50.00이면 구성원이 요청한 min 값은 512 미만으로 내려가지 않고, cpu.uclamp.max=80.00이면 819를 넘지 않습니다. 값은 백분율로 쓰지만 커널 내부에서는 percent << SCHED_CAPACITY_SHIFT를 UCLAMP_PERCENT_SCALE로 나눈 0~1024 스케일로 변환됩니다.

시스템 기본값

sysctl기본값의미
kernel.sched_util_clamp_min1024태스크가 요청할 수 있는 min의 시스템 상한. 1024면 제한 없음
kernel.sched_util_clamp_max1024태스크가 요청할 수 있는 max의 시스템 상한
kernel.sched_util_clamp_min_rt_default1024별도 uclamp 요청이 없는 RT 태스크에 적용할 기본 min

기본값이 모두 1024라는 것은 "아무 제한도 걸지 않는다"는 뜻입니다. 따라서 uclamp는 명시적으로 설정하기 전까지 스케줄링에 영향을 주지 않습니다.

주의사항과 트러블슈팅

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 Node 0 — 이동 전 CPU 0 CPU 1 로컬 메모리 — 접근 ≈100ns Task P CPU 0에서 실행 중 이동 NUMA Node 1 — 이동 후 CPU 2 CPU 3 로컬 메모리 — 접근 ≈100ns Task P CPU 2에서 실행 numa_faults[P] 집계 — 노드별 폴트 가중치 Node 0 120 (30%) Node 1 280 (70%) — 선호 노드 폴트 비율이 높은 노드를 선호 노드로 선택 가중치는 폴트 시점의 지역성을 반영 1. 페이지 테이블 접근 비트를 클리어 → 다음 접근에서 NUMA 힌트 폴트 발생 2. 폴트를 누적·감쇠해 numa_faults 갱신 → 선호 노드를 다시 계산 3. 불균형이 임계를 넘으면 태스크를 선호 노드로 마이그레이션
그림: 폴트 기반 NUMA 밸런싱(Linux v7.3.1). 접근 시각(≈100ns)은 로컬 DRAM 기준의 개념값이며, 실제 지연은 토폴로지에 따라 달라집니다. 폴트 비율이 높은 노드로 태스크를 옮기면 원격 접근이 줄어듭니다.

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
NUMA 밸런싱 비활성화 시나리오: 다음 경우에는 NUMA 밸런싱을 비활성화하는 것이 유리합니다:
  • 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);
    }
}
성능 검증: NUMA 밸런싱 효과를 측정하려면 perf stat -e 'sched:sched_move_numa' -a sleep 60으로 마이그레이션 횟수를, numastat -m으로 노드별 메모리 분포를 확인하세요. numa_miss 대비 numa_hit 비율이 높아져야 효과가 있습니다.

NUMA 밸런싱 파라미터 종합

파라미터경로기본값설명
numa_balancing/proc/sys/kernel/1NUMA 밸런싱 전체 활성화/비활성화
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 힌트 폴트 오버헤드: NUMA 밸런싱은 의도적으로 페이지 폴트를 유발하므로 오버헤드가 존재합니다. 메모리 사용량이 적은 태스크(수십 MB 이하)에서는 마이그레이션 이득보다 폴트 처리 비용이 클 수 있습니다. numa_balancing_scan_period_min_ms를 높이거나, 해당 태스크에 numactl --interleave를 사용하여 균등 분배하는 것이 효율적입니다.

실전 트러블슈팅 사례

CFS 스케줄러와 관련된 실제 운영 환경의 문제를 진단하고 해결하는 방법을 살펴봅니다.

사례 1: Kubernetes CPU 스로틀링(Throttling) 디버깅

증상: Kubernetes 파드(Pod)의 응답 지연이 산발적으로 급증하고, 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: 대화형 애플리케이션 지연 스파이크 분석

증상: 대화형 서비스(웹서버, 게임 서버 등)에서 p99 지연이 평균 대비 10배 이상 치솟는 현상이 간헐적으로 발생합니다.

원인: 배치 작업(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 대역폭 과도한 스로틀링

증상: cgroup 내 모든 태스크가 주기적으로 수십 ms 동안 완전히 정지합니다. CPU 사용률은 낮은데도 스로틀링이 발생합니다.

원인: 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가 더 균등하게 분배되지만 락 경합 약간 증가
일반 원칙: 스로틀링이 5% 이상이면 운영 환경에서 문제가 됩니다. quota를 설정할 때는 nr_throttled / nr_periods 비율을 모니터링하고, 이 값이 5% 이하가 되도록 quota와 burst를 조정하세요. 가장 근본적인 해결책은 실제 CPU 사용 패턴을 프로파일링(Profiling)해 적정 request/limit 값을 찾는 것입니다.

사례 5: EEVDF 환경에서의 꼬리 지연(Tail Latency) 튜닝

Linux 6.6 이후 EEVDF로 전환하면서 기존 CFS의 sched_wakeup_granularity_ns 튜닝이 무효화되어, 일부 대화형 워크로드에서 p99 지연이 증가하는 사례가 보고됩니다. EEVDF에서는 태스크의 slice 값이 deadline을 결정하므로, slice를 통한 명시적 제어가 필요합니다.

# 1. 증상 확인 — wakeup latency 측정 (perf sched timehist)
perf sched record -- sleep 10
perf sched timehist --summary | awk '$NF > 1.0 {print}'
# wait time이 1ms 이상인 태스크 식별

# 2. EEVDF 기본 slice 확인 (Linux 6.6+ 노출)
cat /sys/kernel/debug/sched/base_slice_ns
# 정규화 기본값 750000 (0.75ms). CONFIG_SCHED_TUNABLE_SCALING(LOG) 시
# 2CPU=1.5ms, 4CPU=2.25ms, 8CPU=3ms 로 CPU 수에 따라 스케일되어 표시됨
# 6.15+ 정규화 기본값은 700000 (0.7ms)으로 축소

# 3. 대화형 태스크에 대해 slice를 명시적으로 짧게 설정 (Linux 6.14+)
#    sched_setattr(2)의 sched_runtime이 EEVDF slice로 해석됨 (0.1ms~100ms)
chrt --other --sched-runtime 500000 -p $PID
# 500us slice → 더 이른 deadline → 더 빠른 선점

# 4. DELAY_DEQUEUE 기능 확인 (Linux 6.12+) — 슬립 직전 태스크 lag 보존
cat /sys/kernel/debug/sched/features | grep DELAY_DEQUEUE
# DELAY_DEQUEUE 활성: 짧게 슬립하는 태스크의 vruntime 패널티 완화

# 5. PREEMPT_LAZY (Linux 6.13+) — full preemption 없이 응답성 확보
cat /sys/kernel/debug/sched/preempt
# none / voluntary / full / lazy 중 lazy 선택 시 처리량 손실 최소화
핵심 포인트: EEVDF는 deadline 기반이므로 slice가 짧을수록 우선 선택될 확률이 높아집니다. 다만 너무 짧으면 컨텍스트 스위치 오버헤드가 증가하므로 cs/s(컨텍스트 스위치율)를 함께 모니터링해야 합니다. vmstat 1의 cs 컬럼이 평소보다 2배 이상 증가하면 slice가 과도하게 짧다는 신호입니다.

CFS는 Linux 6.6에서 EEVDF로 대체된 이후에도 계속 진화하고 있습니다. kernel/sched/fair.c는 여전히 동일 파일이지만, 알고리즘 동작과 sysctl/디버그 인터페이스가 크게 변경되었습니다. 2026년 4월 Linux 7.0이 출시되며 sched_ext가 핵심 인프라로 자리잡았습니다. 운영 환경에서 신구 커널을 함께 다루려면 다음 변경 사항을 숙지해야 합니다.

제거/추가된 튜닝 인터페이스

인터페이스도입/제거 버전설명
kernel.sched_wakeup_granularity_ns6.6에서 제거EEVDF는 deadline 비교로 wakeup preemption을 결정하므로 불필요해졌습니다.
kernel.sched_latency_ns6.6에서 제거고정 latency 윈도 개념이 사라졌습니다. base_slice로 대체됩니다.
kernel.sched_min_granularity_ns6.6에서 제거최소 granularity 개념도 base_slice가 흡수했습니다.
/sys/kernel/debug/sched/base_slice_ns6.6 추가EEVDF의 기본 slice 값(태스크별 sched_runtime 미설정 시 사용). 정규화 기본 750us(6.14 이하) / 700us(6.15+).
/sys/kernel/debug/sched/features — DELAY_DEQUEUE6.12+ 추가슬립 직전 태스크를 즉시 dequeue하지 않고 lag를 보존하여 wake-up 시 공정성을 향상합니다.
/sys/kernel/debug/sched/preempt — lazy 모드6.13+ 추가PREEMPT_LAZY: voluntary와 full 사이의 절충점. SCHED_NORMAL은 협력적, RT는 즉시 선점합니다.
sched_attr.sched_runtime6.14+ 의미 변경EEVDF의 태스크별 slice(요청 크기 r_i)로 해석됩니다(6.14 이전에는 SCHED_DEADLINE 전용). 유효 범위 0.1ms~100ms.

DELAY_DEQUEUE — 짧은 슬립 태스크의 공정성 보존

Peter Zijlstra가 Linux 6.12에 머지한 기능으로, 태스크가 슬립할 때 즉시 RB-tree에서 제거하는 대신 lag 정보를 유지합니다. 이를 통해 대화형 워크로드가 짧게 슬립한 뒤 깨어날 때 vruntime이 과도하게 보상되어 다른 태스크를 부당하게 선점하는 문제를 완화합니다.

/* kernel/sched/fair.c (Linux 6.12+, 단순화) */
static bool dequeue_entity(struct cfs_rq *cfs_rq,
                            struct sched_entity *se, int flags)
{
    if (sched_feat(DELAY_DEQUEUE) && (flags & DEQUEUE_SLEEP) &&
        entity_eligible(cfs_rq, se)) {
        /* eligible 상태로 슬립 → 실제 제거를 지연시켜 lag 보존 */
        se->sched_delayed = 1;
        return false;
    }
    /* ... 기존 dequeue 경로 ... */
    __dequeue_entity(cfs_rq, se);
    return true;
}
관측 방법: cat /sys/kernel/debug/sched/features로 활성 여부를 확인하고, echo NO_DELAY_DEQUEUE > /sys/kernel/debug/sched/features로 비활성화하여 워크로드 차이를 비교 측정할 수 있습니다.

PREEMPT_LAZY — 처리량과 지연의 절충

Linux 6.13에서 메인라인에 들어온 새로운 선점 모델입니다. 기존 모델은 다음 4가지였습니다.

PREEMPT_LAZY는 SCHED_NORMAL 태스크에 대해서는 협력적(voluntary)으로 동작하고, SCHED_FIFO/SCHED_RR/SCHED_DEADLINE 같은 RT 태스크에 대해서는 즉시 선점하는 하이브리드입니다. 처리량 손실 없이 RT 응답성을 확보하려는 방향으로 주목받고 있지만, 이를 일반 서버 커널의 확정된 새 기본값으로 단정하기는 어렵습니다. 실제 선택은 커널 설정과 배포판 정책에 좌우됩니다.

PREEMPT_VOLUNTARY 협력적 선점 모델 SCHED_NORMAL cond_resched() 지점에서만 전환 RT 태스크 즉시 선점되지 않음 → 응답 지연이 ms급 + 처리량 우수 - RT 응답성 부족 서버 처리량 중심 워크로드 PREEMPT_LAZY 처리량과 지연의 절충 (6.13+) SCHED_NORMAL tick 선점 없이 필요 지점에서 전환 RT 태스크 깨우기 시 즉시 선점 → 짧은 지연 확보 + 처리량 유지 + RT 응답성 확보 6.13 도입 PREEMPT_FULL 즉시 선점 모델 SCHED_NORMAL 깨우기·tick 모두에서 선점 RT 태스크 즉시 선점 → 최소 지연 + 최저 지연 - 잦은 전환으로 처리량 손실 저지연·오디오·실시간 제어 측정 포인트(정성 비교) 응답 지연: VOLUNTARY ≫ LAZY ≈ FULL 처리량: VOLUNTARY ≈ LAZY > FULL 스위치 빈도·비용: FULL > LAZY ≈ VOLUNTARY 지터: VOLUNTARY가 가장 큼 도구: cyclictest(지연) · hackbench/schbench(처리량) · perf sched(전환)
그림: 선점 모델별 동작 차이(Linux v7.3.1). 부등호는 방향 비교이며 실제 수치는 커널 설정·하드웨어·워크로드에 따라 달라지므로 대상 시스템에서 측정해야 합니다. 어떤 모델을 기본으로 쓸지는 배포판·커널 설정이 결정합니다.

sched_ext — BPF 확장 스케줄러의 안정화

Linux 6.12에서 sched_ext(SCX)가 정식 머지되어 BPF 프로그램으로 스케줄러를 작성·교체할 수 있게 되었습니다. CFS/EEVDF를 우회하는 별도 스케줄러 클래스(ext_sched_class)로 동작하며, 런타임에 SCX 프로그램을 로드/언로드할 수 있습니다.

# sched_ext 활성화 여부 확인
cat /sys/kernel/sched_ext/state
# disabled / enabling / enabled 중 하나

# 현재 로드된 BPF 스케줄러 이름
cat /sys/kernel/sched_ext/root/ops

# scx_simple, scx_rusty, scx_lavd 등 참조 구현은 scx 저장소에서 제공
# https://github.com/sched-ext/scx
CFS와의 관계: sched_ext가 활성화되면 SCHED_NORMAL/SCHED_BATCH 태스크가 ext_sched_class로 이동하지만, SCX 프로그램이 종료되거나 에러로 abort되면 자동으로 EEVDF(fair_sched_class)로 폴백합니다. 따라서 CFS/EEVDF는 시스템의 안전망으로 항상 컴파일되어 있어야 합니다. 자세한 내용은 sched_ext — BPF 확장 스케줄러 문서를 참고하세요.

버전별 변경 요약

커널 버전주요 변경호환성 영향
2.6.23CFS 도입(O(1) 스케줄러 대체)—
3.8PELT 도입load 계산 정밀도 향상
5.14CFS Bandwidth burst 추가cpu.max.burst cgroupv2 인터페이스 추가
6.6EEVDF가 CFS 대체sched_latency/min_granularity/wakeup_granularity 제거
6.6base_slice_ns 디버그 인터페이스 노출, 기존 CFS sysctl(latency/min_granularity/wakeup_granularity) 제거EEVDF 기본 slice 조정 가능
6.12sched_ext 정식 머지, DELAY_DEQUEUE 기능 추가, PREEMPT_RT 메인라인 병합BPF 스케줄러 작성 가능, 짧은 슬립 공정성 향상, RT 전용 브랜치 유지 부담 감소
6.13PREEMPT_LAZY 메인라인, PREEMPT_RT가 독립 옵션(CONFIG_PREEMPT_RT)으로 분리, EEVDF 엔티티 배치 lag 핫픽스선점 모델과 RT 활성화가 직교 축으로 재구성 — PREEMPT_LAZY + PREEMPT_RT 조합 가능
6.14CFS 용어의 공식 폐기 — fair_sched_class는 유지하되 알고리즘은 EEVDF로 통일, sched_attr.sched_runtime으로 SCHED_OTHER 태스크 slice 직접 지정 추가, 이종 코어(P/E) NUMA 도메인 개선이전 CFS 튜너블(sched_latency_ns 등)은 더 이상 문서에 등장하지 않음. sched_setattr로 태스크별 slice(0.1~100ms) 제어 가능
6.15기본 base_slice 750us→700us 축소(여분 tick 방지), sched_ext 내부 이벤트 카운터 8종 노출, fair 계열 enqueue 경로의 cgroup 정합성 개선EEVDF + SCX 하이브리드 환경의 관측성 확보. 슬라이스 단축으로 태스크 전환 빈도 소폭 증가
6.16sched_ext scx_bpf_select_cpu_and() 추가, 아이들 CPU 선택 kfunc를 unlocked 컨텍스트에서 호출 허용, 다중 계층 스케줄러 기반 작업BPF 스케줄러의 토폴로지 인식 향상, 향후 계층형 SCX의 전조
6.17SMP 코드 무조건 컴파일(단일 프로세서 전용 #ifdef 전면 제거), proxy execution 초기 구현(mutex 보유 태스크에 waiter 우선순위 전파, CONFIG_EXPERT 필요), fair scheduler lag·slice parity 개선, sched_ext cgroup 대역폭 제어 인터페이스 추가스케줄러 코드 대폭 단순화. RT 우선순위 역전(Priority Inversion) 완화 첫 단계. sched_ext + cgroup 연동 기반 마련
6.18sched_ext cgroup 서브스케줄러 준비 — scx_bpf_cpu_curr()·scx_bpf_locked_rq() BPF 헬퍼 추가, cgroup 동기화를 scx_cgroup_rwsem → cgroup_lock/unlock()으로 전환, NUMA 밸런싱 'fairer' 개선, migrate_enable/disable 인라인 최적화BPF 스케줄러가 cgroup 계층을 안전하게 조회 가능, 향후 계층형 SCX의 기반 완성
6.19sched_ext eBPF fault recovery — SCX 프로그램 장애 시 자동 EEVDF 폴백 복구 시간을 밀리초 단위로 단축; NEXT_BUDDY 재도입(waker/wakee 캐시 친화성 기반 wakeup 선점), sched_avg fold se_weight() 누락 회귀 수정(schbench 성능 회복)운영 환경 SCX 안정성 대폭 향상. 대화형 wakeup latency 개선. 6.19 초기 schbench 회귀 신속 수정됨
7.0Linux 커널 주 버전 7.0 출시(2026-04-12) — RSEQ 기반 Time Slice Extension 추가(임계 구간 선점 방지, 기본 5µs~최대 50µs), 최신 아키텍처(x86_64/arm64/RISC-V 등)에서 선점 모델을 PREEMPT_FULL/LAZY 두 가지로 집약(PREEMPT_NONE 사실상 폐기), sched_ext GPU 인식·에너지 인식 추상화 준비PREEMPT_LAZY가 최신 아키텍처의 기본 선점 모델. PostgreSQL 등 스핀락(Spinlock) 집약 워크로드에서 성능 저하 보고(Huge Pages 활성화 시 미재현). RSEQ Time Slice Extension으로 임계 구간 선점 완화 가능. distro 커널(Ubuntu 26.04 LTS 등) 7.0 기반으로 전환 시작
7.1sched_ext cgroup 서브스케줄러 groundwork — 다중 계층 인스턴스 구조 재편, 아이들 SMT 시블링 선택 알고리즘 개선계층형 SCX 구현을 위한 내부 구조 재편; 외부 동작 변화 없음
운영 지침(2026 기준): 레거시 CFS 튜너블(sched_latency_ns, sched_min_granularity_ns, sched_wakeup_granularity_ns)을 전제로 한 튜닝 스크립트는 6.6 이후 무효이며, 6.14부터는 문서에서도 삭제되었습니다. 대신 /sys/kernel/debug/sched/base_slice_ns(EEVDF 기본 슬라이스), latency_nice(sched_attr), DELAY_DEQUEUE feature 스위치를 사용하세요. RT 워크로드가 있는 서버는 PREEMPT_LAZY + CONFIG_PREEMPT_RT 조합을 평가하는 것이 권장됩니다. 6.19 이후 sched_ext(SCX)를 운영 환경에 도입하는 경우 eBPF fault recovery가 자동 EEVDF 폴백을 보장하므로 안정성 리스크가 크게 줄었습니다. 7.0으로 업그레이드 시 최신 아키텍처에서 PREEMPT_NONE이 제거되었으므로, PostgreSQL 등 스핀락 집약 워크로드는 Huge Pages 활성화 여부를 확인하고 RSEQ Time Slice Extension 도입을 평가하세요. 7.0의 RSEQ 슬라이스 확장 기본값은 5µs이며 최대 50µs까지 설정 가능합니다.

6.6 이후 EEVDF 계열의 진화

6.6 EEVDF 병합 CFS 대체 6.12 DELAY_DEQUEUE sched_ext 정식화 6.13 PREEMPT_LAZY 지연 선점 모델 6.15 base_slice 700us 750us에서 하향 6.17 Proxy Execution rq->donor 도입 7.2 SCHED_CACHE LLC 인지 밸런싱 7.3 현재 안정 계열 v7.3.1 기준
그림: EEVDF 전환 이후 스케줄러 변경 흐름(6.6~7.3). 위아래 번갈아 배치해 라벨 간격을 확보했고, 각 항목은 병합된 주 버전과 핵심 변경을 나타냅니다.

프록시 실행(Proxy Execution) — Linux 6.17

우선순위 역전은 낮은 우선순위 태스크가 뮤텍스(Mutex)를 보유한 상태에서 높은 우선순위 태스크가 그 뮤텍스를 기다리며 블록될 때 발생합니다. 높은 우선순위 태스크는 뮤텍스를 기다리는 동안 런큐에서 빠지고, 정작 뮤텍스를 풀어 줄 낮은 우선순위 태스크는 CPU를 얻지 못해 시스템 전체가 느려집니다.

전통적인 해법은 우선순위 상속(Priority Inheritance, PI)입니다. rt_mutex가 소유자의 우선순위를 일시적으로 끌어올려 빨리 실행되게 합니다. 프록시 실행은 접근이 다릅니다. 소유자의 우선순위를 바꾸지 않고, 대기자의 스케줄링 컨텍스트(Scheduling Context)를 소유자에게 빌려주어 소유자가 대기자의 우선순위로 실행되게 합니다.

구분우선순위 상속(PI)프록시 실행(Proxy Execution)
변경 대상소유자의 우선순위(prio)를 상향실행에 사용할 스케줄링 컨텍스트를 교체
적용 범위rt_mutex 기반(RT 태스크, PI-futex 대기자)CFS를 포함한 일반 태스크
커널 표현rt_mutex_adjust_prio() → rt_mutex_setprio()로 task->prio 상향rq->donor, rq_set_donor()
부작용우선순위가 여러 단계 전파(Chain)될 수 있음실행 태스크와 스케줄링 주체가 분리됨
/* kernel/sched/sched.h — Linux v7.3.1 */
struct rq {
    /* ... */
    struct task_struct __rcu *curr;    /* 실제로 CPU를 점유한 태스크 */
    struct task_struct __rcu *donor;   /* 스케줄링 컨텍스트 제공자 */
    /* ... */
};

static inline void rq_set_donor(struct rq *rq, struct task_struct *t)
{
    rcu_assign_pointer(rq->donor, t);
}

/* kernel/sched/sched.h — CONFIG_SCHED_PROXY_EXEC */
DECLARE_STATIC_KEY_TRUE(__sched_proxy_exec);

static inline bool sched_proxy_exec(void)
{
    return static_branch_likely(&__sched_proxy_exec);
}
코드 설명
  • 4~5행curr는 CPU를 실제로 점유한 태스크, donor는 그 태스크가 실행될 수 있게 스케줄링 컨텍스트를 제공한 태스크입니다. 프록시 실행이 꺼져 있으면 항상 curr == donor입니다(CONFIG_SCHED_CORE의 force-idle 경로 제외).
  • 15행DECLARE_STATIC_KEY_TRUE는 컴파일 시 기본 활성을 뜻합니다. 즉 CONFIG_SCHED_PROXY_EXEC=y면 부팅 시 켜진 상태로 시작합니다.
  • 17~20행sched_proxy_exec()는 static key 조회만 하므로 분기 비용이 사실상 없습니다. 설정이 꺼진 커널에서는 컴파일러가 false로 상수 폴딩합니다.
활성화 조건과 관측:
  • Kconfig CONFIG_SCHED_PROXY_EXEC는 EXPERT에 의존하며, PREEMPT_RT와 SCHED_CLASS_EXT(sched_ext)가 꺼져 있어야 선택할 수 있습니다. 두 기능과의 상호작용이 아직 정리되지 않았기 때문입니다.
  • 부팅 파라미터 sched_proxy_exec=0(또는 1)으로 켜고 끌 수 있습니다. v7.3.1에는 debugfs 런타임 토글이 없습니다.
  • 스케줄링 회계는 donor 기준으로 이루어집니다(core.c의 "scheduler accounting goes to the donor task" 주석). 따라서 프록시 실행 중에는 CPU를 점유한 소유자가 아니라 컨텍스트를 빌려준 대기자의 실행 시간으로 집계됩니다.
  • v7.3.1에는 donor를 직접 노출하는 procfs/debugfs 인터페이스가 없습니다. 부팅 로그(dmesg)에 sched_proxy_exec enabled via boot arg 또는 disabled via boot arg 메시지가 남고, 이후 동작은 sched_switch 트레이스로 간접 관찰합니다.

캐시 인지 로드 밸런싱(SCHED_CACHE) — Linux 7.2

멀티코어 서버에서 같은 프로세스(같은 주소 공간)의 스레드가 서로 다른 LLC(Last Level Cache)에 흩어지면, 공유 데이터가 캐시 간에 복사되며 캐시 미스와 일관성 트래픽이 늘어납니다. CONFIG_SCHED_CACHE는 같은 프로세스의 태스크를 가능하면 하나의 LLC 도메인에 모으는 로드 밸런싱 정책입니다. Kconfig에서 default y, SMP 의존이므로 일반 멀티코어 커널에서는 기본으로 켜집니다.

/* kernel/sched/fair.c — Linux v7.3.1, CONFIG_SCHED_CACHE */
#define EPOCH_PERIOD                (HZ / 100)  /* 10 ms */
#define EPOCH_LLC_AFFINITY_TIMEOUT  5          /* 50 ms */

unsigned int llc_aggr_tolerance         = 1;
unsigned int llc_epoch_period           = EPOCH_PERIOD;
unsigned int llc_epoch_affinity_timeout = EPOCH_LLC_AFFINITY_TIMEOUT;
unsigned int llc_imb_pct                = 20;   /* 불균형 판정 % */
unsigned int llc_overaggr_pct           = 50;  /* 과집적 판정 % */

static inline bool sched_cache_enabled(void)
{
    return static_branch_unlikely(&sched_cache_active);
}
코드 설명
  • 2~3행정책은 에폭(Epoch) 단위로 동작합니다. 10ms마다 캐시 그룹의 선호 LLC를 다시 평가하고, 50ms(5 에폭) 동안 선호도가 유지되면 그때 실제 이동을 결정합니다. 순간적인 스케줄링 변동으로 태스크가 계속 이주하는 것을 막기 위한 히스테리시스(Hysteresis)입니다.
  • 5~9행llc_aggr_tolerance는 집적 허용 오차, llc_imb_pct는 LLC 간 불균형 판정 임계값(20%), llc_overaggr_pct는 한 LLC에 과도하게 모였다고 판정하는 임계값(50%)입니다. 값이 모두 __read_mostly라 debugfs에서 조정할 수 있습니다.
  • 11~14행sched_cache_present(커널이 지원)와 sched_cache_active(실제 사용)를 분리한 static key입니다. sched_cache_enabled()는 unlikely로 표시되어, 꺼진 상태에서 로드 밸런싱 핫패스에 부담을 주지 않습니다.

로드 밸런싱 쪽은 두 함수로 나뉩니다. record_sg_llc_stats()는 각 sched_group의 LLC 선호 통계를 sd_llc_shared에 기록하는데, CPU_NEWLY_IDLE 밸런싱에서는 건너뛰고 자식 도메인이 sd_llc인 경우(=여러 LLC를 가로지르는 도메인)에만 동작합니다. 실제 이동 판단을 하는 llc_balance()는 다음 조건을 모두 만족할 때만 참을 반환합니다.

관측과 조정 (root, CONFIG_SCHED_DEBUG):
# 기능 활성 여부와 파라미터
ls /sys/kernel/debug/sched/llc_balancing/
cat /sys/kernel/debug/sched/llc_balancing/enabled

# 집적 허용 오차와 불균형 임계값 조정
echo 0 > /sys/kernel/debug/sched/llc_balancing/aggr_tolerance
echo 30 > /sys/kernel/debug/sched/llc_balancing/imb_pct

# 기능 끄기 (기본 로드 밸런싱으로 복귀)
echo 0 > /sys/kernel/debug/sched/llc_balancing/enabled

같은 프로세스의 스레드가 여러 LLC에 흩어져 캐시 미스가 높다면(예: perf stat -e cache-misses와 /proc/<pid>/status의 Cpus_allowed_list를 함께 확인) aggr_tolerance를 낮춰 집적을 강하게 할 수 있습니다. 반대로 특정 LLC가 과부하되면 imb_pct를 낮춰 분산을 우선시합니다.

참고자료

커널 문서

LWN 기사

커널 소스

CFS와 EEVDF의 동작 원리를 더 깊이 이해하려면 다음 문서를 함께 읽는 것이 좋습니다.