Операционные системы Процессы, потоки и планировщики
0%

Процессы, потоки и планировщики

Процессы, потоки и планировщики

У вас 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-задач выбираем ту, у которой минимальный виртуальный дедлайн.

EEVDF: lag, 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 выбирает следующую задачу не одним алгоритмом, а цепочкой классов — строго по приоритету.

Что это значит на практике:

  • 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, повторяющей топологию железа:

Чем выше домен, тем реже балансировка и тем больший дисбаланс терпится — это прямое отражение цены миграции. Отдельная важная механика — 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 — приоритеты фактически инвертированы.

Это не учебный пример: в 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».

Типичные заблуждения — сводка

  1. «Больше потоков — быстрее.» После насыщения ядер потоки лишь добавляют переключений, давления на кэш и конкуренции за блокировки. Для CPU-bound оптимум — около числа ядер.
  2. «nice влияет на приоритет ввода-вывода.» Нет, nice — только CPU. Для диска есть ionice(1) и io.weight в cgroup v2.
  3. «sched_yield() поможет.» В SCHED_OTHER его семантика почти бессмысленна: задача просто теряет своё преимущество. sched_yield(2) прямо не рекомендует его вне RT-политик.
  4. «Приоритеты потоков в Java/Python работают.» Thread.setPriority() на Linux по умолчанию не делает ничего, в CPython приоритетов вообще нет.
  5. «Real-time значит быстро.» Real-time значит предсказуемо: SCHED_FIFO обычно снижает пропускную способность, но даёт ограниченный сверху джиттер.
  6. «CPU limit безопаснее, чем request.» В большинстве серверных сценариев requests (веса) достаточно, а limits добавляет только дросселирование и хвосты латентности.
  7. «Планировщик планирует процессы.» Он планирует потоки/задачи; процесс — единица владения ресурсами, а не планирования.

Мини-итог

  • Процесс — контейнер ресурсов (память, 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, аллокаторы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов