Эта статья представляет собой короткое руководство по созданию минимального планировщика с использованием sched_ext на языке C. Этот планировщик использует глобальную очередь планирования, из которой каждый CPU берёт задачи на выполнение на один квант времени. Порядок планирования — «первым пришёл — первым обслужен» (First-In-First-Out, FIFO). По сути, это реализация циклического планирования (round-robin):

Планирование Round Robin с двумя задачами на одноядерном оборудовании
Планирование Round Robin с двумя задачами на одноядерном оборудовании

Этот короткий туториал покрывает основы; чтобы узнать больше, загляните в материалы из вики scx.

Требования

Чтобы собрать собственный планировщик, нам нужно ядро 6.12 или ядро 6.11 с патчами. Можно взять ядро с патчами расширений планировщика для Ubuntu 24.10 отсюда, либо использовать CachyOS и установить пропатченное ядро оттуда.

Кроме того, вам понадобятся:

  • свежий clang для компиляции

  • bpftool для подключения планировщика

На Ubuntu, например, можно выполнить: apt install clang linux-tools-common linux-tools-$(uname -r).

Больше ничего не нужно для запуска, а код этого туториала вы найдёте в репозитории minimal-scheduler. Я бы рекомендовал просто клонировать его:

git clone https://github.com/parttimenerd/minimal-scheduler
cd minimal-scheduler

Сам планировщик находится в файле sched_ext.bpf.c, но прежде чем мы на него посмотрим, я покажу, как этим планировщиком пользоваться.

Использование

Вкратце, нужны всего два шага:

#собрать бинарник планировщика
./build.sh

#запустить планировщик
sudo ./start.sh

#сделать что-нибудь …

#остановить планировщик
sudo ./stop.sh

Позже я покажу, что находится внутри этих скриптов, но сначала перейдём к коду планировщика:

Планировщик

Предполагается, что у вас уже есть некоторый опыт написания eBPF-программ. Если нет, хорошей отправной точкой будет книга Лиз Райс «Learning eBPF».

Код планировщика зависит только от BPF-заголовков ядра Linux и sched_ext. Ниже приведён код из sched_ext.bpf.c:

// Этот заголовок генерируется автоматически, как будет объяснено далее
#include <vmlinux.h>
// Следующие два заголовка берутся из набора заголовков Linux
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

// Определяем идентификатор общей очереди диспетчеризации (DSQ)
// Мы используем её как глобальную очередь планирования
#define SHARED_DSQ_ID 0

// Два макроса, которые делают дальнейший код более читаемым
// и размещают функции в правильных секциях
// бинарного файла
#define BPF_STRUCT_OPS(name, args...)  \
   SEC("struct_ops/"#name)  BPF_PROG(name, ##args)
#define BPF_STRUCT_OPS_SLEEPABLE(name, args...)  \
   SEC("struct_ops.s/"#name)                    \
   BPF_PROG(name, ##args)

// Инициализируем планировщик, создав общую очередь диспетчеризации (DSQ)
s32 BPF_STRUCT_OPS_SLEEPABLE(sched_init) {
   // Все функции scx_ приходят из vmlinux.h
   return scx_bpf_create_dsq(SHARED_DSQ_ID, -1);
}

// Ставим в очередь задачу, которая хочет выполняться,
// помещая её в общую DSQ с заданным квантом времени
int BPF_STRUCT_OPS(sched_enqueue, struct task_struct *p, u64 enq_flags) {
   // Вычисляем квант времени для задачи на основе числа задач в очереди
   // Это делает систему немного более отзывчивой, чем базовый round-robin,
   // где каждой задаче постоянно выдаётся один и тот же квант времени
   // Базовый квант — 5_000_000 нс, то есть 5 мс
   u64 slice = 5000000u / scx_bpf_dsq_nr_queued(SHARED_DSQ_ID);
   scx_bpf_dispatch(p, SHARED_DSQ_ID, slice, enq_flags);
   return 0;
}

// Забираем (consume) задачу из общей DSQ для CPU,
// когда CPU нужно что-то выполнять — обычно после того, как он закончил
// выполнять предыдущую задачу в течение выделенного кванта времени
int BPF_STRUCT_OPS(sched_dispatch, s32 cpu, struct task_struct *prev) {
   scx_bpf_consume(SHARED_DSQ_ID);
   return 0;
}

// Определяем основную структуру операций планировщика (sched_ops)
SEC(".struct_ops.link")
struct sched_ext_ops sched_ops = {
   .enqueue   = (void *)sched_enqueue,
   .dispatch  = (void *)sched_dispatch,
   .init      = (void *)sched_init,
   // Доступно больше функций, но мы сосредоточимся
   // на важных для минимального планировщика
   .flags     = SCX_OPS_ENQ_LAST | SCX_OPS_KEEP_BUILTIN_IDLE,
   // Имя, которое появится в
   // /sys/kernel/sched_ext/root/ops
   // после подключения планировщика
   // Имя должно быть корректным идентификатором C
   .name      = "minimal_scheduler"
};

// Все планировщики должны распространяться по лицензии GPLv2
char _license[] SEC("license") = "GPL";

Визуализировать взаимодействие всех функций в планировщике можно с помощью следующей диаграммы:

Scheduler Diagram

Теперь, когда вы увидели код, запустите скрипт build.sh, чтобы сгенерировать заголовочный файл vmlinux.h (из BTF), а затем скомпилируйте код планировщика в объектный файл eBPF (BPF bytecode в .o):

bpftool btf dump file /sys/kernel/btf/vmlinux format c &gt; vmlinux.h
clang -target bpf -g -O2 -c sched_ext.bpf.c -o sched_ext.bpf.o -I. 

Затем запустите скрипт start.sh с root-правами, чтобы подключить планировщик с помощью bpftool:

bpftool struct_ops register sched_ext.bpf.o /sys/fs/bpf/sched_ext

Теперь пользовательский планировщик стал планировщиком этой системы. Проверить это можно, открыв файл /sys/kernel/sched_ext/root/ops:

> cat /sys/kernel/sched_ext/root/ops
minimal_scheduler

А также посмотрев вывод dmesg | tail:

&gt; sudo dmesg | tail
# ...
[32490.366637] sched_ext: BPF scheduler "minimal_scheduler" enabled

Поиграйтесь со своей системой и посмотрите, как она себя ведёт.

Если вы закончили, можно отключить планировщик, запустив скрипт stop.sh с root-правами. Это удалит файл /sys/fs/bpf/sched_ext/sched_ops.

Задания для читателя

Теперь, когда вы знаете, как выглядит базовый планировщик, можно начать его модифицировать. Вот несколько идей:

— Изменяйте квант времени

Как ведёт себя система, если увеличить или уменьшить квант времени?

Например, попробуйте квант 1 с. Видите ли вы разницу в том, как движется курсор? Или попробуйте маленький квант в 100 мкс и запустите программу, которая выполняет какие-то вычисления — заметите ли вы разницу в её производительности?

— Используйте фиксированный квант времени

Как поведёт себя система, если изменить планировщик так, чтобы он всегда использовал один и тот же квант времени, игнорируя число задач, поставленных в очередь?

Попробуйте, например, создать нагрузку на систему и посмотреть, как она себя ведёт.

— Ограничьте используемые CPU

Как ведёт себя система, если планировщик планирует задачи только на конкретные CPU?

Попробуйте, например, сделать систему фактически «одно��дерной», потребляя задачи только на CPU 0 в sched_dispatch (подсказка: параметр cpu — это идентификатор CPU).

— Создайте несколько очередей планирования

Как ведёт себя система с несколькими очередями планирования для разных CPU и процессов?

Попробуйте, например, создать две очереди планирования, где одна очередь будет только для процесса с определённым id (подсказка: task_struct#tgid — это id процесса) и будет планироваться на половине ваших CPU.

Загляните в заголовочный файл linux/sched.h, чтобы узнать больше о task_struct.

— Используйте больше возможностей BPF

Если вы уже умеете писать базовые eBPF-программы, используйте bpf_trace_printk и хуки running и stopping.

Хук running вызывается каждый раз, когда задача начинает выполняться на CPU; получить текущий id CPU можно через smp_processor_id():

int BPF_STRUCT_OPS(sched_running, struct task_struct *p) {
    // ...
    return 0; // в eBPF нет функций с типом void
}

Хук stopping вызывается каждый раз, когда задача прекращает выполняться:

int BPF_STRUCT_OPS(sched_stopping, struct task_struct *p, bool runnable) {
    // ...
    return 0;
}

Это можно использовать, чтобы делать визуализации порядка планирования.

Дальше — больше

Чтобы пойти дальше, можно посмотреть собранные материалы в вики scx, особенно хорошо документированный код sched-ext в ядре.

Если вам интересно, как использовать это в Rust, взгляните на scx и scx_rust_scheduler, а для Java — на hello-ebpf.

Заключение

Надеюсь, вам было интересно увидеть, насколько просто сделать крошечный планировщик, не полагаясь на большие фреймворки в качестве зависимостей.

Работа с sched_ext и eBPF быстро упирается в понимание того, как код на C взаимодействует с ядром, памятью и ресурсами ОС. Курс «Программист C» помогает закрыть этот слой: системное программирование на практике, архитектура процессора и памяти, реальные кейсы низкоуровневых приложений и интеграции с ОС. Пройдите вступительный тест и узнаете, подойдет ли вам программа курса.

А чтобы узнать больше о формате обучения и задать экспертам вопросы, приходите на бесплатные демо-уроки:

  • 15 января, 20:00 — Работа с памятью на языке C. Записаться

  • 22 января, 19:00 — eBPF: рентгеновское зрение для production. Видим сеть, безопасность и узкие места на уровне ядра Linux. Записаться