Learning Platform
Глоссарий Troubleshooting
Урок 06.01 · 18 мин
Начальный
SchedulerLinuxCPUContext switchTime slice

Что делает планировщик — 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 отдан другим процессам.

Иллюзия параллелизма -- быстрое переключение
Process AПолучил CPU на 4 мс. Выполняет свой код, кажется ему, что CPU полностью его
Process BПолучил CPU на 4 мс
Process AСнова получил CPU. Программа не заметила, что её 4 мс не было -- она 'спала' с точки зрения CPU
Process CТретий процесс получил свой кусок
Время от программыС точки зрения программы A -- она работала непрерывно. Wall clock и CPU time у неё разные. Через clock_gettime(CLOCK_MONOTONIC) -- видит реальное время; через clock_gettime(CLOCK_PROCESS_CPUTIME_ID) -- только своё CPU-время
Время реальноеWall clock. От старта системы. 12 мс прошло -- но A получил только 8 мс CPU. Разница 4 мс -- спала в очереди

В одно ядро в каждый момент времени работает один процесс. На 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.

Preemptive vs cooperative
CooperativeDOS, Windows 3.x. Процесс сам решает, когда отдать CPU. Если зациклится -- всё, система висит
yield()Программа явно говорит 'я готов отдать CPU'. В Python -- await, в Go -- runtime.Gosched()
Risk: hangЕсли процесс забудет yield или зациклится -- остальные не получат CPU. Кооператив требует доверия
PreemptiveLinux, Windows NT+. Kernel сам отбирает CPU через таймерные прерывания. Программе нечего делать -- её просто остановят посередине инструкции
Timer interruptКаждые ~1-10 мс таймер посылает прерывание в CPU. Kernel получает контроль, решает: продолжить текущий процесс или переключить
RobustОдин зависший процесс не положит систему -- его принудительно вытеснят. Это и есть надёжность multitasking

В 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 — число активных процессов.

Time slice = sched_latency / N
1 active processОдин процесс, time slice = 6 мс. Но переключаться некуда -- работает всё время
2 processesTime slice = 3 мс на каждого. Переключения каждые 3 мс
6 processesTime slice = 1 мс на каждого. Чаще переключения, выше overhead
100 processesTime slice = 60 мкс? Нет -- CFS включает min_granularity (~0.75 мс), чтобы не было слишком частых переключений

Логика: при малом числе процессов давать им большие куски (низкий 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 -- что делает kernel
1. Save A's stateСохранить все регистры процесса A (RAX, RBX, ... RIP, RFLAGS, segment registers) в его task_struct. ~30-50 регистров на x86_64
2. Load B's stateЗагрузить регистры процесса B из его task_struct
3. Switch address spaceЕсли B -- другой процесс (не поток), сменить CR3 (указатель на корень page tables). Это вызывает частичный TLB flush
4. TLB flush impactПосле TLB flush первые сотни обращений к памяти будут медленнее (нужно идти в page tables, ~100 ns vs ~1 ns из TLB)
5. Cache pollutionL1, L2 cache содержат данные процесса A. Процесс B будет получать cache misses, пока не загрузит свои данные. Это тоже стоимость переключения

Стоимость 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 часто его вытесняет.

top и htop: живое наблюдение за планировщиком

Process state machine — кто борется за CPU

Не все процессы готовы к выполнению. Только в state R (Running/Runnable) процесс попадает в runqueue и борется за CPU.

Состояния процессов в Linux
R (Running)Сейчас выполняется на CPU ИЛИ готов выполняться и ждёт в runqueue. ps не различает -- оба показывает как R
S (Sleeping)Interruptible sleep. Ждёт события: I/O, мьютекс, таймер, сигнал. Не борется за CPU. Большинство процессов всегда здесь
D (Disk sleep)Uninterruptible sleep. Обычно ждёт I/O, причём kernel не может прервать (важная операция с диском или сетью). Нельзя убить даже kill -9, пока не закончится I/O
Z (Zombie)Процесс умер, но родитель ещё не вызвал wait() для прочтения exit code. Не выполняется, просто зомби-запись в process table
T (Stopped)Остановлен сигналом SIGSTOP (или Ctrl-Z в shell). Можно вернуть через SIGCONT (fg в shell)
X (Dead)Технически уже не существует, ps его не показывает. Промежуточное состояние при exit

Только 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 runqueue в Linux
CPU 0Свой runqueue. Свои локальные процессы. Свой scheduler tick
CPU 1Свой runqueue
CPU 2Свой runqueue
CPU 7..
rq[0]: [P1, P5, P12]Локальная очередь готовых процессов на CPU 0
rq[1]: [P2, P7]Очередь на CPU 1
rq[2]: [P3, P8, P14, P19]Очередь на CPU 2 -- больше всех, надо балансировать
rq[7]: [P4]Только один процесс. Надо забрать кого-то с rq[2]
Load balancingРаз в N мс kernel смотрит загрузку CPU -- если один сильно загружен, переносит процесс на менее загруженный (migration). Это дорого -- кэши придётся перезагружать

Почему 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 -- перегрузка

Проверка знанийKnowledge check
Junior спрашивает: 'У меня load average = 12 на 8-core машине. CPU usage в top показывает 100%. Это нормально или проблема?'
ОтветAnswer
Это означает: система перегружена. Давай разберём. Load average -- это среднее число процессов в state R (Running) или D (Disk wait) за последние 1/5/15 минут. Принятая интерпретация: - LA <= число cores: система не перегружена, всё ок - LA == число cores: ровно полная загрузка - LA > число cores: есть очередь -- процессы ждут CPU У вас LA=12 на 8 cores -- значит в среднем 4 процесса всегда стоят в очереди на CPU, ждут своей time slice. Это перегрузка. CPU usage 100% подтверждает: все 8 cores заняты. Но в очереди ещё 4 -- они получают CPU реже, работают медленнее. Что делать: 1. Найти горячие процессы: top -o %CPU или ps -eo pid,%cpu,comm --sort -%cpu | head. 2. Понять, что-то одно или много мелких. Если 50 процессов по 16% CPU -- может, неправильно распараллелено (слишком много worker'ов). Если 1 процесс 800% CPU (он использует все 8 cores) -- это CPU-bound bottleneck. 3. Различить I/O wait от CPU. Проверьте: vmstat 1 -- посмотрите колонку 'wa' (iowait). Если wa высокий (>10%) -- проблема не CPU, а диск. Тогда уменьшение CPU-нагрузки не поможет. 4. Если CPU реально не хватает -- увеличить число cores, уменьшить число workers, оптимизировать код, или использовать nice (модуль 5 урок 03) для приоритизации важных процессов. 5. Иногда D-state процессы пускают load в небо без реального CPU usage. ps -eo stat | grep ^D -- если много D, проблема в I/O, не CPU. Кстати, временное всплеск LA=12 -- норма (например, во время больших задач). Постоянный LA сильно выше числа cores -- сигнал что-то делать.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Чем preemptive multitasking отличается от cooperative?

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

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

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

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