Files
eduplan-cpp-eltex/lessons/D3_threads.md
T

349 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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