4편까지는 스케줄러가 하는 일을 밖에서 들여다보기만 했다. 스케줄링 정책 자체를 바꾸려면 kernel/sched/를 고쳐 커널을 다시 빌드하고 재부팅해야 했고, 정책 하나 시험하는 데 빌드 주기가 통째로 들어갔다. 커널 6.12에 정식 합류한 sched_ext(CONFIG_SCHED_CLASS_EXT)는 스케줄러를 BPF 프로그램으로 구현해 실행 중에 붙였다 뗐다 할 수 있게 한다.
이 글에서는 전역 FIFO 하나로만 동작하는 최소 스케줄러를 직접 작성해 QEMU로 띄운 6.12 커널에 붙인다. 붙인 상태에서 CFS와 CPU 배분이 어떻게 달라지는지 측정하고, 타임슬라이스를 바꿔가며 컨텍스트 스위치 횟수를 세고, 마지막으로 일부러 고장 낸 스케줄러를 커널이 어떻게 쫓아내는지 확인한다.
이 시리즈의 다른 글
- eBPF 실전 (1) — libbpf와 CO-RE로 첫 커널 프로그램 작성하기
- eBPF 실전 (2) — BPF 맵과 링 버퍼: per-CPU, LRU, 유실, 맵 고정
- eBPF 실전 (3) — kprobe·fentry·tracepoint·uprobe 훅 오버헤드 비교
- eBPF 실전 (4) — 런큐 지연과 컨텍스트 스위치 관측하기
- eBPF 실전 (6) — 페이지 폴트·slab·메모리 누수·OOM 관측하기
실습 환경
sched_ext는 커널 6.12 이상과 CONFIG_SCHED_CLASS_EXT=y가 필요하다. 테스트에 쓴 우분투 호스트 커널은 6.8이라 sched_ext가 없어서, 6.12를 직접 빌드해 QEMU로 띄우고 작업 디렉터리를 9p로 공유했다.
# 6.12 커널: sched_ext + BPF 관련 옵션을 켜고 빌드
scripts/config --file $O/.config \
-e BPF_SYSCALL -e BPF_JIT -e DEBUG_INFO_BTF \
-e SCHED_CLASS_EXT -e SCHED_DEBUG
# QEMU로 부팅 (~/ebpf를 9p로 게스트에 그대로 노출)
qemu-system-x86_64 -machine q35 -cpu max -m 2048 -smp 2 \
-kernel build/arch/x86/boot/bzImage -initrd ir.cpio.gz \
-append "console=ttyS0 tsc=reliable clocksource=tsc" \
-virtfs local,path=$HOME/ebpf,mount_tag=ebpf,security_model=none \
-nographic -no-reboot게스트가 KVM 없이 TCG로 도는 환경이라 tsc=reliable clocksource=tsc를 주지 않으면 타이머가 튀어 스케줄링 측정값이 실행마다 뒤집힌다.
sched_ext 스케줄러의 골격
BPF 프로그램이 struct sched_ext_ops의 콜백을 채우면 커널이 스케줄링 결정을 그 함수들에게 물어본다. 태스크는 DSQ(dispatch queue)라는 큐에 들어가며, CPU마다 있는 로컬 DSQ와 직접 만든 공용 DSQ를 쓸 수 있다.
| 콜백 | 언제 불리는가 | 이 스케줄러가 하는 일 |
|---|---|---|
select_cpu | 태스크가 깨어날 때 | 유휴 CPU가 있으면 그 CPU의 로컬 DSQ로 바로 넣는다 |
enqueue | 실행 대기 상태가 될 때 | 공용 DSQ 하나에 FIFO로 줄 세운다 |
dispatch | CPU가 실행할 태스크를 찾을 때 | 공용 DSQ에서 하나 꺼낸다 |
init | 스케줄러가 붙을 때 | 공용 DSQ를 만든다 |
exit | 스케줄러가 내려갈 때 | 커널이 준 종료 사유를 기록한다 |
/* scx_min: 전역 FIFO 하나로 동작하는 최소 sched_ext 스케줄러 */
#include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
/* 로드 전에 유저 공간에서 정한다 */
const volatile __u64 slice_ns = SCX_SLICE_DFL; /* 기본 20ms */
const volatile bool no_dispatch; /* dispatch를 비워 두는 고장 모드 */
#define SHARED_DSQ 0
__u64 nr_local, nr_queued, nr_consumed;
__u32 exit_kind;
/* 깨어난 태스크를 어느 CPU에 올릴지 고른다. 유휴 CPU가 있으면 그 CPU의
* 로컬 DSQ로 바로 넣어 공용 큐를 거치지 않는다. */
s32 BPF_STRUCT_OPS(min_select_cpu, struct task_struct *p, s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) {
__sync_fetch_and_add(&nr_local, 1);
scx_bpf_dispatch(p, SCX_DSQ_LOCAL, slice_ns, 0);
}
return cpu;
}
/* 로컬로 못 간 태스크는 전부 공용 DSQ 하나에 FIFO로 줄 세운다.
* nice도 vtime도 보지 않는다. */
void BPF_STRUCT_OPS(min_enqueue, struct task_struct *p, u64 enq_flags)
{
__sync_fetch_and_add(&nr_queued, 1);
scx_bpf_dispatch(p, SHARED_DSQ, slice_ns, enq_flags);
}
/* CPU가 비면 공용 DSQ에서 하나 꺼내 준다. */
void BPF_STRUCT_OPS(min_dispatch, s32 cpu, struct task_struct *prev)
{
if (no_dispatch) /* 아무것도 꺼내지 않으면 어떻게 되는지 보기 위한 모드 */
return;
if (scx_bpf_consume(SHARED_DSQ))
__sync_fetch_and_add(&nr_consumed, 1);
}
s32 BPF_STRUCT_OPS_SLEEPABLE(min_init)
{
return scx_bpf_create_dsq(SHARED_DSQ, -1);
}
/* 커널이 스케줄러를 내릴 때 이유를 남긴다 */
void BPF_STRUCT_OPS(min_exit, struct scx_exit_info *ei)
{
exit_kind = ei->kind;
}
SEC(".struct_ops.link")
struct sched_ext_ops min_ops = {
.select_cpu = (void *)min_select_cpu,
.enqueue = (void *)min_enqueue,
.dispatch = (void *)min_dispatch,
.init = (void *)min_init,
.exit = (void *)min_exit,
.name = "min",
};slice_ns와 no_dispatch는 const volatile이라 로드 전에 유저 공간에서 값을 정한다 — 2편에서 본 대로 verifier가 쓰지 않는 분기를 잘라낸다.
로더와 빌드
스케줄러는 SEC(".struct_ops.link") 맵이므로 bpf_map__attach_struct_ops()로 붙인다. 링크를 닫으면 그 순간 커널이 원래 스케줄러로 되돌린다.
/* scx_min 로더: BPF 스케줄러를 커널에 붙이고 통계를 출력한다 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "scx_min.bpf.skel.h"
static volatile int exit_req;
static void sigint_handler(int sig)
{
exit_req = 1;
}
int main(int argc, char **argv)
{
struct scx_min *skel;
struct bpf_link *link;
int opt, secs = 0;
unsigned long long slice_us = 20000;
bool no_dispatch = false;
signal(SIGINT, sigint_handler);
signal(SIGTERM, sigint_handler);
while ((opt = getopt(argc, argv, "s:t:b")) != -1) {
switch (opt) {
case 's': slice_us = strtoull(optarg, NULL, 10); break;
case 't': secs = atoi(optarg); break;
case 'b': no_dispatch = true; break;
default:
fprintf(stderr, "usage: %s [-s slice_us] [-t seconds] [-b]\n", argv[0]);
return 1;
}
}
skel = scx_min__open();
if (!skel)
return 1;
skel->rodata->slice_ns = slice_us * 1000;
skel->rodata->no_dispatch = no_dispatch;
if (scx_min__load(skel)) {
fprintf(stderr, "load failed\n");
return 1;
}
link = bpf_map__attach_struct_ops(skel->maps.min_ops);
if (!link) {
fprintf(stderr, "attach failed: %m\n");
return 1;
}
printf("attached: slice=%lluus no_dispatch=%d\n", slice_us, no_dispatch);
fflush(stdout);
for (int i = 0; !exit_req && !skel->bss->exit_kind && (!secs || i < secs); i++) {
sleep(1);
printf("local=%llu queued=%llu consumed=%llu\n",
skel->bss->nr_local, skel->bss->nr_queued, skel->bss->nr_consumed);
fflush(stdout);
}
if (skel->bss->exit_kind)
printf("scheduler ejected by kernel: exit_kind=%u\n", skel->bss->exit_kind);
bpf_link__destroy(link);
scx_min__destroy(skel);
return 0;
}KSRC ?= $(HOME)/kbuild/linux-6.12
SCX := $(KSRC)/tools/sched_ext
ARCH := $(shell uname -m | sed 's/x86_64/x86/')
INCS := -I. -I$(SCX)/include -I$(SCX)/include/bpf-compat
all: scx_min spin_nice
spin_nice: spin_nice.c
cc -O2 -Wall $< -o $@
vmlinux.h:
bpftool btf dump file $(HOME)/ebpf/kernel/build/vmlinux format c > $@
scx_min.bpf.o: scx_min.bpf.c vmlinux.h
clang -g -O2 -target bpf -mcpu=v3 -D__TARGET_ARCH_$(ARCH) $(INCS) -c $< -o $@
scx_min.bpf.skel.h: scx_min.bpf.o
bpftool gen skeleton $< name scx_min > $@
scx_min: scx_min.c scx_min.bpf.skel.h
cc -g -O2 -Wall $(INCS) $< -o $@ -lbpf -lelf -lz
clean:
rm -f scx_min scx_min.bpf.o scx_min.bpf.skel.h vmlinux.hvmlinux.h는 호스트 커널이 아니라 게스트로 띄울 6.12 빌드의 vmlinux에서 뽑아야 한다. sched_ext_ops 타입이 6.8 호스트 BTF에는 아예 없다.
붙여보기
=== state (붙이기 전)
disabled
$ ./scx_min -t 10 &
attached: slice=20000us no_dispatch=0
state=enabled ops=min switch_all=1
local=81 queued=156 consumed=156
local=90 queued=158 consumed=157
local=93 queued=160 consumed=160
=== 떼어낸 뒤
disabled
$ dmesg | grep -i sched_ext
[ 46.470841] sched_ext: BPF scheduler "min" enabled
[ 46.470841] sched_ext: BPF scheduler "min" disabled (unregistered from user space)
switch_all=1은 기존 CFS 태스크까지 전부 이 스케줄러로 넘어왔다는 뜻이다. local은 유휴 CPU를 찾아 로컬 DSQ로 직행한 횟수, queued/consumed는 공용 DSQ를 거친 횟수다.
CFS와 CPU 배분 비교
nice 0과 nice 19인 CPU 소모 프로세스를 같은 CPU에 묶어 8초씩 돌렸다. 틱 기반 utime은 TCG에서 값이 튀어서, 각 프로세스가 실제로 돈 루프 횟수로 받은 CPU를 비교한다.
/* spin_nice <nice> <초>: 지정한 nice 값으로 주어진 시간 동안 루프를 돌고
* 실제로 돈 횟수를 출력한다. 틱 기반 utime 대신 이 횟수로 받은 CPU를 비교한다. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/resource.h>
static volatile sig_atomic_t stop;
static unsigned long long iters;
static int nice_val;
static void on_alarm(int sig)
{
stop = 1;
}
int main(int argc, char **argv)
{
nice_val = argc > 1 ? atoi(argv[1]) : 0;
int sec = argc > 2 ? atoi(argv[2]) : 5;
if (setpriority(PRIO_PROCESS, 0, nice_val))
perror("setpriority");
signal(SIGALRM, on_alarm);
alarm(sec);
while (!stop)
for (volatile int i = 0; i < 1000 && !stop; i++)
iters++;
printf("nice=%-3d iters=%llu\n", nice_val, iters);
return 0;
}=== A. CFS 3회 (CPU 0에서 nice 0 vs nice 19, 8초)
CFS nice=0 iters=1602200582
CFS nice=19 iters=27144835
CFS nice=0 iters=1556796400
CFS nice=19 iters=37375138
CFS nice=0 iters=1457745910
CFS nice=19 iters=24317611
=== B. scx_min(전역 FIFO) 3회
state=enabled ops=min switch_all=1
scx_min nice=0 iters=714078158
scx_min nice=19 iters=744626094
scx_min nice=0 iters=41761398
scx_min nice=19 iters=1228784389
scx_min nice=0 iters=815204019
scx_min nice=19 iters=794535299
| 스케줄러 | nice 0 : nice 19 비율 (3회) | 성격 |
|---|---|---|
| CFS | 59:1 / 42:1 / 60:1 | 가중치대로 배분, 실행마다 재현된다 |
| scx_min | 1:1.04 / 1:29 / 1.03:1 | nice를 보지 않고, 공평성도 보장하지 않는다 |
enqueue에서 nice도 vtime도 읽지 않았으니 우선순위가 사라지는 것은 예정된 결과다. 눈여겨볼 것은 2회차로, 같은 코드가 한 태스크에 29배를 몰아줬다 — FIFO는 굶주림(starvation)을 막아주지 않는다.
타임슬라이스 바꾸기
dispatch에 넘기는 슬라이스만 바꿔 같은 워크로드를 돌리고, 4편에서 만든 cswitch로 비자발 컨텍스트 스위치를 셌다. 1편에서 CO-RE로 빌드한 바이너리라 6.8에서 컴파일한 것을 6.12 게스트에서 그대로 실행한다.
=== 타임슬라이스별 비자발 컨텍스트 스위치 (스피너 2개, CPU 0, 6초)
slice=1000us PID COMM VOLUNTARY INVOLUNT
slice=1000us 91 spin_nice 0 336
slice=1000us 90 spin_nice 0 337
slice=5000us 95 spin_nice 0 221
slice=5000us 96 spin_nice 0 221
slice=20000us 100 spin_nice 0 112
slice=20000us 101 spin_nice 0 112
=== 참고: CFS에서 같은 워크로드
CFS 105 spin_nice 0 130
CFS 104 spin_nice 0 131
슬라이스를 20배 줄이면 스위치가 3배로 늘었고, 같은 워크로드의 두 태스크가 거의 같은 값을 기록했다. 다만 절대값은 슬라이스에 반비례하지 않는데, TCG 게스트에서 타이머 틱이 제때 도착하지 못해 1ms 슬라이스도 실제로는 18ms 간격으로 선점됐기 때문이다(CONFIG_HZ=1000인데도).
잘못 만든 스케줄러는 커널이 쫓아낸다
dispatch에서 아무것도 꺼내지 않도록 -b로 로드하고, CPU를 채워 태스크가 공용 DSQ에 쌓이게 했다.
$ ./scx_min -b -t 60 &
state=enabled
local=34 queued=8 consumed=0
local=34 queued=8 consumed=0
[ 64.365103] sched_ext: BPF scheduler "min" disabled (runnable task stall)
[ 64.365508] sched_ext: min: watchdog failed to check in for 32.778s
[ 64.365791] scx_tick+0x85/0x90
state(45초 후)=disabled
scheduler ejected by kernel: exit_kind=1026
consumed가 0에서 멈춘 채 30초가 지나자 워치독이 스케줄러를 내리고 시스템을 원래 스케줄러로 되돌렸다. 커널 모듈이었다면 이 시점에 시스템이 멈췄겠지만, 여기서는 게스트가 그대로 살아 있어 로더가 종료 코드까지 받아 출력했다.
주의사항
vmlinux.h에는 커널 전체 타입이 들어 있어 흔한 이름과 충돌한다. 전역 변수를broken으로 뒀더니redefinition of 'broken' as different kind of symbol로 컴파일이 막혔다(같은 이름의 enum 상수가 이미 있었다).UEI_RECORD()같은 scx 헬퍼는 32비트 atomic을 쓰므로-mcpu=v3없이는unsupported atomic operation으로 clang 백엔드가 죽는다.- bpftool 7.4가 만든 스켈레톤에는
struct_ops멤버가 없어서 커널 트리의SCX_OPS_OPEN/LOAD/ATTACH매크로가 그대로는 빌드되지 않는다. 위 로더처럼bpf_map__attach_struct_ops()를 직접 부르면 된다. switch_all=1이면 이미 돌고 있던 모든 CFS 태스크가 내 스케줄러로 넘어온다. 일부 태스크만 맡으려면ops.flags에SCX_OPS_SWITCH_PARTIAL을 넣고sched_setscheduler()로SCHED_EXT를 지정한 태스크만 받아야 한다.- 워치독 기본 시간은 30초다. 이 안에 태스크를 실행시키지 못하면 사유와 함께 강제로 내려가며, 사유는
dmesg와exit콜백에 함께 남는다. - KVM이 없는 QEMU(TCG)에서는 스케줄링 측정값을 그대로 믿으면 안 된다. 위 실험도
tsc=reliable을 주기 전에는 CFS 기준선조차 실행마다 뒤집혔다.
마무리
콜백 다섯 개짜리 BPF 프로그램으로 커널 스케줄러를 갈아끼웠고, 붙이고 떼는 데 재부팅도 모듈 적재도 필요 없었다. nice를 무시하도록 만든 대가가 측정값에 그대로 드러났고, 스케줄링을 아예 멈춰도 워치독이 30초 만에 원래대로 되돌려놨다.
다음 편에서는 메모리 쪽으로 옮겨가 페이지 폴트와 slab 할당을 추적하고, 누수와 OOM을 관측한다.