# 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 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` для простых типов (счётчики, флаги, указатели) дешевле мьютекса: операции выполняются одной аппаратной атомарной инструкцией процессора (например 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 чистый, нет зависания).
Проверь себя 1. Почему `cv.wait(lock)` обязан принимать `unique_lock`, уже захвативший тот же мьютекс, что защищает разделяемое состояние, а не произвольный лок?
Ответ`wait` должен атомарно освободить именно этот мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат под разными блокировками, и защита состояния перестала бы работать.
2. Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно изменить, чтобы устранить дедлок, не трогая логику самой критической секции?
ОтветПривести захват к единому порядку во всей программе (например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать оба мьютекса разом через `std::scoped_lock`.
3. Почему data race на обычном `int` без `std::atomic` считается UB, даже если на конкретном процессоре чтение/запись `int` физически выполняются одной инструкцией?
ОтветПотому что стандарт C++ формально не гарантирует атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо действительно рвёт запись на части.
4. Чем `memory_order_acquire` отличается от `memory_order_relaxed` по факту разрешённых перестановок кода?
Ответ`relaxed` гарантирует только атомарность самой операции; `acquire` дополнительно запрещает переносить более поздние по коду обращения к памяти до этой операции — то есть даёт порядок, а не только неделимость.
## Материалы - 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