Files

35 KiB
Raw Permalink Blame History

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 — оба висят навсегда.

Четыре условия Коффмана, необходимые одновременно для дедлока:

  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 — ограниченная блокирующая очередь:

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` дополнительно запрещает переносить более поздние по коду обращения к памяти до этой операции — то есть даёт порядок, а не только неделимость.

Материалы