35 KiB
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); недостаточен —
правильный паттерн:
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 — оба висят навсегда.
Четыре условия Коффмана, необходимые одновременно для дедлока:
- Взаимное исключение — ресурс занят не более чем одним потоком.
- Удержание и ожидание — поток держит один ресурс и ждёт другой.
- Невозможность принудительного отбора — ресурс нельзя отобрать у держащего потока.
- Круговое ожидание — цикл потоков, каждый ждёт ресурс следующего.
Разрушить достаточно одно условие. На практике проще всего разрушить круговое ожидание:
всегда захватывать несколько мьютексов в едином порядке во всей программе (например, по
адресу объекта или по заранее заданному номеру) — тогда цикл ожидания просто не может
образоваться. Там, где несколько мьютексов захватываются в одном месте кода, для этого есть
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 — ограниченная блокирующая очередь:
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 чистый, нет зависания).
Проверь себя
-
Почему
cv.wait(lock)обязан приниматьunique_lock, уже захвативший тот же мьютекс, что защищает разделяемое состояние, а не произвольный лок?Ответ
`wait` должен атомарно освободить именно этот мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат под разными блокировками, и защита состояния перестала бы работать. -
Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно изменить, чтобы устранить дедлок, не трогая логику самой критической секции?
Ответ
Привести захват к единому порядку во всей программе (например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать оба мьютекса разом через `std::scoped_lock`. -
Почему data race на обычном
intбезstd::atomicсчитается UB, даже если на конкретном процессоре чтение/записьintфизически выполняются одной инструкцией?Ответ
Потому что стандарт C++ формально не гарантирует атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо действительно рвёт запись на части. -
Чем
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