349 lines
35 KiB
Markdown
349 lines
35 KiB
Markdown
# 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
|