Операционные системы

Теория: Как ОС делает всё одновременно

Многозадачность в системе — это работа планировщика (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

не атомарное. Оно распадается на три шага:

  1. взять значение counter из памяти
  2. прибавить 1
  3. записать новое значение обратно

Если в этот момент вмешался другой поток, запись теряется.

Давайте проверим на практике

import threading
counter = 0  # общая переменная
ITERATIONS = 1_000_000
def increment_without_lock():
    global counter
    for _ in range(ITERATIONS):
        counter += 1  # неатомарная операция
# создаём два потока
t1 = threading.Thread(target=increment_without_lock)
t2 = threading.Thread(target=increment_without_lock)
t1.start()
t2.start()
t1.join()
t2.join()

print(f"Результат без синхронизации: {counter:,} (ожидали {2 * ITERATIONS:,})")

Что увидите на выводе:

  • очень редко: 2 000 000 (почти невозможно)
  • обычно: меньше — 1 300 000, 1 700 000, 900 000...

То есть часть операций потерялась. Один поток перезаписал результат другого. Это и есть race condition.

Мьютекс: как решить проблему гонки

Чтобы потоки не ломали общие данные, операционная система предоставляет механизмы синхронизации. Самый простой и распространённый — мьютекс (mutex, mutual exclusion).

Идея простая:

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

import threading
counter = 0
ITERATIONS = 1_000_000
lock = threading.Lock()  # создаём мьютекс
def increment_with_lock():
    global counter
    for _ in range(ITERATIONS):
        # критическая секция – только один поток внутри
        with lock:
            counter += 1
t1 = threading.Thread(target=increment_with_lock)
t2 = threading.Thread(target=increment_with_lock)
t1.start()
t2.start()
t1.join()
t2.join()

print(f"Результат с мьютексом: {counter:,} (ожидали {2 * ITERATIONS:,})")

Что изменилось

  • оба потока всё ещё работают параллельно
  • но в момент изменения переменной доступ ограничен
  • второй поток ждёт, пока первый выйдет из блока with lock
  • итоговые инкременты не теряются
  • результат всегда ровно **2 000 000 **

Такой же принцип работает в любом языке и любой ОС — C, C++, Java, Go, Rust, Windows, Linux. Везде идея одна: мьютекс не даёт двум потокам одновременно трогать общие данные.

Почему синхронизация не бесплатна

Каждый вызов lock.acquire() и lock.release() требует обращения к ядру и синхронизации с другими потоками. Это занимает микросекунды, но при тысячах обращений в секунду эффект становится заметным. Чем больше потоков и чем чаще они блокируются, тем ниже общая производительность. Именно поэтому многопоточные серверы вроде nginx, MySQL или Redis стараются минимизировать количество общих переменных и критических секций, чтобы потоки могли работать параллельно, не дожидаясь друг друга.

В реальной инфраструктуре подобные проблемы проявляются не в коде, а в поведении сервисов. Если под нагрузкой приложение «виснет» без ошибок, перестаёт отвечать или ведёт себя непредсказуемо, дело может быть не в сети и не в БД, а в гонке данных или взаимной блокировке потоков внутри кода.

Взаимная блокировка и потокобезопасность

Синхронизация решает одну проблему — гонку данных, но при неправильном применении создаёт другую, куда более неприятную — взаимную блокировку, или deadlock. Это ситуация, когда несколько потоков ждут друг друга и никто не может продолжить работу. С точки зрения ядра всё выглядит идеально: процесс жив, ошибок нет, CPU свободен. Но приложение застыло навсегда.

Представьте, что у нас есть два потока. Первый должен записать данные в файл A, потом обновить индекс в файле B. Второй делает обратное — сначала обновляет индекс, потом пишет данные.

Каждый поток блокирует файл, чтобы не повредить данные, но порядок блокировок разный. И всё — мы получили классическую взаимную блокировку.

Пример на Python

import threading
import time

# Симулируем два ресурса — два файла

file_a_lock = threading.Lock()
file_b_lock = threading.Lock()

def writer_a_first():
    """Поток A: сначала блокирует файл A, потом B"""
    print("A: пытается захватить файл A...")
    with file_a_lock:
        print("A: файл A захвачен")
        time.sleep(1)  # имитация записи
        print("A: пытается захватить файл B...")

        with file_b_lock:
            print("A: успешно захватил оба файла")


def writer_b_first():
    """Поток B: сначала блокирует файл B, потом A"""
    print("B: пытается захватить файл B...")
    with file_b_lock:
        print("B: файл B захвачен")
        time.sleep(1)
        print("B: пытается захватить файл A...")
        with file_a_lock:
            print("B: успешно захватил оба файла")

# Запускаем два потока почти одновременно
t1 = threading.Thread(target=writer_a_first)
t2 = threading.Thread(target=writer_b_first)

t1.start()
t2.start()

t1.join()
t2.join()

print("Работа завершена")

Что произойдёт

  1. Поток A успевает захватить file_a_lock.
  2. Поток B в это время захватывает file_b_lock.
  3. Через секунду оба пытаются взять второй замок — и каждый ждёт другого.

Результат: программа зависает навсегда. На экране вы увидите:

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

pstree -p                 # показать дерево процессов с PID
ps -eLf | grep <PID>      # показать потоки внутри процесса
htop                      # нажми H, чтобы раскрыть потоки
top -H                    # режим отображения потоков

macOS

ps -M -p <PID>            # показать потоки конкретного процесса
top -stats pid,threads    # отобразить PID и количество потоков

Windows (PowerShell)

Get-Process | Select Name, Id, @{n='Threads';e={$_.Threads.Count}}

Если процесс загружает CPU не полностью, но всё равно “зависает”, стоит проверить, сколько у него потоков и не блокируют ли они друг друга.

Демоны, фоновые процессы и сигналы

Большинство процессов, которые работают постоянно, — это демоны. Они запускаются при старте системы и работают в фоне: sshd, cron, systemd-journald, dockerd — все они живут, пока работает ОС.

Посмотреть активные демоны:

ps -eo pid,ppid,cmd | grep systemd

systemctl list-units --type=service

Процессы управляются сигналами:

kill -15 <PID>  # SIGTERM — мягкое завершение
kill -9 <PID>   # SIGKILL — принудительное завершение
kill -HUP <PID> # перезагрузка конфигурации (например, nginx)

В Windows для тех же целей служат команды:

Stop-Process -Id <PID>
Restart-Service <Name>

Зомби и лимиты

Когда дочерний процесс завершается, но родитель не вызвал wait(), он остаётся в состоянии Z (zombie) — уже не живой, но запись о нём остаётся в таблице процессов. Много зомби — признак ошибок в управлении процессами.

Проверить:

ps -eo pid,ppid,stat,cmd | grep Z

Кроме того, каждый поток занимает память под стек:

  • в Linux — около 8 МБ по умолчанию,
  • в Windows — около 1 МБ.

Если программа создаёт тысячи потоков, память быстро заканчивается.

Проверить лимиты:

ulimit -s   # размер стека
ulimit -u   # лимит на число процессов/потоков

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845