Что делает планировщик — preemptive vs cooperative, time slice
Откройте htop — увидите 300+ процессов и 1500+ потоков. У вас, скажем, 8 ядер CPU. Как 1500 потоков могут «одновременно работать» на 8 ядрах? Очевидно, не могут — в каждый конкретный момент работает максимум 8 потоков (по одному на ядро). Остальные ждут своей очереди. Решает эту очередь — планировщик ядра (kernel scheduler).
В этом уроке разберём, что физически происходит: как kernel выбирает следующий процесс на CPU, что такое time slice, чем отличаются preemptive и cooperative multitasking, и почему context switch не бесплатен. Junior data engineer на работе постоянно сталкивается с последствиями: «почему мой скрипт стал в 10 раз медленнее, когда параллельно запустилось ещё 100 процессов?», «что значит load average 50?», «почему nice -n 19 не делает фоновую задачу совсем фоновой?». Все ответы — в планировщике.
Зачем нужен планировщик
У современного Linux одна цель в этой области: создать у каждого процесса иллюзию владения CPU. Программе кажется, что она единственная на машине, что у неё свой CPU полностью. На самом деле она получает CPU маленькими кусочками (миллисекунды), между ними CPU отдан другим процессам.
В одно ядро в каждый момент времени работает один процесс. На N-core системе одновременно работают N процессов. Остальные стоят в runqueue — очереди готовых к выполнению.
# Сколько ядер у вас:
nproc
# 8 (например)
# Все процессы в системе:
ps -e | wc -l
# 300+ (типично)
# Сколько сейчас в state R (running или runnable):
ps -eo stat | grep -c '^R'
# Обычно 1-5: те, кто реально работает + те, кто готов и ждёт CPU
# Сколько в state S (interruptible sleep -- ждут I/O, мьютекса, таймера):
ps -eo stat | grep -c '^S'
# Обычно 90%+ -- большинство процессов в любой момент времени спит
Большинство процессов в любой момент спит. Серверы спят на accept(), демоны на epoll(), ваш текстовый редактор — на read() из stdin. Они не претендуют на CPU, пока что-то не произойдёт.
Preemptive vs cooperative
Есть два фундаментально разных подхода к тому, как scheduler отбирает CPU у текущего процесса.
Cooperative multitasking. Процесс сам отдаёт CPU, когда захочет. Если не хочет — работает вечно. В DOS, Windows 3.x, классический Mac OS было именно так. Один зависший процесс мог положить всю систему.
Preemptive multitasking. Kernel принудительно отбирает CPU у процесса. По таймеру — через time slice (раз в N миллисекунд). Или когда процесс делает syscall и kernel замечает, что есть кто-то с более высоким приоритетом. Linux, Windows NT+, macOS, любая современная ОС — preemptive.
В Linux periodic timer interrupt (HZ) — обычно 250 или 1000 Hz. На каждый тик kernel смотрит: «Текущий процесс отработал свой time slice? Есть ли в runqueue кто-то с более высоким приоритетом?». Если да — переключение.
# Узнать частоту тиков kernel:
grep CONFIG_HZ /boot/config-$(uname -r)
# CONFIG_HZ_1000=y -- 1000 Hz (тик каждую миллисекунду)
# Или CONFIG_HZ_250=y -- 250 Hz (тик каждые 4 мс)
# В современных kernel может быть NO_HZ -- тикless, timer прерывания только когда нужно
В user-space асинхронные модели (asyncio, Go runtime) реализуют кооперативную модель внутри preemptive (kernel переключает потоки, потоки кооперативно переключают coroutines). Это даёт лучший контроль и меньше переключений.
Time slice — сколько CPU достаётся каждому
Time slice (или quantum) — кусок CPU времени, который scheduler выделяет процессу. После этого процесс вытесняется, даже если хотел работать дальше.
В Linux CFS (Completely Fair Scheduler, дефолтный с 2007 до 2023, заменён на EEVDF в kernel 6.6) time slice не фиксированный — он вычисляется из числа активных процессов:
time_slice = sched_latency / N
где sched_latency — желаемая частота переключений (по умолчанию 6 мс при N <= 8), N — число активных процессов.
Логика: при малом числе процессов давать им большие куски (низкий overhead), при большом — уменьшать, но не ниже минимума.
# Текущие настройки CFS:
sysctl kernel.sched_latency_ns
# 6000000 -- 6 мс default
sysctl kernel.sched_min_granularity_ns
# 750000 -- 0.75 мс минимум
# (на Linux 6.6+ scheduler стал EEVDF, эти параметры могут отличаться)
В реальности с современным NO_HZ kernel’ом и низким числом активных процессов переключений происходит куда меньше, чем 1000 / tick. Если все спят — CPU вообще не переключается. Энергоэффективно.
Context switch — что происходит при переключении
Когда scheduler решает переключить процесс A на процесс B, происходит context switch. Это не бесплатно.
Стоимость context switch:
- Между потоками одного процесса: ~1-5 мкс (только регистры, TLB сохраняется)
- Между процессами: ~5-20 мкс (плюс TLB flush, partial cache misses)
- С учётом cache pollution: реальная стоимость может быть 10-50 мкс
Это значит: если у вас процесс тратит 1 мс на работу, потом scheduling переключает его — 10 мкс overhead, это 1% потерь. Если переключения каждые 50 мкс — overhead 20%. Слишком частые переключения дорогие.
# Замерить context switches в системе:
vmstat 1
# Колонка "cs" -- context switches per second
# 1000-10000 -- нормально
# 100000+ -- слишком много, проблема
# Или через perf:
perf stat -e context-switches,cpu-migrations,cache-misses -p PID sleep 5
# Покажет статистику для конкретного процесса
# Per-process:
cat /proc/PID/status | grep ctxt
# voluntary_ctxt_switches -- сам отдал CPU (например, sleep, read)
# nonvoluntary_ctxt_switches -- kernel вытеснил (time slice expired)
# Соотношение voluntary/nonvoluntary говорит много: I/O-bound vs CPU-bound
Высокий voluntary_ctxt_switches означает: процесс часто блокируется на I/O (читает с диска, ждёт сеть). Высокий nonvoluntary — процесс CPU-bound, kernel часто его вытесняет.
Process state machine — кто борется за CPU
Не все процессы готовы к выполнению. Только в state R (Running/Runnable) процесс попадает в runqueue и борется за CPU.
Только R-процессы влияют на load. Когда вы видите load average 8.5 на 8-core машине — это значит в среднем 8.5 процессов было в state R за последнюю минуту. Полная загрузка + небольшая очередь.
# Смотрите состояния всех процессов:
ps -eo pid,stat,comm | head -20
# Сколько каких state в системе:
ps -eo stat | sort | uniq -c | sort -rn
# 200 S -- сонные
# 50 Ss -- session leaders, сонные
# 5 R -- готовы или работают
# 2 Z -- зомби (родители забыли wait!)
# 1 D -- disk wait (плохой признак если их много)
Высокий процент D-state — проблема с I/O. Если 50 процессов в D-state — значит, диск или сеть не справляются.
Runqueue и multi-CPU
На современных машинах CPU несколько. У Linux каждое логическое ядро имеет свою runqueue — свой список готовых процессов.
Почему per-CPU очереди, а не одна глобальная? Чтобы не было contention на одной структуре данных. Если 16 CPU дёргают один runqueue с lock’ом — получим ужасный scaling. Per-CPU очереди работают независимо, плюс периодически load balancer выравнивает.
CPU affinity — можно прикрепить процесс к конкретному CPU:
# Прикрепить процесс PID к CPU 0 и 1:
taskset -cp 0,1 PID
# Запустить программу на конкретных CPU:
taskset -c 4,5,6,7 python3 my_script.py
# Зачем? Чтобы:
# 1. Cache был тёплый -- процесс не мигрирует между CPU
# 2. Изолировать нагрузку -- worker на отдельных CPU
# 3. NUMA-aware -- держать на CPU близко к памяти процесса (модуль 6)
Для production CPU-bound нагрузок (как kafka, как Postgres backend) affinity может улучшить cache hit rate на 5-20%.
Иерархия памяти: почему CPU affinity влияет на cache hit rateПопробуй сам
# 1. Посмотреть состояния всех процессов:
ps -eo stat,pid,comm | head -20
# Колонка stat: R, S, D, Z, T
# 2. Замерить context switches на системе:
vmstat 1 5
# Колонки cs (context switches) и in (interrupts) per second
# Запустите что-то параллельно (CPU-bound) -- увидите рост
# 3. Сравнить voluntary vs nonvoluntary switches:
# Для CPU-bound процесса:
python3 -c "while True: pass" &
PID=$!
sleep 5
cat /proc/$PID/status | grep ctxt
# nonvoluntary_ctxt_switches >> voluntary -- kernel вытесняет
kill $PID
# Для I/O-bound процесса:
sleep 60 &
PID=$!
sleep 5
cat /proc/$PID/status | grep ctxt
# voluntary высокий -- сам спит на таймере; nonvoluntary низкий
kill $PID
# 4. Замерьте cost одного context switch:
# perf bench sched messaging
# (показывает microbenchmark на pipe + sched)
# 5. Поиграйте с taskset:
taskset -c 0 yes > /dev/null &
PID=$!
sleep 1
# Посмотрите в top -- yes должен быть только на CPU 0
# При nproc > 1 можно сравнить
kill $PID
# 6. Load average и что он значит:
uptime
# 'load average: 2.5, 1.8, 1.0'
# На 8-core машине это лёгкая нагрузка
# На 1-core -- перегрузка