Learning Platform
Глоссарий Troubleshooting
Урок 06.02 · 20 мин
Начальный
SchedulerCFSRound-robinMLFQLinux

Алгоритмы планирования — от round-robin до CFS

Когда у вас 5 голодных детей и одна шоколадка, нужно делить честно. Алгоритм деления — это и есть scheduling algorithm. На системе с 1500 потоков и 8 CPU задача та же, только дети голодные, шоколадка одна, и kernel решает несколько тысяч раз в секунду.

В этом уроке разберём четыре классических алгоритма: round-robin (всем поровну по очереди), priority-based (важным больше), MLFQ (multilevel feedback queue — умный гибрид), и CFS (Completely Fair Scheduler — то, что было в Linux 18 лет, до конца 2023). Для junior data engineer важно понимать, как именно Linux принимает решение «кому дать CPU следующему» — это объясняет, почему nice работает именно так, почему long-running batch job получает меньше CPU чем интерактивный shell, и почему CPU usage в htop иногда выглядит странно.


Round-robin: простейший fair scheduler

Round-robin — крутая первая модель. Все процессы в одной очереди, scheduler берёт первого, даёт ему фиксированный time slice (например, 10 мс), потом перемещает его в конец очереди и берёт следующего.

Round-robin: очередь, каждому свой кусок
Queue: [A, B, C, D, E]Все runnable процессы в одной FIFO-очереди. Никаких приоритетов, никакого учёта истории
A runs 10msПервый процесс получает 10 мс. По истечении -- вытесняется, ставится в конец очереди
B runs 10msСледующий из очереди
C runs 10ms..
back to AПосле того как все процессы получили свой кусок, A снова первый в очереди -- работает дальше

Плюсы round-robin:

  • Очень просто реализовать.
  • Идеально fair: все получают одинаково.
  • Гарантированный максимум ожидания: N * time_slice.

Минусы:

  • Нет приоритетов: критичный процесс ждёт наравне с фоновым batch job.
  • Нет адаптации: I/O-bound процесс получает свой 10 мс, проспит 9.5 мс на read’е, потом снова 10 мс ждёт следующего хода.
  • Время slice — компромисс: длинный slice (=меньше переключений overhead, но плохо для интерактивности), короткий (хорошо для отзывчивости, но дорого).

Round-robin живёт в Linux как SCHED_RR — real-time класс политики (рассмотрим в уроке 04). Для обычных процессов он был оставлен в 90-х из-за минусов.


Priority-based: важные процессы получают больше

Идея простая: каждому процессу присваивается priority (число от 0 до N). Scheduler всегда выбирает процесс с самым высоким приоритетом. При равном приоритете — round-robin внутри уровня.

Priority-based: high priority first
Priority 80: [P1]Real-time критичный процесс. Получает CPU, как только runnable
Priority 60: [P2, P3]Менее критичные real-time. Получают CPU, только если P1 заснул
Priority 20: [P4, P5, P6, P7]Обычные пользовательские процессы
Priority 0: [P8, P9]Низкий приоритет (фоновые batch jobs)
Starvation riskЕсли P1 не отдаёт CPU -- P8, P9 никогда не получат. Это classic 'starvation'. Решение -- aging: со временем приоритет ждущих повышается

Главная проблема — starvation: если высокоприоритетный процесс не отпускает CPU, низкоприоритетные ждут вечно. Решается aging — автоматическим повышением приоритета процессов, которые долго ждут. Чем дольше ждёшь — тем выше приоритет, в какой-то момент догонишь любой busy процесс.

Static vs dynamic priority:

  • Static — приоритет задаётся при старте, не меняется. Для real-time правильно.
  • Dynamic — приоритет меняется в runtime (например, повышается из-за aging, снижается из-за расхода CPU). Для обычных процессов умнее.

Linux традиционно использует комбинацию: real-time классы (SCHED_FIFO, SCHED_RR) с static priority, плюс normal классы (SCHED_OTHER, SCHED_BATCH) с dynamic priority. Подробно — в уроке 03.


MLFQ — multilevel feedback queue

MLFQ — умный алгоритм 70-х: несколько очередей разных приоритетов, плюс автоматическое перемещение между ними на основе поведения.

MLFQ: процессы переходят между очередями
Queue 0 (highest)Самый высокий приоритет. Время slice короткое (например 2 мс). Сюда попадают новые процессы и интерактивные
Queue 1Средний приоритет. Slice больше (4 мс)
Queue 2 (lowest)Низкий приоритет. Большой slice (8+ мс) -- batch jobs
If used full slice -> demoteПроцесс использовал весь свой time slice без блокировки -- значит, он CPU-bound. Перемещается в очередь ниже (хуже приоритет, но больше slice за раз)
If blocked early -> stay/promoteПроцесс заблокировался на I/O до конца slice -- значит, он интерактивный/I/O-bound. Остаётся в текущей очереди или повышается
Periodic boostЧтобы предотвратить starvation, все процессы периодически 'промотируются' обратно в Q0. Иначе CPU-bound никогда не получит CPU при наличии интерактивных

Логика:

  • Новый процесс — в очередь высокого приоритета.
  • Использовал весь slice — CPU-bound, demote в очередь ниже.
  • Заблокировался на I/O — остаётся в высокой (интерактивный).
  • Долго ждёт — promote обратно (anti-starvation).

Результат: интерактивные процессы (shell, текстовый редактор) автоматически в верхних очередях, batch jobs (компиляция, расчёты) — в нижних. Никто специально не настраивает.

MLFQ использовался в коммерческих Unix (Solaris, AIX). В Linux был похожий O(1) scheduler с 2003 по 2007, потом заменён на CFS.


CFS — Completely Fair Scheduler

CFS появился в Linux 2.6.23 (2007), от Ingo Molnar. Основная идея: вместо очередей и эвристик — математически точная справедливость через virtual runtime (vruntime).

CFS -- virtual runtime per task
task.vruntimeVirtual runtime -- сколько CPU времени получил этот процесс, нормализованное по его весу (приоритету)
Pick min vruntimeCFS всегда выбирает процесс с самым маленьким vruntime. То есть тот, кто меньше всех получил CPU относительно своего веса
Process A runsA работает. Каждую микросекунду на CPU его vruntime увеличивается на 1/weight
Now A's vruntime > B'sA набрал больше vruntime чем B. Теперь B имеет наименьший -- CFS переключает на B
Red-black treeВсе runnable процессы хранятся в red-black tree, отсортированные по vruntime. Поиск минимума -- O(log N), вставка/удаление -- O(log N). Очень эффективно

Идея в одном предложении: процессы получают CPU пропорционально весу, и CFS всегда даёт CPU тому, кто получил меньше всех.

Как nice влияет на CFS:

nice — значение от -20 (highest) до +19 (lowest). Внутри kernel это превращается в weight — множитель, который входит в формулу:

vruntime_delta = actual_runtime * (NICE_0_WEIGHT / weight)

Где NICE_0_WEIGHT = 1024. Веса для nice’ов:

  • nice -20 -> weight 88761 (CPU в ~86x больше чем nice 0)
  • nice 0 -> weight 1024 (стандарт)
  • nice 19 -> weight 15 (CPU в ~68x меньше чем nice 0)

Это значит: процесс с nice -5 получает примерно в 3-4 раза больше CPU чем nice 0, при равной конкуренции. nice работает мультипликативно, а не аддитивно.

# Запустим два CPU-bound процесса -- один nice 0, другой nice 10:
yes > /dev/null &
PID1=$!
nice -n 10 yes > /dev/null &
PID2=$!
sleep 2

# Сравним CPU usage:
ps -p $PID1,$PID2 -o pid,ni,%cpu
# PID 12345 NI  0 %CPU 90
# PID 12346 NI 10 %CPU 10
# nice 10 получает ~10% CPU вместо 50/50

kill $PID1 $PID2

Что делает CFS хорошим:

  1. Никаких эвристик типа “если slice не использовал — хороший, demote”. Чистая математика.
  2. Идеальный fair-share при равных приоритетах. Каждый получит ровно 1/N CPU.
  3. Никаких magic threshold. Раньше O(1) scheduler имел эвристики «sleep_avg > X -> bonus», их трудно настраивать. CFS просто.
  4. O(log N) операции через RB-tree. Масштабируется до тысяч runnable tasks.

Что у CFS не идеально:

  1. Latency-sensitive workloads страдают. Если вам нужна предсказуемая задержка (видео-стриминг, real-time control), CFS усредняет.
  2. Group scheduling сложный. При большом числе cgroups эффективность падает.
  3. Cache locality. CFS не очень умно учитывает, что процесс на CPU 0 имеет тёплый кэш — может перенести его на CPU 5 ради баланса.

EEVDF — следующий шаг (Linux 6.6+)

В Linux 6.6 (октябрь 2023) CFS заменили на EEVDF (Earliest Eligible Virtual Deadline First). Это эволюция, не революция. Идея: каждой задаче даётся deadline — желаемое время, к которому она должна получить свой fair share. Scheduler выбирает задачу с наименьшим deadline (то есть кто срочнее).

EEVDF: deadline-driven scheduling
Each task: eligible time + deadlineEligible time -- когда задача 'имеет право' начать получать CPU. Deadline -- к какому моменту она должна получить свой quota
Pick earliest deadlineScheduler выбирает задачу с самым близким deadline -- то есть наиболее срочную
Better latencyEEVDF даёт лучшую гарантию задержки -- задача не будет ждать больше своего deadline. CFS такого не гарантировал
Same fairnessСохраняется fairness CFS -- задачи получают пропорционально весу
Less knobsМеньше параметров для настройки чем CFS

Для практического junior data engineer-а ничего не меняется: nice, chrt, cgroups работают так же. Изменения внутри. Если у вас kernel 6.6+ (Ubuntu 24.04, Debian 13) — у вас EEVDF.

# Проверьте версию kernel:
uname -r
# 6.8.0-... -- EEVDF
# 5.15.0-... -- CFS

# Текущий scheduler класс не меняется:
chrt -p 1
# SCHED_OTHER -- normal class (CFS/EEVDF под капотом)

Сравнительная таблица

Сравнение scheduling algorithms
Round-robinВсе в одной очереди. Simple. Нет приоритетов. Используется в Linux только как SCHED_RR для real-time
PriorityВысокоприоритетные первые. Может быть starvation. Используется в real-time классах SCHED_FIFO, SCHED_RR
MLFQНесколько очередей с авто-promote/demote. Adaptive к интерактивности. Linux O(1) до 2007
CFSVirtual runtime + RB-tree. Идеальный fair-share по весам. Linux 2.6.23 до 6.6
EEVDFDeadline-driven, эволюция CFS. Linux 6.6+ (октябрь 2023). Лучше latency. Тот же интерфейс для пользователя

Практика: посмотрим scheduler в действии

# 1. Проверим scheduler класс процессов:
chrt -p 1
# pid 1's current scheduling policy: SCHED_OTHER
# pid 1's current scheduling priority: 0
# (SCHED_OTHER = CFS/EEVDF default)

chrt -p $(pgrep -f kworker | head -1)
# Тоже SCHED_OTHER

# 2. Запустим 3 CPU-bound с разными nice:
yes > /dev/null &
A=$!
nice -n 5 yes > /dev/null &
B=$!
nice -n 10 yes > /dev/null &
C=$!
sleep 3

ps -p $A,$B,$C -o pid,ni,%cpu,comm
# Распределение CPU будет ~70/20/10 -- nice работает мультипликативно через CFS weights

kill $A $B $C

# 3. Посмотреть CFS schedstats:
cat /proc/$$/sched | head -20
# se.vruntime -- virtual runtime данного процесса
# se.sum_exec_runtime -- общее CPU время
# nr_switches -- сколько раз переключали

# 4. Per-CPU runqueue stats:
cat /proc/schedstat
# Несколько строк, по одной на CPU
# Параметры: yields, context switches, load среднее

Кooперативный scheduler в userspace (Go runtime)

В userspace часто реализуется свой scheduler внутри runtime. Go scheduler — классический пример.

Go использует work-stealing + cooperative scheduling:

Go runtime scheduler
M (machine)Kernel thread (OS thread). M штук = GOMAXPROCS обычно
P (processor)Логический процессор runtime. У каждого свой queue of goroutines
G (goroutine)Лёгкий поток userspace. Миллионы возможны
P local queueУ каждого P свой queue goroutines (256 штук). Запускаются по FIFO
Work stealingЕсли у P пусто, идёт к другому P и забирает половину его очереди. Хорошо балансирует, минимум lock contention
CooperativeGoroutine yield-ит в трёх случаях: вход в функцию (preemption checkpoint в Go 1.14+), syscall, channel/sleep/network

Это пример того, как принципы scheduler’ов применимы не только в kernel. Похожие идеи в Tokio (Rust async), JVM ForkJoinPool, Erlang BEAM.

kube-scheduler: распределение Pod'ов по нодам по тем же принципам

Попробуй сам

# 1. Сравните CPU-распределение по nice:
for n in 0 5 10 15 19; do
    nice -n $n yes > /dev/null &
    echo "Started nice $n, PID $!"
done
sleep 5
ps -eo pid,ni,%cpu,comm | grep -E '^.{0,30}(yes|^PID)'
killall yes
# nice -19 получит большую часть CPU; nice 19 -- крохи

# 2. Посмотрите детальные scheduler stats для процесса:
sleep 60 &
PID=$!
sleep 3
cat /proc/$PID/sched
# nr_switches, nr_voluntary_switches -- разное
# se.vruntime -- virtual runtime CFS
kill $PID

# 3. SCHED_RR vs SCHED_OTHER:
# Запустить процесс в RR классе (нужно root или CAP_SYS_NICE):
sudo chrt -r -p 50 $$    # current shell -> SCHED_RR prio 50
# (НЕ запускайте бесконечный цикл в SCHED_RR -- будет блокировать систему!)
sudo chrt -o -p 0 $$     # вернуть SCHED_OTHER

# 4. Замерьте, сколько процессов переключений в секунду:
vmstat 1 5
# Колонка cs -- context switches/sec
# Запустите параллельно много yes и сравните: cs скакнёт

# 5. Если есть Go установлен -- запустите программу с миллионом goroutines:
cat > /tmp/many.go << 'EOF'
package main
import "fmt"; import "runtime"; import "time"
func main() {
    for i := 0; i < 1000000; i++ {
        go func() { for { runtime.Gosched() } }()
    }
    fmt.Println(runtime.NumGoroutine())
    time.Sleep(time.Hour)
}
EOF
# go run /tmp/many.go &
# top -- увидите, что process использует все GOMAXPROCS cores

Проверка знанийKnowledge check
Senior спрашивает на собесе: 'У тебя на сервере крутится batch ETL, который ест 100% CPU всех 16 ядер и блокирует интерактивные процессы. Объясни, как именно nice -n 19 поможет, и почему может быть недостаточно. Что ещё можешь сделать?'
ОтветAnswer
nice -n 19 уменьшит вес процесса в CFS (vruntime tree) c 1024 до 15. Это значит, что при равной конкуренции nice 19 процесс получит примерно 1.5% CPU от того, что nice 0 (1024/15 ≈ 68 раз меньше per task). Если у вас 1 ETL с nice 19 + 1 интерактивный с nice 0 -- интерактивный получит ~98% CPU, ETL ~2%. Но nice 19 может быть недостаточен: 1. CFS не строгий. nice 19 даёт меньший вес, но не нулевой. Если в данный момент нет интерактивных процессов, ETL поглотит весь CPU. И когда интерактивный придёт -- ему всё равно нужно 'дождаться' своей очереди (vruntime отставание). 2. I/O wait не считается. ETL может насыщать disk -- интерактивные процессы будут ждать I/O. nice не влияет на I/O. Для этого -- ionice (см. урок 03). 3. Только при contention. nice работает только когда есть конкуренция за CPU. Если ETL ест 100% когда никого больше нет -- всё нормально. Дополнительные инструменты: 1. ionice -c 3 -- idle класс для I/O. ETL будет получать диск только когда никто другой не хочет. Полезно если узкое место не CPU, а disk. 2. cgroups (cpu.weight, cpu.max). Создаём cgroup 'background', помещаем ETL туда. Через cgroup limit: max 50% CPU суммарно. Жесткое ограничение, не относительный приоритет. 3. taskset/affinity. Прибиваем ETL к конкретным CPU (например, 0-7), оставляем 8-15 для интерактива. Никакая работа ETL не влияет на интерактивные ядра. 4. SCHED_BATCH класс. chrt -b 0 PID -- помещает в SCHED_BATCH. Это говорит scheduler: 'этот процесс batch, может ждать дольше, оптимизируй throughput, не latency'. CFS будет меньше переключать его -- больше throughput, меньше interactivity penalty. 5. SCHED_IDLE. chrt -i 0 PID -- самый низкий приоритет. Получает CPU только когда никто другой не runnable. Ещё ниже чем nice 19. 6. nice + ionice + SCHED_BATCH вместе. На production у меня было: nice -n 19 ionice -c 3 chrt -b 0 ./etl-job. ETL не мешает никому, использует idle время. 7. Архитектурно: разнести по разным машинам. Batch на отдельный сервер, интерактив на другой. Самое надёжное решение, если позволяют ресурсы. Если действительно жесткие требования к latency интерактива -- cgroups с CPU quota -- единственный надёжный способ. nice -- это relative priority, не гарантия.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Какая главная проблема чистого priority-based scheduling без других механизмов?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4