Операционные системы
Теория: Потоки
В любой современной ОС всё исполняемое существует как процессы. Процессы запускают сервисы, контейнеры, CI-агентов, демоны и вашу оболочку. Но один процесс редко работает “в одиночку”: внутри него часто крутится сразу несколько независимых линий исполнения — потоки. Поток (thread) — это последовательность команд, которая исполняется внутри процесса и делит с другими потоками этого же процесса память, открытые файлы, сокеты и кучу; у потока свой собственный стек.
Возьмите веб-сервер. Один поток принимает соединения, другой пишет логи, третьи обрабатывают запросы к бэкенду. Всё это — один процесс.
PID 1 и родословная процессов
В Linux корневой пользовательский процесс действительно systemd с PID 1. Если запустить pstree -p, вы увидите дерево, где от systemd(1) расходятся sshd(...), bash(...), nginx(...), dockerd(...), containerd(...), kubelet(...) и т. д. Каждый из них — отдельный процесс со своим адресным пространством: виртуальная память изолирована, таблицы страниц разные, поэтому падение nginx не трогает память sshd или dockerd.
Внутри контейнеров (контейнеризация) PID 1 - это запущенное приложение, либо init.
В macOS роль «первого» пользовательского процесса выполняет launchd (PID 1). Он порождает сервисы системы и пользовательские агенты.
Проверить можно так: ps -A -o pid,ppid,comm | head — вверху будет launchd как родитель. Дальше картина та же: изоляция за счёт виртуальной памяти и защиты ядра, поэтому сбой одного демона не валит остальные.
В Windows древо процессов устроено иначе, чем в Unix. Здесь нет «PID 1» в классическом смысле.
- PID 0 зарезервирован за Idle Process — ядровой сущностью, которая не соответствует реальному пользовательскому процессу.
- PID 4 обычно принадлежит System — первому настоящему процессу ядра.
Первым процессом пользовательского режима становится smss.exe (Session Manager Subsystem). Он получает один из самых ранних PID (как правило, 1–3, но не 0) и отвечает за запуск критически важных подсистем:
- csrss.exe — Client/Server Runtime Subsystem
- wininit.exe, который поднимает:
- services.exe — диспетчер Windows-служб
- lsass.exe — подсистема безопасности
- lsm.exe — Local Session Manager
От этих процессов далее появляются остальные пользовательские процессы, включая вход в систему и рабочий стол.
Таким образом, в Windows роль «раннего процесса пользователя» выполняет не PID 1, а smss.exe, а корневая цепочка выглядит иначе, чем в Unix: ядро → System (PID 4) → smss.exe → csrss/wininit → services/lsass и т. д.
Посмотреть иерархию удобно в Process Explorer (View → Show Process Tree) или из PowerShell:
Изоляция — тоже через виртуальную память и объектную модель ядра NT; авария сервиса, как правило, не трогает соседние.
Посмотреть дерево процессов или узнать родителя/дочерние процессы — это полезно, например, при отладке агентов, демонов или CI-воркеров, чтобы понимать, кто кого породил.
Простой пример: вывод дерева процессов
Как это работает
psutil.Process(pid)— создаёт объект процесса по PIDp.name()— имя процесса (bash, sshd, python и т. д.)p.children()— список потомков- функция вызывает сама себя для детей → получается дерево
Что получится на практике
Если запустить на Linux, то корневым будет systemd(1):
Если запустить на macOS, первым будет launchd(1):
Дерево процессов в Windows
В Windows нет привычного PID 1, как в Linux. Корневые процессы здесь другие:
- System (PID 4) — первый «настоящий» процесс ядра
- smss.exe — первый процесс пользовательского режима (Session Manager)
Чтобы посмотреть дерево процессов, снова можно использовать psutil. Внутри psutil просто вызывает Windows-API, поэтому отдельные драйверы или инструменты не нужны.
Простой пример (Windows)
Что делает код
psutil.process_iter()— перебирает все процессы в системе- ищем те, у кого имя
Systemилиsmss.exe→ это самые ранние процессы p.children()показывает дочерние процессы- функция вызывает сама себя — получается дерево
Как будет выглядеть вывод
Так можно быстро увидеть, кто кого запустил: полезно при отладке служб Windows, агентов, обновляторов и т. д.
Почему потоки “легче” процессов
Создание нового процесса — тяжёлая операция: ядро выделяет отдельное адресное пространство, создаёт таблицы страниц, копирует окружение и файловые дескрипторы, загружает код и динамические библиотеки, инициализирует кучу.
Создание потока намного дешевле. Все ресурсы процесса уже существуют, поэтому ядру нужно только зарезервировать место под стек и зарегистрировать поток. В Linux и macOS на поток по умолчанию резервируется около 8 МБ, в Windows — порядка 1 МБ. Важно: это виртуальный резерв, а не физическое выделение — реальные страницы памяти будут выделены только при обращении к ним (по мере роста стека). Размер резерва можно изменять, например, через ulimit -s в Unix-подобных системах или параметрами при создании потока.
На практике разница огромная. В Linux создание потока занимает сотни микросекунд, тогда как процесс — миллисекунды. Если запустить тест с 10 000 потоков и 10 000 процессов, можно увидеть, что первый вариант выполняется быстрее на порядок. Поэтому все современные серверы и базы данных — от nginx и MySQL до Elasticsearch — многопоточны. Они создают десятки, иногда сотни потоков, чтобы распараллелить обработку запросов и максимально загрузить процессор.
Представьте, вы запустили nginx. Это один процесс, но внутри него десятки потоков. Один принимает соединения, другой пишет логи, третий обслуживает запросы к API. Все потоки используют общие данные — память, глобальные переменные, кэш, сокеты. Но у каждого свой стек — место, где хранятся локальные переменные и контекст выполнения функций. Так nginx может обслуживать сотни клиентов параллельно, не создавая сотни отдельных процессов.
Как ОС делает всё одновременно
Многозадачность в системе — это работа планировщика (scheduler). Он решает, какой поток получит процессор прямо сейчас. Процессор не выполняет всё одновременно — он быстро переключается между потоками, выдавая каждому небольшой отрезок времени — квант (обычно 1–10 миллисекунд). Когда один поток блокируется (например, ждёт чтение с диска), CPU мгновенно переключается на другой.
Если у вас многоядерный процессор, потоки действительно могут работать параллельно: каждое ядро исполняет свой поток. Поэтому если в htop вы видите, что один процесс использует 400% CPU — значит, у него четыре активных потока, и каждый занят своим ядром.
Linux и macOS планируют потоки через структуру task_struct, Windows — через объект ETHREAD, но смысл одинаков: именно потоки конкурируют за процессор, не процессы
Гонка данных и синхронизация потоков
Когда несколько потоков выполняются внутри одного процесса, они делят общую память: глобальные переменные, динамическую кучу, открытые файлы и сетевые соединения. Это делает многопоточность быстрой и эффективной — ведь не нужно копировать данные между процессами. Но эта же особенность превращается в источник ошибок, если потоки начинают работать с одними и теми же данными одновременно.
Представьте: два потока увеличивают значение одного счётчика. Каждый из них выполняет миллион операций. Наивно ожидается, что итоговое значение будет два миллиона. Однако результат почти всегда меньше — иногда 1,4 млн, иногда 1,7 млн, а иногда даже 900 тысяч. Это классическая гонка данных (race condition): результат зависит от того, какой поток успел прочитать и записать значение первым.
Всё дело в том, что операция counter += 1 не является атомарной. Процессор выполняет её в несколько шагов: читает значение из памяти, прибавляет единицу и записывает результат обратно. Если между этими шагами влезает другой поток, один из инкрементов просто теряется.
Посмотрим, как это выглядит на практике.
Пусть два потока увеличивают один и тот же счётчик:
- первый делает 1 000 000 инкрементов
- второй делает 1 000 000 инкрементов
- ожидаем итог: 2 000 000
Но почти никогда такого не получим. Почему?
Потому что выражение:
не атомарное. Оно распадается на три шага:
- взять значение
counterиз памяти - прибавить 1
- записать новое значение обратно
Если в этот момент вмешался другой поток, запись теряется.
Давайте проверим на практике
Что увидите на выводе:
- очень редко: 2 000 000 (почти невозможно)
- обычно: меньше — 1 300 000, 1 700 000, 900 000...
То есть часть операций потерялась. Один поток перезаписал результат другого. Это и есть race condition.
Мьютекс: как решить проблему гонки
Чтобы потоки не ломали общие данные, операционная система предоставляет механизмы синхронизации. Самый простой и распространённый — мьютекс (mutex, mutual exclusion).
Идея простая:
Пока один поток работает в критической секции, остальные ждут. То есть доступ к опасному месту становится эксклюзивным.
Что изменилось
- оба потока всё ещё работают параллельно
- но в момент изменения переменной доступ ограничен
- второй поток ждёт, пока первый выйдет из блока
with lock - итоговые инкременты не теряются
- результат всегда ровно 2 000 000
Такой же принцип работает в любом языке и любой ОС — C, C++, Java, Go, Rust, Windows, Linux. Везде идея одна: мьютекс не даёт двум потокам одновременно трогать общие данные.
Почему синхронизация не бесплатна
Каждый вызов lock.acquire() и lock.release() требует обращения к ядру и синхронизации с другими потоками. Это занимает микросекунды, но при тысячах обращений в секунду эффект становится заметным. Чем больше потоков и чем чаще они блокируются, тем ниже общая производительность. Именно поэтому многопоточные серверы вроде nginx, MySQL или Redis стараются минимизировать количество общих переменных и критических секций, чтобы потоки могли работать параллельно, не дожидаясь друг друга.
В реальной инфраструктуре подобные проблемы проявляются не в коде, а в поведении сервисов. Если под нагрузкой приложение «виснет» без ошибок, перестаёт отвечать или ведёт себя непредсказуемо, дело может быть не в сети и не в БД, а в гонке данных или взаимной блокировке потоков внутри кода.
Взаимная блокировка и потокобезопасность
Синхронизация решает одну проблему — гонку данных, но при неправильном применении создаёт другую, куда более неприятную — взаимную блокировку, или deadlock. Это ситуация, когда несколько потоков ждут друг друга и никто не может продолжить работу. С точки зрения ядра всё выглядит идеально: процесс жив, ошибок нет, CPU свободен. Но приложение застыло навсегда.
Представьте, что у нас есть два потока.
- Первый должен записать данные в файл A, потом обновить индекс в файле B.
- Второй делает обратное — сначала обновляет индекс, потом пишет данные.
Каждый поток блокирует файл, чтобы не повредить данные, но порядок блокировок разный. И всё — мы получили классическую взаимную блокировку.
Пример на Python
Что произойдёт
- Поток A успевает захватить file_a_lock.
- Поток B в это время захватывает file_b_lock.
- Через секунду оба пытаются взять второй замок — и каждый ждёт другого.
Результат: программа зависает навсегда. На экране вы увидите:
A: пытается захватить файл A...
A: файл A захвачен
B: пытается захватить файл B...
B: файл B захвачен
A: пытается захватить файл B...
B: пытается захватить файл A...
И дальше — тишина. CPU не нагружен, ошибок нет. Если это происходит в сервере или CI-агенте — он просто “умирает стоя”.
Как выглядит в реальной системе
То же самое бывает в продакшене. Например:
- База данных: два SQL-транзакции обновляют таблицы в разном порядке (UPDATE orders → UPDATE customers и наоборот). В логах — «всё чисто», но транзакции висят.
- Файловые операции: один поток держит lock на файл логов, другой — на конфиг, и оба ждут друг друга.
- Сетевые драйверы или пулы соединений: один поток занял сокет и ждёт ресурс пула, другой наоборот.
Потокобезопасный код (thread-safe)
Функция или модуль считаются thread-safe, если могут работать корректно при одновременном доступе из разных потоков.
Если в коде используются глобальные переменные, общий кэш или общие структуры данных без блокировок — это небезопасно.
Потокобезопасный код:
- защищает критические участки мьютексами или атомарными операциями;
- минимизирует общую память между потоками;
- избегает состояния гонки и взаимных блокировок.
Для DevOps это важно не только теоретически. Если сервис “глючит” под нагрузкой, но не падает — вероятно, он столкнулся с race condition или deadlock внутри. Такие ошибки внешне выглядят как проблемы сети, балансировщика или базы, но на деле живут внутри кода приложения.
Мониторинг потоков и процессов
Понять, что происходит в системе, можно без профилировщиков — обычными утилитами.
Linux
macOS
Windows (PowerShell)
Если процесс загружает CPU не полностью, но всё равно “зависает”, стоит проверить, сколько у него потоков и не блокируют ли они друг друга.
Демоны, фоновые процессы и сигналы
Большинство процессов, которые работают постоянно, — это демоны. Они запускаются при старте системы и работают в фоне: sshd, cron, systemd-journald, dockerd — все они живут, пока работает ОС.
Посмотреть активные демоны:
Процессы управляются сигналами:
В Windows для тех же целей служат команды:
Зомби и лимиты
Когда дочерний процесс завершается, но родитель не вызвал wait(), он остаётся в состоянии Z (zombie) — уже не живой, но запись о нём остаётся в таблице процессов.
Много зомби — признак ошибок в управлении процессами.
Проверить:
Кроме того, каждый поток занимает память под стек:
- в Linux — около 8 МБ по умолчанию,
- в Windows — около 1 МБ.
Если программа создаёт тысячи потоков, память быстро заканчивается.
Проверить лимиты:

