Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+348
View File
@@ -0,0 +1,348 @@
# D3, часть 2. Многопоточность (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/06_threads`. На
собеседовании тут спрашивают не «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
счётчик под нагрузкой» — то есть понимание синхронизации, а не перечисление API.
## 1. `std::thread`: создание, join/detach, стек
`std::thread t(f, args...)` запускает переданную функцию в новом потоке операционной системы
немедленно, в момент вызова конструктора, а не при отдельной команде «старт». Создание —
это системный вызов (на Linux — `clone`), ядру нужно выделить новому потоку собственный стек
(по умолчанию порядка 8 МБ на поток на типичной Linux-системе) и завести отдельный набор
регистров; адресное пространство, куча и таблица файловых дескрипторов остаются общими с
процессом.
С объектом `std::thread` после создания есть ровно два законных пути: `t.join()` — дождаться
завершения потока, блокируя вызывающий код до его окончания, или `t.detach()` — отсоединить
поток, чтобы он жил и завершался независимо от объекта. Если объект `std::thread`,
представляющий ещё не завершённый и не присоединённый поток, уничтожается (выходит из
области видимости) — стандарт требует вызвать `std::terminate`: поток — ресурс ОС, и его
нельзя «тихо потерять». При `detach()` нужно отдельно следить за временем жизни всего, что
поток использует по ссылке/указателю — если основной поток уничтожит эти объекты раньше, чем
завершится отсоединённый поток, это use-after-free.
**Факты для карточек**
- base | Когда именно стартует новый поток при `std::thread t(f)`? — сразу, в конструкторе
- base | Примерный размер стека потока на типичной Linux-системе? — ~8 МБ
- core | Что произойдёт, если объект `std::thread` с незавершённым и не присоединённым потоком уничтожится? — `std::terminate`, программа аварийно завершится
- core | Каким системным вызовом Linux создаёт новый поток? — `clone`
Почему дальше: раз несколько потоков делят одно адресное пространство, следующий вопрос —
что происходит, если они одновременно трогают одни и те же данные без защиты.
## 2. Data race и почему это UB уже для `int`
Гонка данных (data race) — одновременный доступ двух и более потоков к одной и той же ячейке
памяти без синхронизации, где хотя бы один из доступов — запись. По стандарту C++ это
неопределённое поведение (UB) — причём для **любого** типа, включая обычный `int`, а не
только для сложных структур.
Причина строгости правила не в том, что чтение/запись `int` физически не атомарны на
конкретном железе (выровненный `int` большинство процессоров читает и пишет одной шиной за
раз) — причина в том, что компилятор без синхронизации вправе кэшировать значение переменной
в регистре, переупорядочивать обращения к памяти и предполагать отсутствие гонки при
оптимизациях. UB здесь означает, что компилятор формально может сгенерировать любой код,
включая код, ломающий программу способом, не связанным напрямую с «неправильным числом» —
поэтому полагаться на «на моём железе `int` всё равно атомарен» неверно и небезопасно.
**Факты для карточек**
- base | Что такое data race? — одновременный доступ к одной памяти минимум с одной записью без синхронизации
- core | Является ли гонка на обычном `int` без атомиков UB по стандарту C++? — да, даже если на конкретном железе чтение/запись `int` физически атомарны
- core | Почему компилятору мало того, что чтение `int` атомарно на железе? — без синхронизации он вправе кэшировать значение в регистре и переупорядочивать обращения к памяти
Почему дальше: раз гонка данных — это UB, нужен механизм, который гарантирует, что в
критическую секцию кода одновременно входит только один поток, — мьютекс.
## 3. `std::mutex` и RAII-обёртки
`std::mutex` гарантирует взаимное исключение: только один поток одновременно может держать
его захваченным. На практике мьютекс почти никогда не захватывают вручную через
`lock()`/`unlock()` — их оборачивают в RAII-объект, потому что ручной `unlock()` легко забыть
на пути исключения или раннего `return`, и тогда мьютекс останется захваченным навсегда.
- `std::lock_guard` — простая блокировка на время текущей области видимости, без возможности
разблокировать раньше.
- `std::unique_lock` — то же самое, но с ручной разблокировкой/повторным захватом,
возможностью передать владение и обязательный тип для работы с `condition_variable::wait`.
- `std::scoped_lock` (C++17) — захватывает **несколько** мьютексов одной атомарной операцией,
без риска, что между захватом первого и второго вклинится другой поток с обратным порядком
захвата (это прямая защита от дедлока при захвате нескольких мьютексов в одном месте).
Дедлок из-за неверного порядка захвата компилятор не ловит — это не синтаксическая, а
динамическая ошибка, которая проявляется только в рантайме на конкретной раскладке потоков.
Обнаруживают его ThreadSanitizer (детектирует инверсию порядка захвата, даже если фактического
зависания в конкретном прогоне не случилось) либо наблюдением зависшей программы через
`gdb`/`info threads`.
**Факты для карточек**
- base | Какая RAII-обёртка нужна для работы с `condition_variable::wait`? — `std::unique_lock`
- base | Что делает `std::scoped_lock`? — атомарно захватывает несколько мьютексов сразу
- core | Почему дедлок ловится ThreadSanitizer, а не компилятором? — это динамическая ошибка, зависящая от конкретной раскладки потоков в рантайме, а не от статической структуры кода
Почему дальше: мьютекс защищает данные, но поток часто должен ещё и **ждать** какое-то
событие (данные появились, место освободилось) — для этого нужен `condition_variable`.
## 4. `condition_variable` и predicate-цикл
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток одной
неделимой операцией. Атомарность здесь принципиальна: если бы освобождение мьютекса и уход в
сон были двумя раздельными шагами, между ними мог бы вклиниться другой поток, изменить
состояние и вызвать `notify` — тогда это уведомление потерялось бы (lost wakeup), потому что
ждущий поток ещё не успел реально зайти в состояние ожидания. При пробуждении `wait`
повторно захватывает тот же мьютекс перед тем, как вернуть управление — весь код после
`wait` уже выполняется под защитой.
Стандарт C++ прямо разрешает **ложные пробуждения** (spurious wakeup) — `wait` может
вернуться без единого вызова `notify_one`/`notify_all`, просто по решению ОС (на Linux
`condition_variable` реализован поверх `futex`, который иногда пробуждается по внутренним
причинам платформы). Из-за этого одиночный `if (!ready) cv.wait(lock);` недостаточен —
правильный паттерн:
```c++
std::unique_lock<std::mutex> lock(mtx);
while (!ready) // predicate-цикл, не if
cv.wait(lock);
```
Изменение состояния (`ready = true;`) обязательно делают под тем же мьютексом, которым
защищена сама проверка предиката, — иначе между проверкой предиката и входом в `wait` может
вклиниться другой поток. А вот сам вызов `notify_one`/`notify_all` можно делать как под
мьютексом, так и сразу после `unlock()` — на корректность это не влияет, потому что проверка
предиката и уход в `wait` у ждущего потока в любом случае происходят атомарно под общим
мьютексом. На производительность разница есть: `notify` под захваченным мьютексом иногда
будит поток, который тут же снова блокируется на этом же мьютексе в ожидании его
освобождения — вызов `notify` после `unlock()` избавляет от этого лишнего пробуждения-и-сна.
**Ловушки**
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
- Изменение состояния (`size_++`, `ready = true`) вне захваченного мьютекса → гонка данных →
ловится ThreadSanitizer как data race.
- Один `condition_variable` на два разных предиката (например «не пусто» и «не полно») вместе
с `notify_one` → можно разбудить не тот поток, а нужный останется ждать → зависание.
**Факты для карточек**
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
- core | Почему `while`, а не `if`, вокруг `wait`? — из-за spurious wakeup: `wait` может вернуться без единого вызова `notify`
- core | Обязательно ли вызывать `notify` под захваченным мьютексом? — нет, обязательно лишь менять состояние под мьютексом; `notify` после `unlock()` — оптимизация, не требование корректности
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — `futex`
Почему дальше: мьютекс и `condition_variable` могут заблокировать программу навсегда, если
их захватывать в разном порядке в разных местах кода, — это дедлок.
## 5. Deadlock: 4 условия и порядок захвата
Дедлок — взаимная блокировка, когда каждый из нескольких потоков ждёт ресурс, захваченный
другим, и никто не может продолжить. Классический сценарий на двух мьютексах: поток A держит
мьютекс 1 и ждёт мьютекс 2, поток B держит мьютекс 2 и ждёт мьютекс 1 — оба висят навсегда.
Четыре условия Коффмана, необходимые одновременно для дедлока:
1. Взаимное исключение — ресурс занят не более чем одним потоком.
2. Удержание и ожидание — поток держит один ресурс и ждёт другой.
3. Невозможность принудительного отбора — ресурс нельзя отобрать у держащего потока.
4. Круговое ожидание — цикл потоков, каждый ждёт ресурс следующего.
Разрушить достаточно одно условие. На практике проще всего разрушить круговое ожидание:
всегда захватывать несколько мьютексов в едином порядке во всей программе (например, по
адресу объекта или по заранее заданному номеру) — тогда цикл ожидания просто не может
образоваться. Там, где несколько мьютексов захватываются в одном месте кода, для этого есть
`std::lock` или `std::scoped_lock` — атомарный захват сразу нескольких без риска, что между
захватом первого и второго вклинится поток с обратным порядком.
Дедлок обычно не падает с ошибкой — это зависшая программа без вывода в лог; диагностируют
через `gdb`, `info threads` и просмотр стеков всех потоков, чтобы увидеть, кто на каком
мьютексе застрял.
**Факты для карточек**
- core | Назови 4 условия Коффмана для дедлока? — взаимное исключение, удержание-и-ожидание, невозможность отбора, круговое ожидание
- core | Какое условие обычно разрушают на практике фиксированным порядком захвата мьютексов? — круговое ожидание
- base | Какой командой gdb смотрят, на каком мьютексе застрял каждый поток при зависании? — `info threads` (и стек каждого потока)
Почему дальше: помимо мьютекса, для простых операций вроде счётчика есть более дешёвая
альтернатива без перехода в ядро — атомарные типы.
## 6. `std::atomic` и `memory_order`
`std::atomic<T>` для простых типов (счётчики, флаги, указатели) дешевле мьютекса: операции
выполняются одной аппаратной атомарной инструкцией процессора (например compare-and-swap),
без системного вызова и без усыпления потока — тогда как мьютекс при конкуренции может
потребовать перехода в ядро и контекстного переключения.
`memory_order` управляет тем, какие перестановки чтений/записей вокруг атомарной операции
разрешены компилятору и процессору:
- `relaxed` — гарантирует только атомарность самой операции, никакого порядка относительно
других обращений к памяти не задаёт.
- `acquire` (на загрузке/чтении) — запрещает переносить более поздние по коду обращения к
памяти ДО этой операции.
- `release` (на сохранении/записи) — запрещает переносить более ранние по коду обращения к
памяти ПОСЛЕ этой операции; `release`-запись синхронизируется-с последующим `acquire`-чтением
того же атомика в другом потоке.
- `seq_cst` (значение по умолчанию для всех операций `std::atomic`) — то же, что `acquire`
и `release` вместе, плюс единый глобальный порядок для всех `seq_cst`-операций во всей
программе — самый строгий и самый дорогой по производительности вариант.
Видимость изменений между ядрами процессора на аппаратном уровне обеспечивает протокол
когерентности кэша **MESI** (Modified / Exclusive / Shared / Invalid) — каждая кэш-линия в
каждом ядре находится в одном из этих 4 состояний, и запись в линию на одном ядре инвалидирует
копии этой же линии в кэшах других ядер, заставляя их перечитать актуальное значение при
следующем обращении. `memory_order` — это про то, какие перестановки инструкций разрешены
компилятору и ядру процессора вокруг атомарной операции; MESI — это про то, как аппаратно
гарантируется, что после разрешённого порядка операций другое ядро увидит актуальное значение
кэш-линии, а не устаревшую локальную копию.
**Факты для карточек**
- base | Чем `std::atomic` дешевле мьютекса для простого счётчика? — одна аппаратная инструкция (например CAS), без перехода в ядро и усыпления потока
- core | Что гарантирует `memory_order_relaxed`? — только атомарность операции, без ограничений порядка с другими обращениями к памяти
- core | Что запрещает `acquire`, а что — `release`? — acquire запрещает переносить более поздние обращения ДО себя; release запрещает переносить более ранние обращения ПОСЛЕ себя
- deep | Сколько состояний у кэш-линии в протоколе MESI? — 4 (Modified, Exclusive, Shared, Invalid)
- deep | Какой memory_order используется по умолчанию у операций `std::atomic`? — `seq_cst`
Почему дальше: у синхронизации через мьютекс и `condition_variable` есть готовый типовой
паттерн, где всё это применяется вместе, — producer/consumer.
## 7. Producer/consumer и потокобезопасная очередь (`tasks/06_threads`)
Задача `06_threads` — ограниченная блокирующая очередь:
```c++
class BlockingQueue {
public:
explicit BlockingQueue(size_t capacity);
~BlockingQueue();
void push(int v); // блокируется, пока очередь полна
bool pop(int& out); // блокируется, пока пуста; false — если закрыта и пуста
void close(); // после close: pop() опустошает остаток и отдаёт false
size_t size() const;
};
```
Требования: `push` после `close()` бросает `std::runtime_error`; закрытие разблокирует все
ждущие потоки (никакого вечного ожидания и busy-wait); размер очереди никогда не превышает
`capacity`; ни одной гонки, включая `size()`, который тоже вызывается из другого потока.
Механизм — общее состояние (буфер, счётчик, флаг `closed`) защищено одним `std::mutex`.
Для `push` и `pop` нужны разные условия ожидания («не полна» и «не пуста или закрыта») —
такое возможно с одним `condition_variable`, только если использовать `notify_all` (каждый
разбуженный поток сам перепроверяет свой предикат в цикле и снова засыпает, если условие не
его), либо завести два раздельных `condition_variable`.
Почему `size()` тоже требует мьютекса: инкремент/декремент счётчика — это не одна
процессорная операция, а последовательность «прочитать — изменить — записать»
(read-modify-write); без синхронизации с `push`/`pop`, которые пишут в тот же счётчик, это
гонка данных даже для «безобидного» чтения одного числа — UB по стандарту, а не просто
«иногда неверное число».
**Ловушки**
- Забыть разбудить всех потоков при `close()` → часть потоков навсегда висит в `wait` →
зависание процесса, тест не завершается за отведённое время.
- Защищать `size_` отдельным от данных мьютексом → `size()` может вернуть значение, уже не
соответствующее реальному состоянию буфера.
**Факты для карточек**
- base | Каким исключением отвечает `push` после `close()`? — `std::runtime_error`
- core | Почему `size()` в этой задаче требует того же мьютекса, что и данные очереди? — инкремент/декремент — не атомарная операция read-modify-write, без синхронизации это гонка данных
Почему дальше: если под каждую входящую задачу создавать отдельный `std::thread`, накладные
расходы на создание потока (системный вызов, выделение стека) быстро перевешивают полезную
работу — логично перейти к переиспользуемому пулу потоков.
## 8. Пул потоков
Пул — заранее созданный набор из N рабочих потоков (обычно порядка числа ядер процессора),
которые постоянно забирают задачи из общей очереди (той же природы, что producer/consumer
выше) вместо создания нового `std::thread` под каждую задачу. Причина — цена создания потока:
системный вызов, выделение стека, регистрация в планировщике ОС; при коротких и частых
задачах эти накладные расходы легко превышают время самой полезной работы. Пул амортизирует
эту цену — потоки создаются один раз при старте и переиспользуются для множества задач, а
фиксированное их число не даёт программе бесконтрольно наплодить тысячи потоков под наплывом
запросов и не утопить систему в переключениях контекста.
**Факты для карточек**
- base | Примерно сколько потоков создают в пуле относительно ядер CPU? — порядка числа ядер процессора
- core | Какую конкретно цену амортизирует пул потоков? — стоимость создания потока (системный вызов, стек, регистрация в планировщике) на каждую отдельную короткую задачу
Почему дальше: не всякую параллельную задачу удобно оформлять вручную через
`std::thread`/очередь — для запуска функции и получения её результата есть более короткий
интерфейс, `std::async`/`std::future`.
## 9. `std::async` и `std::future`
`std::async(policy, f, args...)` запускает функцию `f` и сразу возвращает `std::future` —
объект-обещание будущего результата. Политика запуска (`std::launch::async` — обязательно в
новом потоке, `std::launch::deferred` — отложенный вызов при первом обращении к результату,
или их комбинация по умолчанию — реализация вправе выбрать любой вариант) определяет, когда
и где реально выполнится `f`.
`future.get()` блокирует вызывающий поток до готовности результата и возвращает его. `get()`
можно вызвать только один раз: после первого вызова `future` становится невалидным
(`valid() == false`), и повторный вызов `get()` — неопределённое поведение по стандарту.
`std::async` избавляет от ручного управления мьютексом/`condition_variable`, когда нужен
именно единичный результат одной асинхронной операции, а не постоянный поток задач через
очередь.
**Факты для карточек**
- base | Что возвращает `std::async` сразу после вызова? — `std::future` с будущим результатом
- core | Сколько раз можно вызвать `future.get()` для одного результата? — один; второй вызов — неопределённое поведение (`valid() == false`)
Почему дальше: всё, что описано в этом уроке, — мьютексы, `condition_variable`, атомики,
дедлоки — проверяемо инструментом, который ловит гонки по факту исполнения, а не на глаз.
## 10. ThreadSanitizer
ThreadSanitizer (`-fsanitize=thread`, флаг компиляции — то есть перекомпиляция с
инструментацией, а не отдельная программа поверх готового бинарника) инструментирует каждое
обращение к памяти и синхронизирующие примитивы, отслеживая порядок happens-before между
потоками во время конкретного исполнения — то есть ловит гонку по факту того, что реально
произошло в этом запуске.
Из этого следует практическое ограничение: «прогнать разок и не увидеть ошибки» не равно
«гонки в коде нет» — конкретная раскладка потоков в конкретном запуске могла просто не
проявить проблему, которая есть в коде и выстрелит на другой машине или под другой нагрузкой.
**Факты для карточек**
- base | Каким флагом компиляции включается ThreadSanitizer? — `-fsanitize=thread`
- core | Почему чистый прогон под TSan не гарантирует отсутствие гонки в коде вообще? — TSan ловит гонку по факту конкретной раскладки потоков в конкретном запуске, а не статическим анализом всех возможных раскладок
Ссылка на задачу этого дня: `tasks/06_threads` — потокобезопасная ограниченная очередь,
проверяется `python3 grade.py 06` (все `ok`, TSan чистый, нет зависания).
<details>
<summary>Проверь себя</summary>
1. Почему `cv.wait(lock)` обязан принимать `unique_lock`, уже захвативший тот же мьютекс, что
защищает разделяемое состояние, а не произвольный лок?
<details><summary>Ответ</summary>`wait` должен атомарно освободить именно этот мьютекс
перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат
под разными блокировками, и защита состояния перестала бы работать.</details>
2. Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно
изменить, чтобы устранить дедлок, не трогая логику самой критической секции?
<details><summary>Ответ</summary>Привести захват к единому порядку во всей программе
(например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать
оба мьютекса разом через `std::scoped_lock`.</details>
3. Почему data race на обычном `int` без `std::atomic` считается UB, даже если на конкретном
процессоре чтение/запись `int` физически выполняются одной инструкцией?
<details><summary>Ответ</summary>Потому что стандарт C++ формально не гарантирует
атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в
регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо
действительно рвёт запись на части.</details>
4. Чем `memory_order_acquire` отличается от `memory_order_relaxed` по факту разрешённых
перестановок кода?
<details><summary>Ответ</summary>`relaxed` гарантирует только атомарность самой операции;
`acquire` дополнительно запрещает переносить более поздние по коду обращения к памяти до
этой операции — то есть даёт порядок, а не только неделимость.</details>
</details>
## Материалы
- cppreference: `std::mutex` — https://en.cppreference.com/w/cpp/thread/mutex
- cppreference: `std::condition_variable` — https://en.cppreference.com/w/cpp/thread/condition_variable
- cppreference: `std::atomic` и `memory_order` — https://en.cppreference.com/w/cpp/atomic/memory_order
- Clang: документация ThreadSanitizer — https://clang.llvm.org/docs/ThreadSanitizer.html
- cppreference: undefined behavior (data race) — https://en.cppreference.com/w/cpp/language/ub
- Linux kernel: "volatile considered harmful" — https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html