Процессы, потоки и ядро ОС

Что происходит между вашим print() и железом: системные вызовы, fork и exec, сигналы, планировщик, потоки и GIL, load average и контейнеры.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Режим ядра и режим пользователя

Процессор умеет работать в двух режимах. Ваш код выполняется в пользовательском (на x86 — кольцо 3): привилегированные инструкции запрещены, доступна только своя память. Ядро работает в кольце 0 и может всё.

  • Попасть в ядро можно тремя путями: системный вызов, исключение (ошибка страницы, деление на ноль) и прерывание от устройства или таймера.
  • Так ОС защищает процессы друг от друга и делит между ними диск, сеть и процессор.

Выберите действие — увидите, где оно выполняется.

Системные вызовы: что видит strace

Системный вызов — единственная дверь из программы в ядро. Номер вызова и аргументы кладутся в регистры, инструкция syscall переключает процессор в режим ядра. В Linux около 400 вызовов, и любой ввод-вывод в любом языке сводится к ним.

strace показывает эти вызовы у работающей программы. Выберите строку на Python и пройдите по шагам.

Сколько стоит системный вызов

Вход в ядро и выход — это сотни наносекунд: сохранить регистры, проверить аргументы, а с защитой от Meltdown и Spectre ещё и сбросить часть состояния процессора. Обычный вызов функции — около наносекунды.

  • Поэтому ввод-вывод буферизуют: Python open() и Java BufferedOutputStream копят по 8 КБ, Go bufio.Writer — 4 КБ.
  • os.File.Write в Go, FileOutputStream в Java и fs.writeSync в Node не буферизуют: каждый вызов — системный.

Программа пишет в файл 100 МБ. Двигайте размер буфера.

Что такое процесс

Процесс — это запущенная программа вместе со всем, что ядро ей выделило: адресное пространство, открытые файлы, права, окружение. Внутри — один или несколько потоков, которые выполняют код.

Почти всё это видно в /proc/<PID>/. Нажмите на часть процесса.

fork, exec, wait

В Unix новый процесс появляется в два шага. fork() делает копию текущего процесса, execve() заменяет в копии программу. Между ними ребёнок настраивает себя: перенаправляет вывод, меняет каталог. Так shell выполняет ls > out.txt.

Копия дешёвая: страницы памяти общие и копируются только при записи (copy-on-write). Но таблицы страниц копируются сразу — у процесса на 10 ГБ fork занимает порядка 100 мс. Пройдите по шагам и попробуйте другие сценарии.

Сигналы

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

  • SIGKILL и SIGSTOP перехватить нельзя: процесс не узнает, что его убили, и не успеет ничего закрыть.
  • Код выхода после смерти от сигнала — 128 + номер: 137 — убит SIGKILL (часто это OOM killer), 143 — SIGTERM.
  • Обработчик должен быть коротким: выставить флаг и вернуться. Python вызывает его между байткодами главного потока, Go отдаёт сигнал в канал, Node — событием process.on('SIGTERM').
SIGTERM 15«Пожалуйста, завершись». Его шлют kill, docker stop, Kubernetes. По умолчанию — смерть. Ловите и завершайтесь аккуратно.
SIGINT 2Ctrl+C в терминале. В Python — исключение KeyboardInterrupt.
SIGKILL 9Немедленная смерть, перехватить нельзя. Через грейс-период после SIGTERM и от OOM killer.
SIGCHLD 17Ребёнок завершился или остановился. Сигнал родителю забрать статус через wait.
SIGHUP 1Терминал закрыт. Демоны по традиции перечитывают по нему конфиг (nginx).
SIGPIPE 13Запись в закрытый канал или сокет. По умолчанию убивает; Python его игнорирует и бросает BrokenPipeError.
SIGSEGV 11Обращение к чужой или несуществующей памяти. Ядро убивает процесс целиком, со всеми потоками.
SIGQUIT 3Java печатает дамп всех потоков и работает дальше, Go печатает стеки горутин и завершается.

Остановка контейнера и PID 1

docker stop шлёт SIGTERM процессу с PID 1 в контейнере, ждёт 10 секунд (Kubernetes — 30) и добивает SIGKILL. Есть две ловушки.

  • Скрипт-обёртка без exec: PID 1 — это shell, и сигнал до приложения не доходит.
  • Ядро не доставляет PID 1 сигналы, для которых нет обработчика. Приложение без обработчика SIGTERM в роли PID 1 его просто не заметит. А ещё PID 1 обязан забирать осиротевших зомби.

Решение: exec в скрипте, обработчик SIGTERM и маленький init (docker run --init, tini, dumb-init). Выберите вариант и нажмите docker stop.

Планировщик

Готовых к работе задач обычно больше, чем ядер. Планировщик Linux (CFS, с версии 6.6 — EEVDF) делит время честно: у каждой задачи счётчик потраченного времени с учётом веса, и на ядро идёт та, у кого он меньше.

  • Задача, ждущая диск или сеть, заблокирована и в очереди не стоит. Проснувшись, она отстала по счётчику и получает ядро почти сразу — поэтому веб-сервер отзывчив рядом с тяжёлой сборкой.
  • nice меняет вес: каждая ступень — примерно ±10% процессора.

A и B только считают, C — веб-сервер: 1 мс работы, потом 8 мс ждёт сеть. Добавьте задач, уберите ядро, поменяйте nice или переключитесь на простую очередь.

Переключение контекста

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

  • Прямая цена — 1–3 мкс. Главная цена — косвенная: новая задача находит кэши и TLB заполненными чужими данными и несколько десятков микросекунд работает медленнее.
  • Тысячи активных потоков на нескольких ядрах — это постоянные переключения. Счётчики видно в vmstat 1 (столбец cs) и pidstat -w.

Двигайте частоту переключений и размер рабочих данных задачи.

Потоки и процессы

Поток — это ещё один исполнитель внутри процесса: свои регистры и стек, но общие память, файлы и обработчики сигналов. В Linux поток — та же задача ядра, что и процесс, просто созданная clone() с флагами «делить память и файлы».

Общая память — это и плюс (обмен данными без копирования), и минус: гонки данных и общая судьба. Синхронизация — в презентации про конкурентность, лёгкие потоки и async — про асинхронный ввод-вывод.

ПроцессыПотоки
ПамятьСвоя у каждогоОбщая, свой только стек
Созданиеfork — от 50 мкс и дольше с ростом памяти, fork+exec — около 1 мс10–20 мкс
Обмен даннымиКаналы, сокеты, общая память — с сериализациейПросто общие объекты — с блокировками
ПереключениеДороже: смена таблицы страниц, TLBДешевле
ПадениеУмирает один процессSegfault в одном потоке убивает весь процесс
УтечкиПроцесс завершился — ОС всё вернулаЖивут, пока жив процесс

Сколько потоков можно создать

Стек потока — 8 МБ виртуальной памяти в Linux и 1 МБ в Java, но реально занимаются только тронутые страницы: обычно десятки килобайт. Упираются раньше в лимиты ядра и в переключения.

  • Ошибки: pthread_create: Resource temporarily unavailable, в Java — OutOfMemoryError: unable to create native thread.
  • В контейнере есть ещё pids.max из cgroup, у Go — свой лимит в 10 000 потоков ОС.

Двигайте число потоков: какой лимит сработает первым на сервере с 16 ГБ?

Python и GIL

В CPython байткод выполняет только поток, держащий глобальную блокировку интерпретатора (GIL). Потоки отдают её каждые 5 мс и на время ввода-вывода. Поэтому потоки ускоряют ожидание сети, но не вычисления на чистом Python.

  • Для вычислений — multiprocessing или ProcessPoolExecutor: отдельные процессы со своим GIL, но данные передаются через pickle.
  • Python 3.13+ можно собрать без GIL (python3.14t): потоки работают параллельно, однопоточный код медленнее на 5–10%.

Выберите задачу и способ, нажмите «Запустить». Задача — 8 одинаковых кусков работы на 4 ядрах.

Как с этим живут рантаймы

Python

Потоки ОС 1:1, но байткод выполняет один поток за раз. Вычисления — в процессы. На Linux с 3.14 процессы по умолчанию запускаются через forkserver, а не fork.

Node.js

Ваш JS — один поток с циклом событий. Файлы, DNS, crypto и zlib уходят в пул libuv из 4 потоков (UV_THREADPOOL_SIZE). Вычисления — в worker_threads: отдельные изоляты V8 в том же процессе, обмен через postMessage или SharedArrayBuffer.

Java

Обычный поток — поток ОС с 1 МБ стека. Поэтому их держат в пулах (ExecutorService) фиксированного размера. С Java 21 есть виртуальные потоки: миллионы задач поверх нескольких потоков ОС.

Go

Горутины M:N: тысячи горутин на GOMAXPROCS потоках ОС, стек от 2 КБ. Блокирующий системный вызов занимает поток целиком, и рантайм запускает ещё один на замену.

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

Load average

Три числа в uptime и top — сглаженное среднее числа задач, которые работают или ждут ядро, за 1, 5 и 15 минут. Сравнивайте с числом ядер: load 8 на 8 ядрах — полная загрузка, на 2 ядрах — очередь вчетверо длиннее.

В Linux в load входят и задачи в состоянии D — непрерываемое ожидание диска или NFS. Поэтому высокий load при свободном процессоре — повод смотреть на диск. Двигайте ползунки и следите, как догоняют средние.

Контейнер — это просто процесс

Никакой виртуальной машины: процессы контейнера видны в ps на хосте. Изоляцию дают namespaces — свои PID, сеть, файловая система, hostname. Ресурсы ограничивают cgroups.

  • Лимит CPU — это квота: cpu.max = 200000 100000 значит 200 мс процессорного времени на каждые 100 мс. Квоту тратят все потоки вместе.
  • 16 потоков выжигают её за 12,5 мс, и процесс стоит до конца периода. Это видно в cpu.stat: nr_throttled.
  • Пулы по числу ядер хоста усиливают эффект: os.cpu_count() в Python видит все ядра, GOMAXPROCS учитывает лимит только с Go 1.25.

Двигайте лимит и число занятых потоков. Запросу нужно 50 мс процессора.

Проверьте себя

В программе 8 потоков. Один из них вызывает fork() без exec. Что окажется в процессе-ребёнке?

Ребёнок получает копию всей памяти, но только один поток. Если в момент fork другой поток держал мьютекс — аллокатора, логгера, пула соединений, — в ребёнке его никто не отпустит, и первый же вызов зависнет. Поэтому Python 3.12 предупреждает о fork в многопоточной программе, а с 3.14 на Linux multiprocessing по умолчанию использует forkserver. Безопасно сразу после fork только вызвать exec.

Проверьте себя

На сервере с 8 ядрами load average 40, а top показывает 92% idle. Что происходит?

Спящие задачи (S) и зомби (Z) в load не входят. Linux считает работающие и готовые (R) плюс непрерываемо ждущие ввода-вывода (D). Найти их: ps -eo pid,stat,wchan:30,cmd | awk '$2 ~ /D/', а vmstat 1 покажет их число в столбце b. Частые причины — перегруженный диск или отвалившийся NFS.

Проверьте себя

8 потоков Python с GIL считают hashlib.sha256() от блоков по 1 МБ на 8 ядрах. Какое будет ускорение по сравнению с одним потоком?

GIL защищает интерпретатор, а не код на C. hashlib отпускает его на данных больше 2 КБ, так же поступают zlib, операции numpy над большими массивами, чтение файлов и сокетов. Пока поток внутри такого кода, другие выполняют Python. Медленно в потоках только то, что крутится в байткоде.

Как посмотреть у себя

  • ps, top -H, htop — процессы, их потоки и состояния: R работает, S спит, D ждёт диск, Z зомби.
  • strace -f -c — какие системные вызовы и сколько времени на них уходит. Сильно замедляет программу: на проде осторожно.
  • /proc/<PID>/ — всё о процессе: память, файлы, лимиты, переключения контекста.
  • Стеки всех потоков: py-spy dump --pid для Python, jstack для Java, kill -QUIT для Go.
  • perf top и perf sched — где тратится процессор и сколько задачи ждут в очереди.

ps -eo pid,ppid,stat,nlwp,rss,cmd --forest
pstree -p 2210

strace -f -c python app.py   # сводка
strace -f -e trace=process,signal -p 2210

grep -E 'State|Threads|ctxt' /proc/2210/status
ls -l /proc/2210/fd    # файлы и сокеты
cat /proc/2210/limits
tr '\0' '\n' < /proc/2210/environ

vmstat 1      # r — очередь, b — D, cs
pidstat -w 1  # переключения контекста

cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/cpu.stat  # nr_throttled
ls -l /proc/2210/ns    # namespaces

Главное

  • Всё, что выходит за пределы памяти процесса, идёт через системные вызовы. Они стоят сотни наносекунд — буферизуйте ввод-вывод.
  • Процесс — адресное пространство, файлы и права. Поток — исполнитель внутри него. Процессы изолированы, потоки дешевле, но падают вместе.
  • Новый процесс — это fork и exec. Родитель обязан вызвать wait, иначе остаются зомби.
  • В контейнере PID 1 особенный: exec в скриптах, обработчик SIGTERM и --init.
  • Планировщик честно делит ядра и быстро будит тех, кто ждал ввод-вывод. Лишние активные потоки — это переключения и холодные кэши.
  • Load average — очередь к ядрам плюс ожидание диска. Лимит CPU в контейнере — квота, которую потоки выжигают вместе.

Ваша программа не владеет процессором — она его арендует у ядра.