Процессы, потоки и планировщики
У вас 8 ядер и 600 работоспособных задач. Кто-то должен каждые несколько миллисекунд отвечать на вопрос «кому отдать ядро прямо сейчас» — и делать это за сотни наносекунд, потому что сам ответ отнимает то же самое время, что и полезная работа. Этот кто-то — планировщик, а сущности, между которыми он делит процессор, — процессы и потоки.
Для прикладного программиста здесь спрятано больше практики, чем кажется. Почему p99 вашего
сервиса 300 мс при средней латентности 4 мс? Почему контейнер с limits.cpu: 1 тормозит,
хотя top показывает 40% загрузки? Почему Thread.setPriority(MAX_PRIORITY) в Java ничего
не меняет? Почему fork() в многопоточной программе — почти всегда ошибка? Все эти вопросы
упираются в устройство планировщика и модель процессов. Про то, где вообще проходит граница
ядра и пользователя, — в статье Архитектуры ядер;
здесь мы считаем её известной.
Процесс: контейнер ресурсов, а не «запущенная программа»
Бытовое определение «процесс — это запущенная программа» вредно, потому что скрывает главное: процесс — это контейнер изолированных ресурсов, привязанный к одному адресному пространству. Программа (файл ELF/PE/Mach-O) пассивна; процесс активен и владеет:
- адресным пространством (
mm_structв Linux,vm_mapв BSD,task->mapв XNU); - таблицей файловых дескрипторов (
files_struct); - учётными данными: uid/gid/capabilities;
- обработчиками сигналов, рабочим каталогом, umask, лимитами
rlimit; - в современном Linux — ещё и набором namespace’ов и cgroup-принадлежностью (см. Безопасность и изоляция).
В классическом Unix ядро хранит это в структуре proc (плюс user-структура). В Linux
исторического разделения на «процесс» и «поток» в ядре просто нет: есть задача (task_struct),
а процесс — это группа задач с общим tgid. То, что POSIX называет PID, в Linux — это tgid,
а gettid() возвращает настоящий идентификатор задачи.
$ ls /proc/self/task/ # все потоки текущего процесса
$ grep -E 'State|Tgid|^Pid' /proc/self/status
State: R (running)
Tgid: 4097180 # ← это то, что getpid() вернёт всем потокам
Pid: 4097180 # ← это gettid(): у каждого потока свой
task_struct — структура на пару килобайт с сотней полей: состояние, sched_entity для
планировщика, указатели на mm, files, signal, ссылки на родителя и детей, стек ядра,
статистика. Каждый поток имеет свой экземпляр.
Поток: контекст исполнения внутри контейнера
Поток — это то, что реально планируется: набор регистров, счётчик команд, стек пользователя, стек ядра и TLS. Всё остальное он делит с «братьями».
Практические следствия этой картинки, которые ловят людей на собеседованиях и в проде:
errno— не глобальная переменная, а TLS-макрос (*__errno_location()), иначе многопоточность была бы невозможна;- закрытие fd в одном потоке мгновенно ломает его во всех — таблица дескрипторов общая;
- сегфолт в одном потоке убивает весь процесс, потому что общее адресное пространство
повреждено, а сигнал
SIGSEGVдоставляется в контексте процесса; - маска сигналов у каждого потока своя (
pthread_sigmask), а вот обработчики — общие; отсюда классический рецепт: заблокировать сигналы во всех потоках и обрабатывать их одним выделенным черезsigwaitinfo()илиsignalfd().
Модели потоков: 1:1, N:1, M:N
Как отображать пользовательские потоки на планируемые ядром сущности — старый спор.
| Модель | Кто планирует | Примеры | Плата |
|---|---|---|---|
| N:1 (green threads) | только userspace | ранняя Java (Solaris green threads), Ruby до 1.9 | блокирующий syscall останавливает все потоки; нет параллелизма на SMP |
| 1:1 | ядро | Linux NPTL, FreeBSD libthr, Windows, macOS pthreads | переключение = вход в ядро; тысячи потоков дороги по памяти |
| M:N | оба | Solaris LWP, FreeBSD KSE, Go, Java Virtual Threads (Loom), Erlang BEAM | сложность; двойное планирование конфликтует |
История поучительна. Linux прошёл путь от LinuxThreads к NPTL — см. документ Ulrich Drepper
и Ingo Molnar «The Native POSIX Thread Library for Linux».
FreeBSD пыталась в M:N через KSE, признала это тупиком и в 7.0 перешла на 1:1 (libthr);
Solaris убрала M:N в 9-й версии по той же причине: ядро и рантайм принимают решения независимо
и мешают друг другу. При этом M:N вернулся — но целиком в userspace, поверх 1:1: горутины Go,
виртуальные потоки Java, процессы Erlang. Теперь рантайм не спорит с ядром, а строит свой
планировщик над пулом обычных потоков ОС (Go-шный M:P:G с work-stealing разобран в
треке Go).
Рождение и смерть: fork, exec, clone
Unix разделил «создать процесс» и «загрузить программу» — решение, которое до сих пор
обсуждают. fork() создаёт почти точную копию, execve() заменяет образ.
pid_t pid = fork(); // копия адресного пространства через copy-on-write
if (pid == 0) {
execlp("ls", "ls", "-l", (char *)NULL); // заменяем образ; возврата не будет
_exit(127); // сюда попадём только если exec не удался
}
int status;
waitpid(pid, &status, 0); // без wait потомок останется зомби
printf("потомок %d вышел с кодом %d\n", pid, WEXITSTATUS(status));
Что происходит внутри: ядро копирует task_struct, но не копирует память — таблицы
страниц помечаются read-only, и физические страницы разделяются до первой записи (copy-on-write).
Подробности механики COW — в Управлении памятью.
Проблема в том, что COW-fork() в 2020-х — дорогая абстракция. Копирование таблиц страниц
процесса на 40 ГиБ занимает десятки миллисекунд; fork() в многопоточной программе копирует
только вызывающий поток, оставляя мьютексы, захваченные несуществующими потоками, — поэтому
между fork() и exec() разрешены только async-signal-safe функции (malloc — не из них).
Отличный разбор — статья «A fork() in the road»
(Baumann, Appavoo, Krieger, Roscoe, HotOS 2019).
Правильные ответы сегодня — posix_spawn(3) (единая операция «создать и запустить», внутри
glibc использует CLONE_VFORK), vfork(2) (потомок разделяет память с родителем и блокирует
его до exec) и clone3(2) (низкоуровневый интерфейс Linux с явными флагами). В Windows
аналога fork нет вообще: CreateProcess() сразу принимает путь к образу, а наследование
настраивается явно — WSL1 пришлось эмулировать fork через NtCreateProcess с клонированием
секций.
$ strace -f -e trace=clone3 ./threaded_app 2>&1 | head -2
clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM
|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, ...}) = 4097201
Обратите внимание: создание потока и создание процесса — один и тот же системный вызов, разница только в наборе флагов. Это и есть «Linux не различает процессы и потоки».
Состояния задачи
D (uninterruptible sleep) — задача ждёт в ядре на ресурсе, где нельзя корректно откатить
операцию (типично — блочный I/O или NFS). Её нельзя убить даже kill -9, пока I/O не завершится;
массовое залипание в D — почти всегда проблема хранилища. Компромисс TASK_KILLABLE
(D + реакция на фатальные сигналы) используют, например, NFS и mutex_lock_killable().
Z (зомби) — процесс завершился, но запись в таблице жива, потому что родитель не забрал
код возврата. Зомби не потребляет ни памяти, ни процессора — только PID; утечка зомби всегда
означает баг в родителе: нет wait(), нет обработчика SIGCHLD, не выставлен SIG_IGN.
Осиротевшие процессы переприписываются к init (PID 1) — или, в Linux с 3.4, к ближайшему
предку с PR_SET_CHILD_SUBREAPER; на этом строятся супервизоры и контейнерные рантаймы
(см. Системы инициализации).
Переключение контекста: за что мы платим
Переключение контекста — не «сохранить регистры и загрузить другие»: полная цена складывается из прямой и косвенной частей.
Прямая цена (0.5–2 мкс на современном x86-64) — вход в ядро с сохранением pt_regs
на стек ядра; сохранение расширенного состояния FPU/AVX-512 через xsave (до 2.5 КиБ,
отсюда оптимизации xsaveopt/xsaves); выбор следующей задачи (pick_next_task);
switch_mm() — смена таблицы страниц (запись в CR3 на x86), если процесс другой;
и наконец switch_to() — смена стека ядра и регистров.
Косвенная цена (часто в 10–100 раз выше прямой): холодные L1/L2, вымытый TLB,
испорченные предсказатели ветвлений. Смена mm без PCID/ASID означала полный сброс TLB;
поэтому x86-64 (PCID), ARM (ASID) и другие добавили теги адресного пространства.
Отдельная беда — KPTI (митигация Meltdown), которая вернула сброс TLB на каждый переход
в ядро на уязвимых процессорах; на некоторых нагрузках это стоило 5–30% производительности.
# Сколько раз задача переключалась и сколько из этого — вытеснения
$ grep -E 'nr_switches|voluntary' /proc/self/sched
nr_switches : 128443
nr_voluntary_switches : 127998 # сама ушла в сон (обычно на I/O)
nr_involuntary_switches : 445 # выгнал планировщик
Соотношение voluntary/involuntary — быстрая диагностика. Много involuntary → задача CPU-bound и конкурирует за ядро. Много voluntary → она в основном ждёт.
Заблуждение. «Переключение потоков внутри процесса бесплатное». Оно дешевле на один
switch_mm()(TLB и кэш частично живут), но всё равно требует входа в ядро. Именно поэтому рантаймы вроде Go и Loom переключают свои корутины в userspace — там переключение стоит десятки наносекунд, потому что это просто смена стека и нескольких регистров.
Чего вообще хочет планировщик
Целей несколько, и они прямо конфликтуют. Throughput требует длинных квантов: меньше переключений, теплее кэш. Latency требует ровно обратного — коротких квантов и агрессивного вытеснения проснувшихся задач. Справедливость — чтобы никто не голодал и доли соответствовали весам. Соблюдение сроков для real-time — это вообще не справедливость, а гарантия. Энергия и топология — на ноутбуке и на big.LITTLE выбор ядра важнее выбора задачи. Универсального оптимума нет; отсюда — не «один правильный планировщик», а классы политик и настраиваемые параметры.
Классика, которую надо знать
- FCFS/FIFO — просто и катастрофично: одна долгая задача блокирует всех (convoy effect).
- SJF/SRTF — оптимален по среднему времени отклика, но требует знать длительность заранее.
- Round-Robin — квант по кругу; вся сложность в выборе кванта (мал → накладные расходы, велик → латентность).
- MLFQ — эвристика «мы не знаем длительность задачи, но можем её наблюдать»: новая задача
идёт в верхнюю очередь; израсходовала квант целиком — опускается; уснула рано — остаётся
наверху. Интерактивность получается без хинтов от программиста. Основа классического Unix,
Solaris, а сегодня — Windows, FreeBSD и OpenBSD. Разбор — глава MLFQ в
OSTEP. Её главная болезнь —
гейминг:
sleep(1мкс)перед концом кванта вечно держит задачу наверху; лечится периодическим бустом всех задач и учётом суммарного потребления. - Lottery / stride scheduling (Waldspurger, 1994) — пропорциональное разделение через «билеты». Идейный предок fair-share планировщиков.
Эволюция планировщика Linux
Стоит понимать, почему CFS был революцией. До него планировщик пытался угадать, является ли
задача интерактивной, по эвристикам сна. Ingo Molnar предложил другую формулировку: представим
идеальный процессор, исполняющий все N задач одновременно со скоростью 1/N каждую; реальный
планировщик просто выбирает задачу, которая сильнее всего отстала от этого идеала. Эвристики
исчезли — интерактивность стала следствием модели: задача, которая много спала, автоматически
отстала и получит процессор первой. Учёт ведётся в виртуальном времени:
vruntime += реальное_время * (NICE_0_WEIGHT / вес_задачи). Вес берётся из таблицы
sched_prio_to_weight[]: nice 0 → 1024, каждый шаг nice умножает вес примерно на 1.25
(nice −20 → 88761, nice +19 → 15). Практическое правило: разница в один nice даёт около 10%
процессорного времени, а разница в 5 пунктов — примерно втрое.
EEVDF: что изменилось в 6.6
CFS хорошо делил пропускную способность, но плохо выражал латентность: «эта задача может занимать 5% CPU, но когда она просыпается — пустите её немедленно» сказать было нечем. EEVDF (Earliest Eligible Virtual Deadline First) решает обе задачи одной моделью. Алгоритм — из работы Ion Stoica и Hussein Abdel-Wahab, TR-95-22, Old Dominion University, 1995; в Linux его принёс Peter Zijlstra, см. разбор на LWN: «An EEVDF CPU scheduler for Linux».
Три понятия:
- lag задачи — разница между тем, что она должна была получить по справедливости, и тем, что получила. Сумма lag по всем задачам равна нулю по построению.
- eligible — задача с неотрицательным lag (ещё не «переела»). Только такие рассматриваются.
- виртуальный дедлайн
vd = ve + slice/вес— когда задача должна закончить свой квант.
Правило: из eligible-задач выбираем ту, у которой минимальный виртуальный дедлайн.
Ключевой практический подарок: квант (slice) стал свойством задачи, а не глобальной
настройкой. Задача может попросить короткий квант — тогда её дедлайн ближе, её выберут раньше,
но и бежать она будет меньше. Латентность покупается за пропускную способность честно и явно,
через sched_setattr(2) с sched_runtime в политике SCHED_OTHER:
// Попросить у EEVDF короткий квант: «мне важна отзывчивость, не throughput»
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_OTHER,
.sched_flags = 0x08, /* SCHED_FLAG_KEEP_PARAMS — nice не трогаем */
.sched_runtime = 300000, /* желаемый квант, нс — 0.3 мс */
};
syscall(SYS_sched_setattr, 0, &attr, 0);
Убедиться, что вы на EEVDF, можно прямо в /proc; глобальные ручки при этом переехали
из sysctl в debugfs:
$ grep -E 'se.slice|policy|prio' /proc/self/sched
policy : 0 # SCHED_OTHER
prio : 120 # 120 = nice 0
se.slice : 2800000 # ← персональный квант, нс. Поля нет в старом CFS
$ cat /sys/kernel/debug/sched/base_slice_ns
3000000 # базовый квант, 3 мс
Заблуждение. «Надо покрутить
sched_min_granularity_nsдля отзывчивости». Этих sysctl больше нет — они удалены вместе с CFS в 6.6. Гайды, которые их советуют, устарели минимум на два года.
Классы политик: кто важнее кого
Linux выбирает следующую задачу не одним алгоритмом, а цепочкой классов — строго по приоритету.
есть задача?"} B -->|да| B1["migration/N — вытесняет ВСЁ
(миграция, hotplug)"] B -->|нет| C{"dl_sched_class:
SCHED_DEADLINE?"} C -->|да| C1["Ближайший абсолютный дедлайн
(EDF + Constant Bandwidth Server)"] C -->|нет| D{"rt_sched_class:
FIFO / RR?"} D -->|да| D1["Высший приоритет 1..99
FIFO: до блокировки; RR: квант 100 мс"] D -->|нет| E{"fair_sched_class:
OTHER / BATCH / IDLE?"} E -->|да| E1["EEVDF: eligible + min virtual deadline"] E -->|нет| F{"ext_sched_class:
загружен BPF-планировщик?"} F -->|да| F1["sched_ext: логика из BPF-программы"] F -->|нет| G["idle_sched_class:
swapper — уснуть в C-state"]
Что это значит на практике:
SCHED_FIFO/SCHED_RR(приоритеты 1–99) вытесняют вообще всё «обычное». ЗадачаSCHED_FIFOс бесконечным циклом заблокирует ядро намертво — от этого спасает только RT-throttling: по умолчанию RT-классу отдаётся 950 мс из каждой секунды.
$ sysctl kernel.sched_rt_runtime_us kernel.sched_rt_period_us
kernel.sched_rt_runtime_us = 950000 # 95% секунды RT-классу; 5% — гарантия остальным
kernel.sched_rt_period_us = 1000000
$ chrt -f 50 ./low_latency_daemon # запустить как FIFO с приоритетом 50
$ chrt -p $$ # политика своего шелла: SCHED_OTHER, prio 0
SCHED_DEADLINE(с 3.14) — не про приоритеты, а про контракт: «дай мнеruntimeнаносекунд каждыеperiod, к дедлайнуdeadline». Ядро выполняет admission control: если суммаruntime/periodпо всем задачам превысит ёмкость системы,sched_setattr()вернётEBUSY. Внутри — Constant Bandwidth Server поверх глобального EDF; задача, превысившая бюджет, дросселируется до следующего периода, а не крадёт время у других. Официальная документация: sched-deadline.rst.
// Задача аудио-микшера: 2 мс работы каждые 10 мс, дедлайн = период
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 2 * 1000 * 1000, /* 2 мс */
.sched_deadline = 10 * 1000 * 1000, /* 10 мс */
.sched_period = 10 * 1000 * 1000,
};
if (syscall(SYS_sched_setattr, 0, &attr, 0) < 0)
perror("sched_setattr"); // EBUSY = система не может гарантировать этот бюджет
SCHED_BATCH— «я CPU-bound, не считай меня интерактивным»: подавляет wakeup-вытеснение, кванты длиннее, кэш теплее. Хорошо для сборки, ETL, компиляции.SCHED_IDLE(nice-подобный вес 3) — бежит только когда больше некому. Идеально для фоновой индексации и бэкапов.
SMP: где именно бежать — важнее, чем когда
На многоядерной машине у каждого CPU своя очередь готовых задач: глобальная очередь не масштабируется, один спинлок на 128 ядер убьёт систему. Но per-CPU очереди создают новую проблему — дисбаланс, а чинится он миграцией, которая стоит дорого: холодный кэш, а на NUMA ещё и удалённая память. Linux решает это иерархией scheduling domains, повторяющей топологию железа:
балансировка редкая, порог дисбаланса высокий
миграция = удалённая память, ~2x latency"] NUMA --> P0["Пакет / LLC-домен, сокет 0
общий L3 — миграция терпима"] NUMA --> P1["Пакет / LLC-домен, сокет 1"] P0 --> C0["MC: ядро 0
общий L2"] P0 --> C1["MC: ядро 1"] C0 --> S0["SMT: CPU0"] C0 --> S1["SMT: CPU1
тот же исполнительный конвейер"] C1 --> S2["SMT: CPU2"] C1 --> S3["SMT: CPU3"] S0 -.->|"миграция почти бесплатна:
кэш общий"| S1 S2 -.->|"балансировка каждые
несколько мс"| S3
Чем выше домен, тем реже балансировка и тем больший дисбаланс терпится — это прямое отражение цены миграции. Отдельная важная механика — wake affinity: когда задача A будит задачу B, ядро пытается запустить B рядом с A (в одном LLC), потому что они, скорее всего, обмениваются данными. Эвристика полезная, но именно она даёт «загадочные» регрессии: producer/consumer-пары слипаются на одном ядре, пока остальные простаивают.
$ lscpu | grep -Ei 'model name|thread|core|socket|numa'
Model name: AMD EPYC 9354 32-Core Processor
Thread(s) per core: 1
Core(s) per socket: 8
NUMA node0 CPU(s): 0-7
$ cat /sys/devices/system/cpu/cpu0/topology/core_cpus_list # SMT-братья данного CPU
0
$ taskset -c 2,3 ./worker # привязать к CPU 2,3 (sched_setaffinity)
$ numactl --cpunodebind=0 --membind=0 ./db # CPU и память из одного NUMA-узла
Для действительно latency-critical нагрузок используют полную изоляцию: параметры ядра
isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7 убирают ядра из общей балансировки, выключают
периодический тик на них (если бежит ровно одна задача) и уносят RCU-колбэки на другие CPU.
Итог — можно получить джиттер в единицы микросекунд вместо десятков.
Группы, веса и квоты: cgroup v2
Голая справедливость «по задачам» ломается тривиально: процесс, породивший 100 потоков, получит в 100 раз больше процессора, чем однопоточный. Отсюда иерархическое планирование — сначала делим между группами, потом внутри группы.
$ cat /proc/self/cgroup
0::/user.slice/user-1007.slice/session-11986.scope
$ cat /sys/fs/cgroup/user.slice/cpu.weight # относительный вес 1..10000, аналог nice
100
$ cat /sys/fs/cgroup/user.slice/cpu.max # "квота период"; max = без ограничения
max 100000
$ grep -E 'usage_usec|nr_throttled|throttled_usec' /sys/fs/cgroup/user.slice/cpu.stat
usage_usec 1268811438418
nr_throttled 0 # ← вот этот счётчик надо мониторить
throttled_usec 0
Здесь живёт самая дорогая для бизнеса ошибка в проде — CPU throttling в Kubernetes.
limits.cpu: "1" превращается в cpu.max = 100000 100000: 100 мс процессорного времени
на каждые 100 мс реального. Но если у вас 8 потоков, они выберут этот бюджет за 12.5 мс —
и оставшиеся 87.5 мс весь контейнер стоит. Средняя загрузка при этом честно покажет 100%
от лимита, а p99 улетит в стратосферу. Растущий nr_throttled — верный признак, что вы
упёрлись в лимит, а не в процессор. Лечится тремя способами: убрать limits, оставив
requests (веса и так дают справедливость под нагрузкой); ограничить параллелизм рантайма
под лимит (GOMAXPROCS, размеры пулов — Go 1.25 научился учитывать cgroup-лимит сам, до этого
нужна была automaxprocs); включить cpu.max.burst (Linux 5.14+). Отмечу и исторический
баг: до Linux 5.4 распределение квоты между CPU теряло неиспользованные кусочки и дросселировало
задачи, не выбравшие лимит. Документация:
CFS Bandwidth Control.
sched_ext: планировщик как BPF-программа
С 6.12 в ядре есть sched_ext — фреймворк, позволяющий загрузить свой планировщик как
BPF-программу, без пересборки ядра, с автоматическим откатом на EEVDF, если программа зависла
(сторожевой таймер) или вы её просто выгрузили: sudo scx_lavd — запустить, pkill scx_lavd —
вернуться назад. Это меняет экономику экспериментов: раньше идея планировщика требовала форка
ядра и года споров в LKML, теперь — вечера. В репозитории scx
живут scx_rusty (топология-aware, часть логики на Rust в userspace), scx_lavd
(latency-aware, используется в SteamOS), scx_flash, scx_bpfland. Документация:
sched-ext.rst.
Как это устроено в других ОС
FreeBSD: ULE
Планировщик по умолчанию с 7.1 — ULE (Jeff Roberson, ULE: A Modern Scheduler for FreeBSD,
BSDCon 2003).
Это MLFQ-подход, а не fair-share: считается interactivity score из отношения времени сна
к времени исполнения; задачи со счётом ниже порога (30) считаются интерактивными и получают
приоритетный класс. Есть per-CPU очереди, топология-aware балансировка и явное «приклеивание»
к CPU ради кэша; альтернатива SCHED_4BSD осталась в дереве. Смотрится всё через
sysctl kern.sched (kern.sched.name: ULE, kern.sched.quantum, kern.sched.preempt_thresh),
привязка — cpuset -l 2,3 -c ./worker, RT-приоритеты — rtprio(1).
OpenBSD и NetBSD
OpenBSD сознательно консервативна: классический BSD-планировщик с 32 очередями по
приоритетам и пересчётом в schedcpu() раз в секунду по накопленному p_estcpu. Никакого
fair-share и дедлайн-классов; SMP-масштабируемость приносится в жертву простоте и аудируемости
кода — осознанная позиция проекта (подробнее — в Семействе BSD).
NetBSD с 5.0 даёт модульный выбор: SCHED_M2 (по умолчанию, per-CPU очереди) или
SCHED_4BSD, настройка через sysctl kern.sched.*.
macOS / XNU
XNU унаследовал планировщик от Mach: 128 уровней приоритета, разбитых на полосы —
normal (0–63), kernel (64–95), realtime (96–127). Но прикладной программист с ними почти
не работает: интерфейс — это QoS-классы (QOS_CLASS_USER_INTERACTIVE, USER_INITIATED,
DEFAULT, UTILITY, BACKGROUND), которые указываются при создании очереди GCD через
dispatch_queue_attr_make_with_qos_class(). На Apple Silicon это критично: QoS определяет
не только приоритет, но и на каком кластере ядер задача побежит — BACKGROUND
практически гарантированно уходит на E-ядра. Управляет этим CLPC (Closed Loop Performance
Controller), связывающий thread groups, QoS и DVFS. Для жёсткого real-time (CoreAudio) есть
thread_policy_set() с THREAD_TIME_CONSTRAINT_POLICY — по сути аналог SCHED_DEADLINE.
Windows NT
Классический вытесняющий MLFQ с 32 уровнями: 0 (zero page thread), 1–15 — динамический диапазон, 16–31 — real-time (без динамических бустов). Приоритет потока = класс приоритета процесса + относительный приоритет потока.
Особенности, которых нет в Unix: priority boosts (завершение I/O, снятие ожидания на событии и поток переднего окна получают временную прибавку и утроенный квант); Balance Set Manager, раз в секунду сканирующий готовые потоки и поднимающий до 15 тех, кто ждёт дольше ~4 секунд (антиголодание); ideal processor — «мягкая» привязка к ядру; разные кванты на клиенте (~2 тика) и сервере (~12). Современные версии интегрированы с Intel Thread Director и EcoQoS. Канонический источник — Russinovich et al., Windows Internals, Part 1, 7th ed.
Инверсия приоритетов — и почему это не абстракция
Классический сценарий: низкоприоритетный поток L захватил мьютекс; высокоприоритетный H ждёт этот мьютекс; появляется среднеприоритетный M и вытесняет L. Итог: H ждёт M — приоритеты фактически инвертированы.
H заблокирован потоком ВДВОЕ меньшего приоритета Note over K: С PTHREAD_PRIO_INHERIT ядро временно
поднимает L до 90 → L добегает и отпускает m K-->>L: приоритет унаследован = 90 L->>K: pthread_mutex_unlock(m) K-->>H: разблокирован, бежит
Это не учебный пример: в 1997 году марсоход Mars Pathfinder регулярно перезагружался на Марсе именно из-за инверсии приоритетов в VxWorks — наследование приоритетов было доступно, но выключено. Исправление залили патчем на 200 миллионов километров. В Linux лечится PI-футексами:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
// Наследование приоритета: держатель временно получает приоритет ждущего
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&m, &attr);
Работает это только вместе с RT-политиками (SCHED_FIFO/SCHED_RR); для SCHED_OTHER
аналогом служит то, что EEVDF и так поднимает отставшую задачу. Подробности механики
futex — в Системных вызовах и IPC.
Практикум: как измерять, а не гадать
# 1. Кто и сколько ждал в очереди готовности (гистограмма latency, bcc/bpftrace)
$ sudo runqlat -m 5 1
msecs : count distribution
0 -> 1 : 148291 |****************************************|
2 -> 3 : 1204 | |
4 -> 7 : 87 | |
128 -> 255 : 3 | | ← хвост
# 2. Кто кого вытеснял и сколько это стоило
$ sudo perf sched record -- sleep 5 && sudo perf sched latency --sort max | head -8
# 3. Давление по CPU: сколько времени задачи ЖДАЛИ процессор (PSI, ядро 4.20+)
$ cat /proc/pressure/cpu
some avg10=3.18 avg60=2.89 avg300=3.83 total=32025480446
# ^ 3.18% времени за последние 10 с хотя бы одна задача ждала CPU
# 4. Статистика по задаче: ns на CPU | ns в очереди | число квантов
$ sudo sysctl kernel.sched_schedstats=1 && cat /proc/1234/schedstat
563114 21044 1
# 5. Джиттер для realtime-нагрузок
$ sudo cyclictest -m -p 80 -t 4 -i 200 -D 60
T: 0 ( 4321) P:80 I:200 C: 300000 Min: 2 Act: 3 Avg: 4 Max: 47
Порядок диагностики «сервис тормозит»: /proc/pressure/cpu (есть ли давление вообще) →
cpu.stat cgroup (не дросселируем ли себя лимитами) → runqlat (латентность очереди
и длина хвоста) → nr_involuntary_switches (конкуренция или ожидание) → perf sched latency
(кто именно вытесняет).
Заблуждение про load average. В Linux load average включает задачи в состоянии
D(непрерываемый сон), то есть смешивает «ждут CPU» и «ждут диск». LA 40 на 8 ядрах может означать, что процессор простаивает, а умирает хранилище. Разбор истории этого решения — Brendan Gregg, «Linux Load Averages: Solving the Mystery».
Типичные заблуждения — сводка
- «Больше потоков — быстрее.» После насыщения ядер потоки лишь добавляют переключений, давления на кэш и конкуренции за блокировки. Для CPU-bound оптимум — около числа ядер.
- «nice влияет на приоритет ввода-вывода.» Нет,
nice— только CPU. Для диска естьionice(1)иio.weightв cgroup v2. - «
sched_yield()поможет.» ВSCHED_OTHERего семантика почти бессмысленна: задача просто теряет своё преимущество.sched_yield(2)прямо не рекомендует его вне RT-политик. - «Приоритеты потоков в Java/Python работают.»
Thread.setPriority()на Linux по умолчанию не делает ничего, в CPython приоритетов вообще нет. - «Real-time значит быстро.» Real-time значит предсказуемо:
SCHED_FIFOобычно снижает пропускную способность, но даёт ограниченный сверху джиттер. - «CPU limit безопаснее, чем request.» В большинстве серверных сценариев
requests(веса) достаточно, аlimitsдобавляет только дросселирование и хвосты латентности. - «Планировщик планирует процессы.» Он планирует потоки/задачи; процесс — единица владения ресурсами, а не планирования.
Мини-итог
- Процесс — контейнер ресурсов (память, fd, права); поток — контекст исполнения. В Linux
и то и другое
task_struct, разница только во флагахclone(). - Переключение контекста стоит 0.5–2 мкс напрямую и куда больше косвенно, через кэш и TLB. Поэтому userspace-корутины выигрывают там, где нужен массовый параллелизм.
- Цели планировщика противоречивы (throughput, латентность, справедливость, дедлайны), поэтому в ядре не один алгоритм, а иерархия классов.
- Linux прошёл путь O(n) → O(1) → CFS → EEVDF; современная модель выражает и справедливость
(через lag), и латентность (через персональный
slice). - На SMP «где бежать» важнее «когда бежать»: домены планирования, wake affinity, NUMA,
изоляция ядер. Поверх этого cgroup v2 даёт иерархическое разделение — и самую частую
продовую ошибку, дросселирование по
cpu.max. - Другие ОС решают ту же задачу иначе: ULE во FreeBSD и MLFQ в Windows — эвристические, QoS в macOS — декларативный, OpenBSD — намеренно простой.
Источники
- Arpaci-Dusseau, Operating Systems: Three Easy Pieces — главы про планирование, MLFQ и proportional share, бесплатно: https://pages.cs.wisc.edu/~remzi/OSTEP/
- Robert Love, Linux Kernel Development, 3rd ed., гл. 3–4; McKusick et al., The Design and Implementation of the FreeBSD OS, 2nd ed., гл. 4; Russinovich et al., Windows Internals, Part 1, 7th ed., гл. 4; Brendan Gregg, Systems Performance, 2nd ed., гл. 6.
- Stoica & Abdel-Wahab, Earliest Eligible Virtual Deadline First, TR-95-22, 1995; Baumann et al., A fork() in the road, HotOS 2019.
- Документация ядра: https://docs.kernel.org/scheduler/index.html
- man-страницы:
sched(7),pthreads(7),clone(2),sched_setattr(2),chrt(1),taskset(1),cpuset(1)(FreeBSD).
Что дальше
Планировщик решает, когда задача бежит. Но каждая её инструкция обращается к памяти — и то, как ядро создаёт иллюзию бесконечного приватного адресного пространства поверх конечной физической RAM, определяет производительность не меньше, чем выбор кванта. Дальше — Управление памятью: виртуальная память, страницы, TLB, swap, аллокаторы.