Многопоточность в C++: примитивы, техники синхронизации и практические лайфхаки

Курс охватывает многопоточность в современном C++: модель памяти, стандартные примитивы синхронизации и техники построения более сложных схем. Разбираем типовые проблемы (гонки, дедлоки, ABA), практики проектирования, отладку и оптимизацию конкурентного кода.

1. Базовая модель: потоки, задачи, время жизни и исключения

Базовая модель: потоки, задачи, время жизни и исключения

В многопоточности на C++ почти все проблемы упираются в четыре вещи:

  • Кто выполняет работу (поток или пул потоков)
  • Что именно выполняется (функция/задача)
  • Сколько живут поток, задача и данные, с которыми они работают
  • Что происходит при ошибках (исключения, отмена, аварийное завершение)
  • Эта статья задаёт базовую модель, на которую будет опираться весь курс: дальше мы будем добавлять примитивы синхронизации (mutex/condvar/atomic), техники (lock ordering, thread confinement, message passing) и практические лайфхаки.

    Ментальная модель: поток vs задача

    Поток (thread) — это исполняющий контекст ОС.

    Задача (task) — это единица работы, которую можно выполнить:

  • в новом потоке
  • в одном из потоков пула
  • лениво (в момент запроса результата)
  • В стандартной библиотеке C++ это примерно соответствует:

  • std::thread / std::jthreadуправление потоком
  • std::async, std::packaged_task, std::promise + std::futureуправление задачей и результатом
  • Ключевой практический вывод:

  • управлять потоками вручную дорого и рискованно
  • чаще безопаснее управлять задачами и результатами, а не потоками
  • std::thread: минимальная база

    std::thread создаёт новый поток и запускает в нём вызываемый объект.

    Время жизни std::thread: joinable, join, detach

    У std::thread есть важнейшее состояние: joinable.

  • Поток joinable, если он связан с реально запущенным потоком исполнения и вы ещё не сделали join() или detach().
  • При разрушении std::thread, если он joinable, программа вызывает std::terminate().
  • Это сделано намеренно: чтобы вы не забывали явно определить судьбу потока.

    join():

  • блокирует текущий поток до завершения целевого
  • гарантирует, что работа закончилась до продолжения
  • detach():

  • “отцепляет” поток: std::thread больше его не контролирует
  • целевой поток продолжает выполняться сам по себе
  • Главный риск detach():

  • вы очень легко получаете обращение к уничтоженным данным (dangling references)
  • вы теряете канал для ошибок и результата
  • !Диаграмма жизненного цикла std::thread и точек, где можно получить std::terminate

    Справка: std::thread.

    Передача аргументов и типичные ошибки времени жизни

    Аргументы в std::thread по умолчанию копируются/перемещаются во внутреннее хранилище потока.

    Если вы хотите передать ссылку, нужен std::ref.

    Практическая ловушка: ссылка должна жить дольше потока.

    RAII для потоков: не забыть join

    Из-за правила “joinable в деструкторе => terminate” в реальном коде удобно применять RAII-обёртку, которая в деструкторе гарантирует join().

    Это особенно важно при исключениях: RAII гарантирует корректное завершение.

    std::jthread: поток “с батарейками” (C++20)

    std::jthread — улучшенный поток:

  • в деструкторе автоматически делает join
  • поддерживает кооперативную остановку через std::stop_token
  • Важно:

  • остановка кооперативная: поток должен сам периодически проверять stop_requested() или использовать прерываемые ожидания
  • Справка: std::jthread, std::stop_token.

    Исключения и потоки: что происходит на самом деле

    Правило: исключение не может “перепрыгнуть” границу потока само по себе.

    Если функция, выполняемая в std::thread, выбросит исключение и оно не будет поймано внутри потока, то будет вызван std::terminate().

    Правильные варианты:

  • ловить исключения внутри потока
  • передавать ошибку в основной поток через std::exception_ptr
  • использовать std::future/std::async, где исключение автоматически переносится в get()
  • Передача исключений вручную: std::exception_ptr

    Этот способ требует синхронизации доступа к ep, если запись/чтение могут быть конкурентными. В примере чтение происходит после join(), поэтому гонки нет.

    Справка: std::exception_ptr.

    Задачи и результаты: future, promise, async

    Когда вам нужен результат или исключение из параллельной работы, удобнее мыслить задачей, а не потоком.

    std::future: контракт “результат будет позже”

    std::future<T> — это объект, из которого можно получить T (или исключение) позже методом get().

  • get() блокируется, пока результата нет
  • если задача завершилась исключением, get() выбросит его в вызывающем потоке
  • Справка: std::future.

    std::promise: вручную выполнить обещание

    std::promise<T> — это объект, который устанавливает значение или исключение, связанное с future.

    Справка: std::promise.

    std::async: “запусти задачу и дай future”

    std::async возвращает std::future, связывая его с выполнением функции.

    Ключевой момент: политика запуска.

  • std::launch::async — задача должна стартовать асинхронно (обычно в отдельном потоке или в пуле реализации)
  • std::launch::deferred — задача не стартует, пока вы не вызовете get()/wait(), и выполнится в вызывающем потоке
  • без явной политики реализация может выбрать любой вариант
  • Справка: std::async.

    std::packaged_task: задача как объект

    std::packaged_task<R()> упаковывает вызываемый объект так, что его результат будет доступен через future.

    Это удобно для построения собственных очередей задач и пулов потоков (к этому мы вернёмся позже в курсе).

    Справка: std::packaged_task.

    Блокировки по ожиданию: sleep_for и минимальная дисциплина

    На старте важно различать:

  • ожидание по времени (std::this_thread::sleep_for, sleep_until)
  • ожидание по событию (условные переменные, семафоры, флаги)
  • sleep_for не синхронизирует доступ к данным и почти никогда не является правильным механизмом координации потоков — это лишь способ “не крутиться в цикле” или сделать задержку.

    Справка: std::this_thread::sleep_for.

    Практические правила и лайфхаки этой базы

  • Не используйте detach() без очень ясного дизайна времени жизни данных: это частая причина скрытых падений.
  • Всегда определяйте владельца потока: кто обязан сделать join() и когда.
  • Исключения из потоков не “доходят” сами: либо ловите внутри, либо используйте future, либо передавайте exception_ptr.
  • По умолчанию предпочитайте std::jthread вместо std::thread в новом коде (если доступен C++20).
  • Предпочитайте “задача + future” вместо “поток + shared state”: проще композиция, лучше обработка ошибок.
  • Ссылки в поток — только осознанно (std::ref) и только если объект гарантированно живёт дольше потока.
  • Что дальше по курсу

    Дальше мы будем наращивать эту базу:

  • гонки данных и модель памяти (почему “оно иногда работает”)
  • mutex/lock_guard/unique_lock и дисциплина захвата
  • условные переменные и правильные ожидания
  • атомики и lock-free паттерны
  • отмена, таймауты, пулы потоков и очереди задач
  • Пока важно довести до автоматизма: время жизни и канал ошибок/результата — это основа любой корректной многопоточности.

    2. Модель памяти C++ и атомарные операции: orderings, fences, ABA

    Модель памяти C++ и атомарные операции: orderings, fences, ABA

    После базовой модели из прошлой статьи (потоки, задачи, время жизни и исключения) следующий слой — модель памяти. Она отвечает на главный вопрос многопоточности: когда и в каком порядке изменения из одного потока становятся видимыми в другом, и что вообще означает «правильно синхронизировано».

    Если этот слой пропустить, то mutex/condition_variable/atomic превращаются в набор “магических” инструментов, а ошибки выглядят как «иногда работает».

    Зачем нужна модель памяти

    В однопоточном коде кажется очевидным:

  • операция A в тексте раньше операции B
  • значит A “произойдёт” раньше B
  • В многопоточном коде это не гарантируется:

  • компилятор может переупорядочивать инструкции
  • процессор может переупорядочивать выполнение и задерживать публикацию записей
  • кеши и буферы записи могут “разводить” наблюдаемую картину по потокам
  • Модель памяти C++ формализует, что можно считать корректным и какие гарантии дают разные примитивы.

    Гонка данных и почему это не просто “плохой результат”

    Гонка данных (data race) в C++ — это когда два потока обращаются к одной и той же памяти, и:

  • хотя бы одно обращение — запись
  • нет правильной синхронизации (например, нет атомиков или блокировок)
  • Последствие в C++ жёсткое: поведение не определено (undefined behavior).

    Это важно:

  • это не “возможна потеря обновления”, а компилятор имеет право сделать что угодно
  • оптимизации могут сломать даже “логически понятный” код
  • Пример гонки:

    Правило курса: если есть совместный доступ без mutex или без атомиков — вы на минном поле.

    Справка: Гонка данных.

    Основные отношения: sequenced-before, synchronizes-with, happens-before

    Чтобы говорить о порядке, C++ использует несколько отношений.

  • Sequenced-before — порядок внутри одного потока (что “по программе раньше/позже”).
  • Synchronizes-with — связь между потоками через синхронизационные операции (например, release/acquire).
  • Happens-before — “итоговый” порядок видимости: если A happens-before B, то эффекты A обязаны быть видны в B.
  • Идея:

  • внутри потока порядок задаёт sequenced-before
  • между потоками мост строится synchronizes-with
  • их транзитивное замыкание даёт happens-before
  • !Схема, показывающая как release/acquire делает обычные записи видимыми в другом потоке

    Справка: Модель памяти (cppreference).

    Что такое атомики в C++ и что они гарантируют

    std::atomic<T> даёт:

  • атомарность отдельных операций над объектом T (без torn read/write)
  • инструменты упорядочивания и публикации видимости через memory order
  • Это не означает:

  • “все данные вокруг автоматически защищены”
  • “всё становится последовательным”
  • Атомик — это точка синхронизации, вокруг которой вы строите правила.

    Справка: std::atomic.

    Memory order: какие бывают и для чего

    В C++ порядок задаётся значением std::memory_order.

    | Порядок | Что гарантирует | Типичное применение | |---|---|---| | relaxed | только атомарность, без межпоточного порядка | счётчики, статистика | | release | все записи до store “публикуются” | публикация данных + флаг | | acquire | после load видны публикации release | чтение флага готовности | | acq_rel | acquire + release для RMW | lock-free структуры | | seq_cst | глобальный единый порядок для seq_cst операций | проще думать, труднее оптимизировать |

    Справка: std::memory_order.

    memory_order_relaxed: атомарно, но почти без гарантий

    relaxed говорит:

  • операция атомарна
  • но не создаёт happens-before с другими потоками
  • Пример корректного применения:

    Здесь нам важна корректность счётчика, но не важно, когда другой поток увидит обновление относительно других данных.

    Типичная ошибка: использовать relaxed как “флаг готовности”, надеясь, что вместе с ним “доедут” обычные данные.

    Release/Acquire: базовый кирпич “публикация данных”

    Классический паттерн: один поток готовит данные, потом поднимает флаг; другой поток ждёт флаг и читает данные.

    Что именно гарантируется:

  • в producer: все записи до store(release) становятся видимыми потоку, который сделает load(acquire) и увидит true
  • в consumer: все чтения/записи после load(acquire) не “уедут” до него
  • Важно:

  • release работает только для записывающих операций (store / часть RMW)
  • acquire работает только для читающих операций (load / часть RMW)
  • связь возникает, только если acquire действительно наблюдает значение, записанное release (или более новое в той же модификационной последовательности)
  • memory_order_acq_rel для read-modify-write

    Операции типа fetch_add, exchange, compare_exchange одновременно читают и пишут.

  • acq_rel означает: “на чтении — acquire, на записи — release”
  • Пример (упрощённо): публикация указателя одним потоком через CAS.

    Этот код намеренно неполный с точки зрения безопасного управления памятью (см. раздел про ABA), но он показывает типичный стиль: разные memory order для успеха и провала CAS.

    Справка: compare_exchange_weak.

    memory_order_seq_cst: самый “простой для головы”, но не всегда лучший

    seq_cst добавляет сильную гарантию:

  • все seq_cst операции над атомиками образуют единый глобальный порядок, одинаково наблюдаемый всеми потоками
  • Это упрощает рассуждение, но может стоить производительности и мешать компилятору/процессору оптимизировать.

    Практическое правило:

  • если вы не уверены — начните с seq_cst
  • когда появится измеримая необходимость — ослабляйте до acquire/release/relaxed точечно
  • Модификационный порядок (modification order) и “почему я увидел не то”

    Для каждого отдельного атомика C++ определяет modification order:

  • все записи в этот атомик (включая RMW) упорядочены в единую цепочку
  • любой поток наблюдает эти записи в некотором согласованном порядке
  • Но важный нюанс:

  • это относится к одному атомику
  • оно не говорит, как “согласуются” между собой изменения разных атомиков без дополнительных гарантий
  • Именно поэтому “пара флагов” без продуманного протокола часто приводит к неожиданным межпоточным наблюдениям.

    Fences: когда не хватает store/load

    Fence — это барьер упорядочивания для текущего потока.

    В C++ это std::atomic_thread_fence(order).

    Справка: std::atomic_thread_fence.

    Зачем fences вообще нужны

    Обычно проще и яснее использовать:

  • store(release)
  • load(acquire)
  • Но fences появляются, когда:

  • вы работаете с низкоуровневыми протоколами
  • вам нужно упорядочить обычные операции относительно атомарной операции, которая сама по себе может быть relaxed
  • вы реализуете примитивы синхронизации
  • Пример идеи “release fence + relaxed store”

    Иногда пишут:

    Смысл:

  • fences создают порядок вокруг обычных операций, а атомик используется как “переносчик” значения
  • Но практический совет:

  • в прикладном коде почти всегда лучше заменить это на flag.store(..., release) и flag.load(..., acquire)
  • fences легче использовать неправильно, а проверять сложнее
  • Атомики не заменяют мьютекс “вообще”: типичные ловушки

  • Атомарность не делает неатомарные данные безопасными. Нужен протокол публикации (например, release/acquire) или mutex.
  • Слишком слабый ordering ломает инварианты. “Счётчик готовности” на relaxed часто неверен.
  • Spin-wait без паузы может убить производительность. Для ожидания обычно лучше условные переменные/семафоры (будут в следующих статьях), а для спина хотя бы std::this_thread::yield() или платформенный pause (аккуратно).
  • ABA-проблема: когда CAS “успешен”, но логика сломана

    ABA — классическая проблема lock-free алгоритмов на CAS.

    Сценарий:

  • поток 1 читает head = A
  • поток 2 меняет head с A на B, потом снова на A (узел A мог быть удалён и даже переиспользован)
  • поток 1 делает compare_exchange(head, A -> ...) и он успешен, потому что значение снова A
  • С точки зрения CAS всё “ок”, но с точки зрения смысла — поток 1 работал с устаревшей реальностью.

    !Иллюстрация ABA: значение атомика вернулось к A, но состояние структуры изменилось

    Почему ABA опасна

  • вы можете получить use-after-free
  • вы можете “потерять” узлы в стеке/очереди
  • вы можете нарушить инварианты структуры данных, хотя все CAS формально успешны
  • Важно понимать границу:

  • memory order (acquire/release/seq_cst) решает видимость и порядок
  • ABA — это проблема идентичности и времени жизни объектов, она не лечится только orderings
  • Типовые способы борьбы с ABA

  • Tagged pointer / version counter: хранить вместе с указателем счётчик версии и сравнивать пару.
  • - идея: вместо A сравнивать (A, ver); если было A→B→A, версия изменится - на практике нужно место под тэг (например, выравнивание) или атомик для пары (не всегда возможен без платформенной поддержки)
  • Hazard pointers: поток объявляет “я сейчас читаю вот этот узел”, и удаление откладывается, пока есть “опасные” ссылки
  • Epoch based reclamation: освобождение памяти откладывается до момента, когда гарантированно нет читателей из старой эпохи
  • Ссылочный счётчик: иногда применимо, но может быть дорогим и само по себе требует аккуратной атомарной работы
  • Стандартная библиотека C++ не предоставляет hazard pointers/epoch reclamation “из коробки”, поэтому в прикладном коде часто выгоднее:

  • использовать mutex-защиту
  • или использовать готовые проверенные библиотеки lock-free структур, а не писать свои
  • Практические правила (чтобы реже стрелять себе в ногу)

  • Не лечите гонки volatile. volatile не про межпоточную синхронизацию в C++.
  • Начинайте с простого. Если можно решить через mutex — решайте через mutex. Lock-free имеет смысл только при явной потребности.
  • Если делаете “флаг готовности” — используйте release/acquire. Это базовый безопасный протокол публикации.
  • relaxed оставляйте для независимой статистики. Если порядок важен — relaxed почти всегда ошибка.
  • ABA — это про время жизни памяти. Если вы пишете lock-free стек/очередь — заранее планируйте reclamation.
  • Что дальше по курсу

    Следующие статьи будут опираться на эту модель:

  • mutex и дисциплина владения (почему мьютекс проще: он строит happens-before автоматически)
  • условные переменные и ожидания “по событию”, а не через sleep_for
  • семафоры, latch/barrier и другие примитивы координации
  • практические паттерны: lock ordering, thread confinement, message passing
  • Материал этой статьи — фундамент: чтобы правильно использовать синхронизацию, нужно уметь отличать атомарность от порядка и видимости, и помнить, что некоторые проблемы (например, ABA) лежат вообще в другой плоскости — управлении временем жизни данных.

    3. Примитивы синхронизации стандарта: mutex, timed_mutex, shared_mutex

    Примитивы синхронизации стандарта: mutex, timed_mutex, shared_mutex

    В прошлых статьях мы договорились о базовой модели (потоки/задачи/время жизни/исключения) и о том, что без синхронизации гонки данных в C++ — это UB, а не “редкий баг”. Теперь переходим к самому практичному уровню: примитивам блокировок стандартной библиотеки.

    Ключевая идея: мьютекс — это не только взаимное исключение, но и синхронизация видимости. Он “сшивает” модель памяти так, что после правильного unlock() в одном потоке другой поток, сделавший lock(), увидит все изменения защищённых данных.

    Справочные страницы:

  • std::mutex
  • std::timed_mutex
  • std::shared_mutex
  • std::lock_guard
  • std::unique_lock
  • std::scoped_lock
  • std::shared_lock
  • std::lock
  • Что именно гарантирует мьютекс в C++

    Если несколько потоков обращаются к общей структуре данных, то “правильный” дизайн обычно выглядит так:

  • у структуры есть мьютекс
  • любой доступ к данным выполняется только под этим мьютексом
  • время жизни данных и мьютекса гарантированно больше времени жизни потоков, которые их используют
  • Почему это работает на уровне модели памяти:

  • unlock() в потоке A synchronizes-with успешным lock() того же мьютекса в потоке B
  • значит, все записи в критической секции потока A становятся видимыми в критической секции потока B
  • Практическое следствие: с мьютексом вам обычно не нужно думать про memory_order и fences для защищаемых данных — мьютекс уже строит нужные отношения happens-before.

    !Как мьютекс обеспечивает видимость изменений между потоками

    std::mutex: базовая взаимная блокировка

    std::mutex — не рекурсивный мьютекс: поток, который уже владеет им, не может захватить его повторно, иначе будет deadlock.

    RAII: правильно захватывать и освобождать

    Базовое правило: не делать lock()/unlock() руками без крайней необходимости. Используйте RAII-обёртки.

    std::lock_guard:

  • захватывает мьютекс в конструкторе
  • освобождает в деструкторе
  • не умеет unlock() раньше конца области видимости
  • Обратите внимание на mutable: он нужен, чтобы синхронизировать доступ даже из const-методов. Это нормальная практика, потому что мьютекс — не “логическое состояние данных”, а механизм обеспечения корректности.

    std::unique_lock: более гибкий захват

    std::unique_lock тяжелее, чем lock_guard, но даёт возможности:

  • отложенный захват через std::defer_lock
  • попытка захвата через try_to_lock
  • ручной unlock() и повторный lock()
  • “передача владения” через перемещение
  • Практическое правило:

  • если вам достаточно “захватить на время области видимости” — берите lock_guard
  • если нужна гибкость — берите unique_lock
  • try_lock: “не блокироваться” и типичные ошибки

    У std::mutex есть try_lock(): возвращает true, если захват успешен.

    Проблема в том, что try_lock часто провоцирует плохие паттерны:

  • активное ожидание в цикле (busy-wait), которое жжёт CPU
  • нет явной политики, что делать при неуспехе
  • Если вы всё же используете try_lock, вам нужен план:

  • что делает поток при провале
  • как он избегает бесконечного кручения
  • Deadlock: главная бытовая проблема мьютексов

    Deadlock обычно возникает, когда два потока захватывают несколько мьютексов в разном порядке.

    Типичный сценарий

    Оба потока могут навсегда ждать друг друга.

    std::lock и std::scoped_lock: безопасный захват нескольких мьютексов

    std::lock(m1, m2, ...) захватывает несколько мьютексов без deadlock (используя согласованный алгоритм).

    std::scoped_lock (C++17) — RAII-обёртка, которая внутри использует std::lock для нескольких мьютексов.

    Практическое правило: если вам всегда нужно захватывать набор мьютексов вместе — используйте scoped_lock.

    std::recursive_mutex: почему почти всегда лучше не надо

    std::recursive_mutex позволяет одному потоку захватывать один и тот же мьютекс несколько раз (счётчик захватов).

    Справка: std::recursive_mutex.

    Это кажется удобным, но часто маскирует проблемы дизайна:

  • критические секции разрастаются
  • сложно понять, где реально держится блокировка
  • повышается риск инверсии порядка захвата и скрытых deadlock
  • Используйте recursive_mutex, только если:

  • вы действительно не можете перепроектировать код
  • вы понимаете, где и почему возникает повторный захват
  • std::timed_mutex: мьютекс с таймаутами

    std::timed_mutex добавляет:

  • try_lock_for(duration)
  • try_lock_until(time_point)
  • Справка: std::timed_mutex.

    Когда полезен таймаут

    Таймауты обычно применяют, когда нужно:

  • не зависнуть навсегда при внешних сбоях
  • сделать “best effort” работу: если не удалось — пропустить, деградировать, вернуть ошибку
  • реализовать периодическое обслуживание, которое не должно мешать основному потоку
  • Пример: периодическая статистика, которая не должна тормозить горячий путь.

    Важный нюанс про “таймауты”

    try_lock_for не гарантирует точного времени ожидания:

  • поток может проснуться позже из-за планировщика
  • реализация может иметь особенности
  • Таймаут — это политика отказа, а не высокоточный таймер.

    recursive_timed_mutex

    Есть и комбинация: std::recursive_timed_mutex.

    Справка: std::recursive_timed_mutex.

    Практический совет: если вам одновременно нужны рекурсия и таймауты, обычно стоит остановиться и ещё раз проверить архитектуру.

    std::shared_mutex: “много читателей, один писатель”

    std::shared_mutex (C++17) — это мьютекс с двумя режимами:

  • разделяемый (shared) захват для чтения: много потоков могут держать одновременно
  • уникальный (exclusive) захват для записи: только один поток, и он исключает читателей
  • Справка: std::shared_mutex.

    Как им пользоваться

    Для чтения используют std::shared_lock, для записи — обычный std::unique_lock или std::lock_guard.

    Справки:

  • std::shared_lock
  • std::unique_lock
  • Когда shared_mutex ускоряет, а когда делает хуже

    shared_mutex полезен, если одновременно выполняются условия:

  • чтений сильно больше, чем записей
  • критические секции на чтение достаточно длинные, чтобы окупить накладные расходы
  • нет постоянного потока записей, который “срывает” читателей
  • Когда может стать хуже:

  • критические секции очень короткие (накладные расходы RW-блокировки выше)
  • много записей или записи частые
  • на конкретной платформе реализация shared_mutex тяжёлая
  • Практическое правило: shared_mutex — это оптимизация, её стоит подтверждать измерениями.

    Голодание (starvation) читателей или писателей

    Для RW-блокировок возможна ситуация, когда:

  • приоритет отдан читателям, и писатель долго не может получить эксклюзивный доступ
  • или приоритет отдан писателям, и читатели “пачками” простаивают
  • Стандарт не обещает справедливости. Если вам нужны гарантии по справедливости, иногда проще:

  • перейти на обычный mutex
  • использовать более специализированные примитивы/библиотеки
  • менять дизайн (например, sharding: несколько независимых мьютексов по сегментам данных)
  • shared_timed_mutex

    std::shared_timed_mutex (C++14) — аналог shared_mutex с таймаутами.

    Справка: std::shared_timed_mutex.

    Если у вас C++17+, чаще выбирают shared_mutex, а таймауты добавляют только при реальной необходимости.

    Практические лайфхаки и дисциплина использования

  • Один инвариант — один мьютекс: данные, которые должны изменяться согласованно, должны защищаться одним и тем же замком.
  • Держите блокировку минимально возможное время: подготовку данных делайте до захвата, тяжёлую работу и I/O делайте после освобождения.
  • Не вызывайте чужой код под мьютексом, если можете избежать:
  • - колбэки - виртуальные методы - произвольные пользовательские функции Это снижает риск deadlock и непредсказуемых задержек.
  • Всегда фиксируйте порядок захвата нескольких мьютексов:
  • - либо строгий порядок по адресу/ID - либо std::scoped_lock / std::lock
  • Не “лечите” гонки volatile: в C++ volatile не является межпоточной синхронизацией.
  • Сначала делайте правильно, потом быстрее: shared_mutex, try_lock_for, хитрые протоколы — это инструменты оптимизации и контроля деградации, но базовая корректность обычно проще на mutex.
  • Связь с дальнейшими темами курса

    Эта статья закрывает “блокировочную” половину синхронизации. Дальше нам понадобятся ещё два уровня:

  • ожидание по событию, а не по времени: условные переменные, семафоры, барьеры
  • техники построения протоколов: lock ordering, thread confinement, message passing
  • На практике мьютексы и RW-мьютексы часто комбинируются с ожиданием (например, “положили элемент в очередь и разбудили потребителя”). Но корректно ждать мы будем учиться отдельно — чтобы не скатиться в sleep_for и редкие “иногда зависает”.

    4. Условные переменные, семафоры, барьеры и latch: ожидание и координация

    Условные переменные, семафоры, барьеры и latch: ожидание и координация

    В прошлой статье мы разобрали mutex/shared_mutex и дисциплину владения блокировками. Но мьютекс отвечает только на вопрос кто сейчас может трогать данные. В реальных системах есть второй вопрос: когда имеет смысл продолжать работу.

    Если поток ждёт “пока что-то появится”, у него есть два плохих варианта:

  • крутиться в цикле (busy-wait) и жечь CPU
  • спать sleep_for и получать задержки, дрожание таймингов и редкие зависания
  • Эта статья — про правильное ожидание по событию и про примитивы координации в стандарте C++.

    Мы разберём:

  • std::condition_variable и типичные протоколы ожидания
  • семафоры C++20: std::counting_semaphore и std::binary_semaphore
  • “старт/финиш вместе”: std::latch и std::barrier
  • практические ловушки: потерянные уведомления, ложные пробуждения, “thundering herd”
  • Ментальная модель: состояние и событие

    Правильная координация почти всегда строится вокруг двух вещей:

  • разделяемое состояние (например, очередь задач, флаг остановки, счётчик)
  • событие изменения состояния (например, “в очереди появился элемент”)
  • Важно: ожидание должно быть привязано не к уведомлению как таковому, а к предикату над состоянием.

    Формула (словами): ждём, пока предикат станет истинным; после пробуждения всегда перепроверяем предикат.

    !Диаграмма правильной связки: mutex защищает состояние, condition_variable будит ожидание по предикату

    Условные переменные: std::condition_variable

    std::condition_variable — механизм, который позволяет потоку уснуть, отпустив мьютекс, и проснуться, когда другой поток сообщит об изменении состояния.

    Справка: std::condition_variable.

    Главный контракт wait: отпустить мьютекс и уснуть атомарно

    Ключевая операция:

  • поток держит std::unique_lock<std::mutex>
  • вызывает cv.wait(lock, pred)
  • При этом ожидание делает две вещи как единый протокол:

  • атомарно отпускает мьютекс и переводит поток в сон
  • при пробуждении снова захватывает мьютекс и только потом возвращает управление
  • Почему нужен именно std::unique_lock:

  • wait должен уметь временно отпустить и снова захватить мьютекс
  • std::lock_guard так не умеет
  • Справка: std::unique_lock.

    Ложные пробуждения и почему wait почти всегда должен иметь предикат

    Условные переменные допускают spurious wakeups: поток может проснуться без notify_* и без фактического изменения состояния.

    Поэтому корректный код:

  • всегда ждёт в форме wait(lock, pred)
  • или делает цикл while (!pred()) cv.wait(lock);
  • Это не “паранойя”, а часть спецификации.

    Producer-consumer: канонический пример с очередью

    Ниже — минимальный шаблон, который закрывает типовые ошибки: предикат, корректный notify_one, корректный сигнал завершения.

    Что здесь важно:

  • состояние q_ и closed_ защищены одним mutex
  • ожидание привязано к предикату closed_ || !q_.empty()
  • close() делает notify_all(), потому что потенциально нужно разбудить всех ожидающих потребителей
  • notify_one vs notify_all

  • notify_one() будит одного ожидающего
  • notify_all() будит всех
  • Выбор зависит от политики:

  • если событие “появился один ресурс” — обычно notify_one
  • если событие меняет глобальное условие ожидания для многих (например, остановка, закрытие, смена фазы) — чаще notify_all
  • Практическая проблема notify_all: эффект thundering herd — просыпаются многие потоки, но реальную работу может сделать один; остальные снова засыпают, создавая лишние переключения контекста.

    “Потерянное уведомление” и как его избежать

    Частая ошибка мышления: “я вызвал notify_one, значит кто-то обязательно проснётся и увидит событие”.

    На самом деле notify_* не хранит “счётчик уведомлений”. Если никого не ждёт в момент уведомления — уведомление пропадает.

    Как избежать:

  • всегда меняйте состояние под мьютексом
  • ожидайте состояние через предикат под тем же мьютексом
  • Тогда даже если уведомление “пролетело”, поток при входе в wait(lock, pred) сразу увидит истинный предикат и не уснёт.

    Нужно ли звать notify_* под мьютексом

    Обычно рекомендуемая практика:

  • изменить состояние под мьютексом
  • отпустить мьютекс
  • вызвать notify_one/notify_all
  • Причина практическая: если разбудить поток, пока вы всё ещё держите мьютекс, он проснётся и сразу упрётся в блокировку, что может увеличить конкуренцию.

    Но есть тонкость:

  • корректность определяется тем, что состояние изменено под мьютексом и wait проверяет предикат под тем же мьютексом
  • порядок “unlock затем notify” или “notify затем unlock” сам по себе не делает код корректным или некорректным, если протокол “состояние + предикат” соблюдён
  • std::condition_variable_any и “не только mutex”

    std::condition_variable_any умеет ждать на любом типе блокировки, который удовлетворяет требованиям (например, на пользовательском мьютексе).

    Справка: std::condition_variable_any.

    Цена — обычно больше накладных расходов. В прикладном коде чаще достаточно обычного condition_variable.

    Семафоры C++20: std::counting_semaphore и std::binary_semaphore

    Семафор — это примитив, который хранит счётчик разрешений. Поток может:

  • acquire() — дождаться, пока счётчик станет положительным, затем уменьшить его на 1
  • release(n) — увеличить счётчик на n и разбудить ожидающих
  • Справки:

  • std::counting_semaphore
  • std::binary_semaphore
  • Когда семафор проще условной переменной

    Семафор особенно удобен, когда вы синхронизируете именно ресурс, а не произвольный предикат:

  • ограничение параллелизма (не больше N активных запросов)
  • подсчёт “сколько задач готово к обработке”
  • реализация пула ресурсов
  • Пример: лимит параллелизма

    Практический лайфхак: делайте RAII-обёртку для acquire/release, чтобы не протекать при исключениях.

    Таймауты у семафоров

    У семафоров есть:

  • try_acquire()
  • try_acquire_for(duration)
  • try_acquire_until(time_point)
  • Это удобный механизм “best effort”: если ресурс не появился быстро — деградируем, возвращаем ошибку, пропускаем работу.

    Семафор vs condition_variable

    | Ситуация | Обычно лучше | Почему | |---|---|---| | Ждать произвольный предикат над состоянием (очередь пуста, флаг закрытия, несколько условий) | condition_variable | Предикат может быть сложным, состояние защищено мьютексом | | Ждать “разрешение/токен”, счётчик ресурсов | counting_semaphore | Счётчик встроен, меньше ручного протокола | | Нужна передача данных вместе с сигналом | condition_variable + структура данных | Семафор будит, но данные всё равно нужно где-то хранить |

    std::latch: дождаться, пока N событий произойдут

    std::latch (C++20) — одноразовый примитив “обратного отсчёта”:

  • инициализируется числом (обычно “сколько участников должно закончить”)
  • участники вызывают count_down()
  • ожидающий вызывает wait() и просыпается, когда счётчик стал 0
  • Справка: std::latch.

    Пример: главный поток ждёт, пока рабочие инициализируются

    Ключевые свойства:

  • latch одноразовый: после достижения нуля его нельзя “перезарядить”
  • это удобно для фаз “подготовка -> старт”, “загрузка -> запуск сервиса”
  • std::barrier: многофазная синхронизация “все дошли — все пошли дальше”

    std::barrier (C++20) — примитив для повторяющихся фаз:

  • есть фиксированное число участников
  • каждый вызывает arrive_and_wait() в конце фазы
  • когда все пришли, барьер “открывается”, и все переходят к следующей фазе
  • Справка: std::barrier.

    !Иллюстрация многофазной синхронизации через barrier

    Completion function: “один поток выполняет действие при открытии барьера”

    std::barrier может иметь completion function — функцию, которая выполняется один раз на фазу, когда все участники пришли.

    Это удобно, например, чтобы:

  • пересобрать общий буфер
  • переключить поколение данных
  • обновить глобальное состояние между фазами
  • Упрощённый пример структуры:

    Практическое замечание: completion function выполняется в одном из участвующих потоков, поэтому она должна быть быстрой и не должна пытаться ждать этот же барьер повторно.

    Практические ловушки и лайфхаки

    Не путайте “уведомление” и “условие”

  • condition variable — не очередь событий
  • корректность строится на предикате над разделяемым состоянием под тем же мьютексом
  • Всегда проектируйте протокол завершения

    Для очередей/воркеров почти всегда нужно явное состояние:

  • closed_, done, stop_requested, “poison pill”
  • И ожидания должны просыпаться при завершении:

  • часто это notify_all()
  • Избегайте ожидания под мьютексом с тяжёлой работой

    Правильный стиль:

  • под мьютексом: проверить условие, забрать минимум данных, обновить структуру
  • без мьютекса: делать тяжёлые вычисления, I/O, вызовы внешнего кода
  • “Thundering herd” лечится дизайном

    Если notify_all() будит слишком много потоков:

  • подумайте, можно ли сделать notify_one()
  • используйте шардирование (несколько очередей/замков)
  • рассмотрите семафор или структуру “один ресурс — один пробуждённый”
  • Интересный факт: atomic::wait/notify как низкоуровневая альтернатива

    В C++20 есть ожидание на атомиках:

  • a.wait(old)
  • a.notify_one() / a.notify_all()
  • Справка: std::atomic::wait.

    Это полезно для построения быстрых примитивов (включая семафоры/футексы-подобные вещи), но в прикладном коде чаще проще и безопаснее использовать condition_variable, semaphore, latch, barrier.

    Как выбрать примитив на практике

  • Нужно ждать “пока состояние станет таким-то” и одновременно защищать сложные данные: mutex + condition_variable
  • Нужно считать токены/разрешения и ограничивать параллелизм: counting_semaphore
  • Нужно один раз дождаться “все участники готовы”: latch
  • Нужно многократно синхронизировать фазы: barrier
  • В следующих материалах курса поверх этих примитивов будут строиться более сложные техники: очереди задач, graceful shutdown, комбинирование stop-токенов с ожиданием, lock ordering и дизайн без разделяемого состояния там, где это возможно.

    5. Техники сложной синхронизации: RCU, lock-free структуры, hazard pointers

    Техники сложной синхронизации: RCU, lock-free структуры, hazard pointers

    До этого в курсе мы строили правильную многопоточность на “классике”:

  • модель памяти и атомики (orderings, fences, ABA)
  • блокировки (mutex, shared_mutex) и дисциплина захвата
  • ожидание по событию (condition_variable, семафоры, latch, barrier)
  • Эти инструменты закрывают большую часть практических задач. Но в высоконагруженных системах иногда упираются в цену блокировок:

  • конкуренция за один mutex превращается в очереди у планировщика
  • потоки часто блокируются, растёт tail latency
  • горячий путь чтения “платит” за редкие записи
  • Тогда появляются сложные техники синхронизации:

  • lock-free структуры для конкурентного доступа без взаимного исключения
  • RCU (Read-Copy-Update) для сверхдешёвых чтений
  • hazard pointers (и родственные техники) для безопасного освобождения памяти в lock-free коде
  • Главный тезис статьи:

  • атомики решают атомарность и порядок видимости
  • самая трудная часть lock-free кода — время жизни памяти (reclamation)
  • Когда вообще стоит лезть в lock-free и RCU

    Обычно стоит начать с более простых решений:

  • mutex с хорошей дисциплиной критических секций
  • шардирование: несколько независимых замков по сегментам данных
  • shared_mutex, если чтений очень много и они не микроскопические
  • message passing: “владелец данных один поток, остальные шлют сообщения”
  • Сложные техники оправданы, если одновременно верно:

  • чтений существенно больше, чем записей
  • блокировки дают измеримую деградацию (latency или throughput)
  • вы готовы вложиться в тестирование, формальные инварианты и инструменты диагностики
  • Практический лайфхак:

  • если вы не можете сформулировать инварианты структуры и правила времени жизни объектов в двух-трёх предложениях, то lock-free решение почти наверняка будет источником багов
  • Термины прогресса: lock-free, wait-free, obstruction-free

    Эти слова описывают не “быстроту”, а гарантии прогресса при конкуренции.

  • Obstruction-free: поток завершит операцию, если ему не мешают другие потоки.
  • Lock-free: система в целом делает прогресс: всегда найдётся поток, который завершит операцию за конечное число шагов.
  • Wait-free: каждый поток завершит операцию за конечное число шагов независимо от других.
  • Важно:

  • lock-free не означает отсутствие ожидания вообще, но означает отсутствие блокировки по владению замком
  • wait-free почти всегда сложнее и дороже, поэтому в прикладном C++ встречается редко
  • Две главные проблемы lock-free структур

    Согласованность (линейризуемость)

    Нужно определить точку линейризации: момент, когда операция “как бы” произошла атомарно для всех потоков.

    Типичный инструмент для этого — compare_exchange_* и протоколы acquire/release.

    Справка: std::atomic, compare_exchange.

    Освобождение памяти (reclamation)

    Если поток “вынул” узел из структуры, это не значит, что его можно delete прямо сейчас:

  • другой поток мог уже прочитать указатель на этот узел и ещё не успел понять, что узел удалён
  • без специальных техник это приводит к use-after-free
  • Именно поэтому в прошлой статье про атомики мы отдельно выделяли ABA: это частный симптом общей проблемы времени жизни.

    Классический пример: lock-free стек Трейбера (Treiber stack)

    Это учебный пример: он показывает механику CAS, но сам по себе ещё не решает reclamation.

    !Упрощённая визуализация push/pop через CAS и места, где возникает проблема освобождения памяти

    Минимальная версия (без безопасного удаления):

    Что здесь важно по модели памяти:

  • push: compare_exchange с release публикует запись n->next и другие поля узла
  • pop: acquire гарантирует, что после успешного CAS мы увидим корректно опубликованный узел
  • Чего здесь нет:

  • защиты от ABA
  • безопасного освобождения памяти узлов
  • RCU: Read-Copy-Update

    RCU — это семейство техник, где чтения максимально дешёвые, а запись делает “copy-update” с последующим безопасным освобождением старой версии.

    Классическая формула:

  • читатели почти не блокируются (часто вообще без мьютекса)
  • писатель создаёт новую версию данных и атомарно “переключает” указатель
  • старая версия освобождается только после того, как все текущие читатели гарантированно вышли из критической секции чтения
  • Справка: Read-copy-update.

    Ментальная модель RCU

    Есть три роли:

  • Read-side critical section: читатель входит, читает указатель на актуальную версию, использует данные, выходит
  • Update: писатель создаёт новый объект (или новую копию), затем атомарно публикует
  • Grace period: период, после которого гарантируется, что все читатели, начавшие чтение до публикации, уже завершились
  • !Таймлайн RCU с понятием grace period

    Базовый “RCU-подобный” паттерн в пользовательском C++

    В чистом стандартном C++ нет готового “настоящего RCU” как в ядре Linux, но есть практичный вариант для конфигураций и таблиц, которые редко обновляются:

  • хранить данные как std::shared_ptr<const T>
  • публиковать новую версию через атомарные операции над shared_ptr
  • читатели делают атомарную загрузку shared_ptr и держат его, пока читают
  • Справка: Atomic operations on std::shared_ptr.

    Пример:

    Почему это “RCU-подобно”:

  • чтение не берёт mutex и не блокируется
  • запись делает “copy-update”
  • освобождение старых версий откладывается автоматически через счётчик ссылок
  • Цена:

  • атомарные операции над shared_ptr обычно заметно дороже, чем работа с голыми указателями
  • высокочастотные обновления будут создавать давление на аллокатор
  • Практический вывод:

  • для “редко обновляемой конфигурации” это часто идеальный компромисс
  • для настоящих lock-free структур на узлах shared_ptr обычно слишком дорог
  • Настоящее RCU и что в нём сложного

    В “настоящем” RCU (например, в ядре Linux) нет shared_ptr. Вместо этого есть механизм определения, что все читатели “прошли точку безопасности”:

  • читатели отмечают вход/выход (очень дёшево)
  • писатели ждут grace period
  • после grace period старую память можно освобождать
  • Эта идея приводит к семейству reclamation-техник:

  • epoch-based reclamation
  • QSBR (quiescent state based reclamation)
  • hazard pointers
  • RCU можно воспринимать как “стратегию для read-mostly”, а hazard pointers и epoch — как “механизмы безопасного освобождения” для lock-free структур.

    Hazard pointers: безопасное освобождение памяти в lock-free коде

    Hazard pointers — техника, где поток перед тем, как использовать указатель на узел, публикует “я сейчас могу к нему обратиться”. Пока хотя бы один поток держит узел в hazard pointer, узел нельзя освобождать.

    Справка: Hazard pointer.

    Почему это нужно

    CAS защищает только “смену указателя”, но не “жизнь объекта по этому указателю”. Опасный сценарий:

  • поток A читает p = head
  • поток B удаляет p из структуры и делает delete p
  • поток A продолжает использовать p
  • Hazard pointers добавляют правило:

  • прежде чем использовать p, поток A записывает p в свой hazard slot
  • поток B при “удалении” не делает delete сразу, а отправляет узел в список retired
  • периодически поток B проверяет: есть ли p в чьих-то hazard slots
  • если нет, узел можно безопасно delete
  • !Механика hazard pointers: публикация опасного указателя и отложенное освобождение

    Типовой протокол чтения узла с hazard pointer

    Ключевой шаблон: “прочитал указатель, опубликовал hazard, перепроверил, что указатель не изменился”. Иначе можно опубликовать hazard уже на устаревший узел.

    Псевдокод идеи:

    Где:

  • atomic_ptr — атомарный указатель в структуре
  • hp — hazard slot текущего потока
  • Плюсы и минусы hazard pointers

    Плюсы:

  • не нужно ждать “глобальной эпохи”, освобождение более локальное
  • хорошо работает, когда у потока мало одновременно удерживаемых указателей
  • Минусы:

  • нужен глобальный реестр hazard slots
  • удаляющий поток периодически сканирует hazard slots, что даёт накладные расходы
  • реализация заметно сложнее “очереди под мьютексом”
  • Практический вывод:

  • hazard pointers имеют смысл в библиотеках и инфраструктуре
  • в прикладном коде чаще выбирают mutex, либо готовую библиотеку lock-free, либо RCU-подобный shared_ptr для read-mostly данных
  • Epoch-based reclamation и связь с RCU

    Epoch-based reclamation (EBR) близка к RCU по духу:

  • есть глобальная “эпоха” (поколение)
  • потоки объявляют “я в критической секции” и запоминают текущую эпоху
  • удалённые узлы складываются в retired списки с номером эпохи
  • память можно освобождать, когда все активные потоки перешли в более новую эпоху
  • Это обычно быстрее, чем hazard pointers в сценариях:

  • много удалений
  • потоки регулярно выходят из критических секций
  • Но EBR плохо подходит, если:

  • есть поток, который “завис” в критической секции надолго
  • вам нужна гарантия, что память освобождается независимо от поведения отдельных потоков
  • RCU можно воспринимать как конкретное семейство EBR/QSBR подходов, оптимизированных под read-mostly.

    ABA: почему reclamation и “тэги” часто идут вместе

    Даже если вы решили проблему use-after-free, ABA может сломать логику:

  • значение указателя снова стало прежним
  • CAS успешен, но “мир успел поменяться”
  • Классическая защита:

  • tagged pointer: вместе с указателем хранить версию (счётчик)
  • Идея:

  • сравнивать не просто ptr, а пару (ptr, tag)
  • при каждом изменении увеличивать tag
  • Но это не заменяет reclamation:

  • tagged pointer снижает шанс “невидимых изменений” для CAS
  • reclamation гарантирует, что объект не будет освобождён, пока его могут читать
  • Практические лайфхаки и правила выживания

  • Не пишите lock-free структуру без плана reclamation. “Сначала сделаю CAS, потом разберусь с delete” почти всегда заканчивается use-after-free.
  • Сначала докажите инварианты на бумаге. Минимум:
  • - точка линейризации каждой операции - что делает поток при конфликте CAS - какие указатели могут быть видимы читателям - когда узел становится недоступен - когда узел становится безопасен для освобождения
  • Не путайте производительность с отсутствием блокировок. Lock-free может быть медленнее mutex, если:
  • - много конфликтов CAS - сильное давление на кеш-линии - много аллокаций
  • Осторожно с ложной оптимизацией. Часто проще:
  • - заменить один глобальный mutex на 8 шардированных - уменьшить критическую секцию - убрать I/O из-под замка
  • Используйте инструменты. Даже если алгоритм lock-free, проверяйте себя:
  • - ThreadSanitizer хорошо ловит data race, но не “логические гонки” и не все ошибки reclamation - AddressSanitizer может поймать use-after-free

    Как выбирать технику: краткая таблица

    | Цель | Типичный выбор | Почему | |---|---|---| | Read-mostly конфигурация/таблица, редкие обновления | RCU-подобно через атомарный shared_ptr | Просто, безопасно по времени жизни | | Высокочастотные операции, нужна неблокирующая структура | Lock-free + reclamation (hazard pointers или epoch) | Нужен прогресс без замков | | Нужна предсказуемость и простота, нет жёсткой боли от блокировок | mutex/shared_mutex + condition_variable/semaphore | Проще доказать корректность |

    Что дальше

    Следующий шаг после понимания этих техник — научиться проектировать системы так, чтобы реже требовались “героические” lock-free конструкции:

  • очереди задач и пулы потоков с корректным shutdown
  • уменьшение совместного состояния через message passing
  • композиция отмены (stop_token) и ожиданий
  • Эта статья должна оставить правильное ощущение: lock-free и RCU — мощные инструменты, но их цена в сложности почти всегда упирается в время жизни памяти и доказуемость инвариантов.

    6. Типовые проблемы и анти-паттерны: дедлоки, starvation, false sharing

    Типовые проблемы и анти-паттерны: дедлоки, starvation, false sharing

    Многопоточность в C++ почти всегда ломается не потому, что вы не знаете mutex или atomic, а потому что вы попали в одну из типовых системных ловушек.

    В предыдущих статьях курса мы разобрали:

  • базовую модель: потоки, задачи, время жизни, исключения
  • модель памяти: атомики, orderings, ABA
  • примитивы синхронизации: mutex, shared_mutex
  • ожидание и координацию: condition_variable, семафоры, latch, barrier
  • сложные техники: lock-free, RCU, hazard pointers
  • Эта статья связывает всё это практикой: как распознавать и предотвращать самые частые проблемы и анти-паттерны.

    Дедлоки

    Дедлок (deadlock) — это состояние, когда два или больше потоков навсегда ждут друг друга и прогресса нет.

    Каноническая причина: разные порядки захвата

    Самый частый дедлок в C++: потоки захватывают несколько мьютексов в разном порядке.

    Если t1 успел взять a, а t2 успел взять b, то дальше оба навсегда ждут второй мьютекс.

    !Два потока захватили разные мьютексы и ждут друг друга

    Профилактика: захват нескольких мьютексов безопасно

    Используйте std::scoped_lock или std::lock, чтобы захватить набор мьютексов без взаимной блокировки.

    Ссылка: std::scoped_lock, std::lock.

    Анти-паттерн: “попробуем try_lock в цикле”

    Часто попытка "избежать дедлока" превращает проблему в другие:

  • livelock: потоки активно бегают по кругу, но никто не делает полезной работы
  • перегрев CPU из-за busy-wait
  • ухудшение latency из-за постоянных конфликтов
  • Плохой пример:

    Если вам нужен неблокирующий захват, вам нужна политика:

  • что делать при неуспехе
  • как ограничивать попытки
  • как делать backoff
  • В прикладном коде чаще правильнее: std::scoped_lock и корректная архитектура захвата.

    Дедлоки “не на мьютексах”: самозависание через ожидания

    Даже если у вас один мьютекс, можно “повесить систему” неправильным ожиданием.

    Типичные сценарии:

  • ожидание condition_variable без корректного предиката
  • поток держит мьютекс и вызывает внешнюю функцию, которая в итоге пытается взять тот же мьютекс
  • completion-функция barrier делает ожидания, которые зависят от участников барьера
  • База из статьи про condition_variable:

  • ждём предикат над состоянием
  • всегда используем wait(lock, pred)
  • Ссылка: std::condition_variable.

    Практические правила против дедлоков

  • Фиксируйте порядок захвата. Например, по уровню абстракции или по адресу объекта.
  • Если мьютексов несколько и они берутся вместе — используйте std::scoped_lock.
  • Не вызывайте произвольный внешний код под мьютексом (колбэки, виртуальные методы, I/O).
  • Делайте критические секции короткими: под замком берите только минимум данных, тяжёлую работу делайте без замка.
  • Starvation

    Starvation (голодание) — ситуация, когда поток формально не заблокирован навсегда, но практически не получает ресурсов или времени для прогресса.

    Важно отличать:

  • дедлок: прогресс невозможен в принципе
  • starvation: прогресс возможен, но не гарантирован и может не происходить очень долго
  • Классический источник: несправедливые блокировки

    Стандарт C++ не гарантирует справедливость большинства примитивов:

  • std::mutex не обязан быть fair
  • std::shared_mutex может голодать писателей или читателей, в зависимости от реализации и нагрузки
  • Пример: read-heavy нагрузка + shared_mutex.

  • читателей много, они постоянно приходят
  • писатель пытается взять эксклюзивный lock
  • в некоторых реализациях писатель может ждать очень долго
  • Ссылка: std::shared_mutex.

    Starvation из-за активного ожидания

    Spin-wait без пауз часто “отнимает” CPU у того потока, который должен выполнить работу и снять условие.

    Плохой стиль:

    Если вы ждёте событие, обычно лучше:

  • condition_variable
  • семафор
  • atomic::wait (C++20), если вы строите низкоуровневый примитив
  • Ссылка: std::atomic::wait, std::counting_semaphore.

    Анти-паттерн: notify_all “на всё подряд”

    Частая схема:

  • много потоков ждёт condition_variable
  • при каждом событии вызывается notify_all
  • Это может создать:

  • эффект thundering herd: проснулись все, но ресурс один
  • борьбу за мьютекс
  • и в итоге starvation части потоков на “шуме” пробуждений
  • Правильный подход:

  • если событие даёт один ресурс, чаще нужен notify_one
  • notify_all используйте для глобальных событий: остановка, закрытие очереди, смена фазы
  • Priority inversion: отдельный случай starvation

    Инверсия приоритетов возникает, когда:

  • низкоприоритетный поток держит мьютекс
  • высокоприоритетный поток ждёт этот мьютекс
  • среднеприоритетные потоки постоянно вытесняют низкоприоритетный, и он не может освободить мьютекс
  • В чистом стандартном C++ вы не управляете приоритетами ОС напрямую, но на практике инверсия проявляется как:

  • резкие всплески latency
  • ощущение “почему простой lock иногда занимает миллисекунды?”
  • Лечение обычно архитектурное:

  • короче критические секции
  • меньше глобальных замков
  • шардирование (несколько независимых замков)
  • перенос тяжёлой работы из-под замка
  • Практические правила против starvation

  • Не используйте shared_mutex как “по умолчанию быстрее”: это оптимизация, измеряйте.
  • Избегайте активного ожидания, если нет чёткой причины.
  • Делайте пробуждения адресными: notify_one для единичного ресурса.
  • Шардируйте горячие точки: лучше 8 независимых очередей/замков, чем один глобальный.
  • False sharing

    False sharing (ложное совместное использование) — проблема производительности, когда потоки пишут в разные переменные, но эти переменные лежат в одной cache line. Из-за этого ядра процессора вынуждены постоянно “перетягивать” одну и ту же cache line друг у друга, хотя логически данные независимы.

    Ключевой момент: это не ошибка корректности, это деградация производительности, часто драматическая.

    !Два потока пишут в разные переменные, но из-за общей cache line происходит постоянная инвалидация кеша

    Как это выглядит в коде

    Паттерн: “у каждого потока свой счётчик” в массиве структур.

    Логически всё хорошо:

  • каждый поток пишет в свой c[i]
  • Но физически элементы c[i] часто лежат рядом, и несколько c[i].v попадают в одну cache line.

    Признаки false sharing на практике

  • масштабирование “ломается”: больше потоков не ускоряет, а замедляет
  • CPU загружен, но throughput не растёт
  • профилировщик показывает много времени в атомарных инкрементах или простых записях
  • Как лечить: разнести по cache lines

    Варианты:

  • добавить выравнивание и padding
  • использовать стандартные константы размера интерференции
  • C++17 добавил:

  • std::hardware_destructive_interference_size
  • Ссылка: std::hardware_destructive_interference_size.

    Пример:

    Практические замечания:

  • это не магическая константа “всегда 64”, но часто близко к реальности
  • выравнивание увеличивает потребление памяти, поэтому применяйте точечно, на горячих структурах
  • Частый анти-паттерн: один общий атомарный счётчик

    Ещё одна типовая “горячая точка”: глобальный атомарный счётчик статистики.

    Даже на relaxed это:

  • общий контеншен на одну cache line
  • дорого при высокой частоте
  • Частое решение:

  • per-thread счётчики (thread_local или массив по thread_id)
  • периодическая агрегация в один поток
  • Пример идеи:

    Агрегация делается отдельно и реже (например, по таймеру или при логировании), уже под мьютексом или атомарно, в зависимости от требований.

    Связь с другими темами курса

    False sharing часто появляется там, где вы “оптимизировали” блокировки:

  • ушли от mutex к атомикам
  • сделали lock-free структуру
  • добавили много per-thread данных
  • И внезапно упёрлись в кеш-линии. Поэтому практический вывод курса:

  • сначала добейтесь корректности (mutex, condition_variable, явный протокол)
  • затем измерьте
  • и только потом оптимизируйте, учитывая кеши и память
  • Сводная таблица: проблема, симптом, лечение

    | Проблема | Симптом | Типичное лечение | |---|---|---| | Дедлок | Полная остановка, потоки висят в lock() | Единый порядок захвата, std::scoped_lock, меньше внешнего кода под замком | | Starvation | Сильный перекос: один поток “почти не работает”, latency скачет | Сократить критические секции, шардирование, меньше notify_all, меньше spin-wait | | False sharing | Нет масштабирования по ядрам, высокая нагрузка CPU без роста throughput | Padding/alignas, per-thread данные, уменьшить общий контеншен на атомиках |

    Мини-чеклист перед тем, как винить компилятор и ОС

  • Есть ли у вас общий мьютекс, который берут “на всё”? Это кандидат на contention, convoying и starvation.
  • Есть ли у вас два мьютекса, которые иногда берутся вместе? Это кандидат на дедлок.
  • Вы используете notify_all по умолчанию? Проверьте thundering herd.
  • Вы заменили мьютекс на атомик и стало хуже? Проверьте false sharing и контеншен на cache line.
  • У вас есть busy-wait без пауз? Это кандидат на starvation и деградацию всей системы.
  • В следующем материале курса обычно логично перейти к практическим конструкциям уровня “архитектура”: очереди задач, graceful shutdown, комбинирование stop_token с ожиданиями и уменьшение общего состояния через message passing.

    7. Практика и лайфхаки: проектирование, тестирование, профилирование и инструменты

    Практика и лайфхаки: проектирование, тестирование, профилирование и инструменты

    Эта статья связывает весь предыдущий материал курса в практический процесс.

    Ранее мы разобрали:

  • базовую модель потоков и задач, время жизни и исключения
  • модель памяти и атомики (orderings, fences, ABA)
  • блокировки (mutex, shared_mutex) и ожидания (condition_variable, семафоры, latch, barrier)
  • сложные техники (RCU, lock-free, hazard pointers)
  • типовые проблемы (дедлоки, starvation, false sharing)
  • Теперь цель другая: научиться проектировать так, чтобы многопоточность была проверяемой, и иметь набор инструментов, который позволяет не гадать, а находить причины ошибок и деградации производительности.

    Практическая стратегия: от дизайна к доказуемости

    Главный лайфхак многопоточности в C++ звучит скучно, но экономит недели:

  • сначала уменьшите совместное состояние и сформулируйте инварианты
  • затем выберите примитивы синхронизации
  • затем сделайте тесты, которые могут ломать эти инварианты
  • затем включите санитайзеры
  • затем профилируйте и оптимизируйте
  • !Пайплайн практической разработки многопоточного кода

    Проектирование: как сделать конкурентный код проще

    Делайте владельца и границы ответственности явными

    Практический минимум для любого конкурентного компонента:

  • кто владеет данными
  • кто имеет право их менять
  • каким примитивом защищён каждый инвариант
  • как выглядит завершение (shutdown)
  • Один инвариант должен иметь один ясный протокол синхронизации.

    Снижайте совместное состояние до минимума

    Сильные подходы, которые часто проще, чем тонкая синхронизация:

  • thread confinement: данные принадлежат одному потоку, остальные общаются сообщениями
  • immutable snapshot: читатели работают с неизменяемой версией данных
  • sharding: вместо одного глобального замка несколько независимых (по ключу, по диапазону, по потокам)
  • Если вы можете превратить проблему из конкурентного доступа к памяти в передачу сообщений, вы обычно выигрываете в корректности.

    Выбирайте примитив синхронизации по форме ожидания

    | Задача | Типичный выбор | Почему | |---|---|---| | Защитить инвариант данных | std::mutex + RAII | Проще доказать корректность, понятный happens-before | | Много читателей, редкие записи | std::shared_mutex | Оптимизация для read-heavy, требует измерений | | Ждать "пока состояние станет истинным" | std::condition_variable | Ожидание по предикату над состоянием | | Ограничить параллелизм (токены) | std::counting_semaphore | Встроенный счётчик разрешений | | Фазовый алгоритм | std::barrier | Синхронизация поколений | | Read-mostly конфигурация | атомарный std::shared_ptr<const T> | RCU-подобный дизайн без ручного reclamation |

    Справочные страницы: std::condition_variable, std::counting_semaphore, std::barrier, атомарные операции над std::shared_ptr.

    Синхронизация должна жить внутри абстракции

    Хороший дизайн:

  • наружу отдаёт операции, а не mutex&
  • не требует от пользователя помнить, какой замок брать
  • документирует, какие операции блокируются и могут ждать
  • Плохой дизайн:

  • возвращает ссылку на внутренний контейнер
  • требует “снаружи взять замок, потом вызвать два метода, потом отпустить”
  • Мини-шаблон: храните mutex внутри класса и защищайте все обращения к инварианту.

    Не держите замки во время тяжёлых действий

    Под замком нельзя делать то, что непредсказуемо по времени:

  • I/O
  • логирование в тяжёлую систему логов
  • вызов пользовательских колбэков
  • вызов виртуальных методов (если это внешняя расширяемая часть)
  • Правильный стиль:

  • под замком забрать минимально нужные данные
  • отпустить замок
  • сделать тяжёлую работу
  • Дедлоки убирают не try_lock, а дисциплина

    Если вам нужно брать несколько замков:

  • фиксируйте порядок захвата
  • или используйте std::scoped_lock
  • Справка: std::scoped_lock.

    Делайте завершение (shutdown) частью протокола

    Почти любой “пул/очередь/воркер” должен уметь корректно завершаться.

    Канонический минимальный протокол:

  • есть флаг closed_ (под mutex)
  • операции ожидания ждут предикат closed_ || есть_работа
  • close() ставит closed_ = true и делает notify_all()
  • Это напрямую связано с прошлой статьёй про “потерянные уведомления” и “ложные пробуждения”: корректность строится на состоянии и предикате, а не на “уведомлении как событии”.

    Используйте std::jthread и stop_token для кооперативной остановки

    Если вы на C++20, std::jthread упрощает два риска:

  • гарантирует join в деструкторе
  • даёт стандартный канал остановки через stop_token
  • Справка: std::jthread, std::stop_token.

    Лайфхак: если у вас ожидание через condition_variable, добавьте в предикат проверку остановки и делайте notify_all() при запросе остановки, иначе поток может не проснуться.

    Помните про кеши: false sharing появляется от “невинных” счётчиков

    Если вы делаете per-thread счётчики или массивы атомиков, разнесите элементы по cache line.

    Справка: std::hardware_destructive_interference_size.

    Тестирование многопоточности: как ловить редкие баги

    Многопоточные баги часто зависят от расписания потоков, поэтому “юнит-тест один раз” почти ничего не доказывает.

    Делайте тесты, которые управляют гонкой за расписание

    Вместо sleep_for используйте структурные точки синхронизации:

  • std::latch и std::barrier для стартов и фаз
  • семафоры для выдачи токенов
  • condition_variable для ожидания конкретного состояния
  • Справка: std::latch.

    Лайфхак: часто полезно искусственно создавать “плохие” окна:

  • запустить много потоков одновременно через barrier
  • заставить их многократно повторять короткую операцию
  • периодически переключать режимы (например, открытие/закрытие очереди)
  • Стресс-тестирование важнее красивых сценариев

    Проверяйте не только “обычную работу”, но и края:

  • закрытие во время ожидания
  • закрытие во время активной обработки
  • конкуренция нескольких производителей и потребителей
  • повторяющиеся циклы старт-стоп
  • Практический критерий: если компонент имеет shutdown, то должен быть тест, который вызывает shutdown в случайный момент.

    Вотчдог против дедлоков

    Дедлок часто выглядит как “тест завис”. Полезный приём:

  • отдельный таймер/поток-сторож
  • если тест не завершился за лимит, выведите диагностику и прервите
  • Важно: таймаут в тесте не доказывает отсутствие дедлоков, но помогает автоматически находить их.

    Инварианты и проверки

    Проверяйте инварианты там, где они гарантированно консистентны:

  • под mutex
  • или сразу после join всех потоков
  • Лайфхак: полезно иметь метод check_invariants() и вызывать его в стресс-тестах после каждой серии операций.

    Бенчмарки как тесты

    Микробенчмарк может выявить логические проблемы:

  • неожиданную деградацию на росте потоков
  • эффект thundering herd
  • глобальный контеншен на атомике
  • Для инфраструктуры бенчмарков удобно использовать Google Benchmark.

    Санитайзеры и динамическая диагностика

    Санитайзеры часто дают самый быстрый “первый результат”, потому что они ловят классы ошибок автоматически.

    ThreadSanitizer (TSAN)

    TSAN ищет гонки данных.

  • ловит: конкурентный доступ без синхронизации, многие ошибки публикации
  • не гарантирует: обнаружение дедлоков, логических гонок, проблем производительности
  • Документация: ThreadSanitizer (Clang).

    Пример сборки (Clang или совместимый инструмент):

    Полезная справка по флагам компилятора: GCC Instrumentation Options.

    Лайфхак: запускайте TSAN на стресс-тестах, а не только на юнитах.

    AddressSanitizer (ASAN) и UndefinedBehaviorSanitizer (UBSAN)

    В многопоточном коде ASAN особенно ценен, потому что реальные падения часто выглядят как:

  • use-after-free (например, в ошибочной “детачнутой” нити)
  • выход за границы
  • UBSAN ловит неопределённое поведение, которое может проявляться сильнее именно под конкуренцией.

    Документация: AddressSanitizer, UndefinedBehaviorSanitizer.

    Важно: TSAN и ASAN обычно не включают одновременно в одном запуске, используйте разные конфигурации сборки.

    Профилирование: как понять, что тормозит на самом деле

    В многопоточности “профилировать” означает отвечать минимум на три разных вопроса:

  • где тратится CPU
  • где происходит ожидание (блокировки, системные ожидания)
  • где ломается масштабирование (контеншен, кеш-линии)
  • Методика профилирования, которая меньше врёт

  • зафиксируйте нагрузку и входные данные
  • прогрейте систему (кеши, аллокатор, JIT-эффектов в C++ обычно нет, но прогрев всё равно полезен)
  • измеряйте несколько прогонов
  • сравнивайте медиану и хвосты (например, 95-й перцентиль), а не только среднее
  • Если вам нужна метрика хвоста, используйте перцентили: означает время, которое не превышают 95% измерений.

  • объясняет “редкие тормоза”, которые пользователи чувствуют сильнее среднего
  • это важно при контеншене за замки и при всплесках планировщика
  • CPU-профилирование

    Для Linux базовый инструмент: perf.

    Лайфхак: при интерпретации результатов смотрите не только на “горячие функции”, но и на признаки конкуренции:

  • много времени в атомарных операциях
  • много времени в pthread_mutex_lock или похожих системных вызовах
  • рост контеншена при увеличении числа потоков
  • Трейсинг и профилирование по событиям

    Если вам нужна картина по временной шкале (что делали потоки и когда), удобны трассировщики.

    Практичный вариант для C++: Tracy Profiler.

    Плюс: видно, где потоки стоят, как выглядят очереди задач, где происходят пики.

    Профилирование контеншена и ожиданий

    Типовой симптом проблем синхронизации:

  • throughput не растёт с числом потоков
  • latency резко растёт в хвостах
  • Два частых виновника:

  • слишком крупная критическая секция
  • false sharing или общий атомик на горячем пути
  • !Визуальные признаки проблем: ожидание замка и контеншен на cache line

    Практические лайфхаки, которые часто окупаются

    Мини-чеклист перед ревью многопоточного кода

  • каждый совместно используемый объект имеет описанный протокол синхронизации
  • ни один std::thread не уничтожается в состоянии joinable, лучше std::jthread
  • в ожиданиях нет sleep_for как механизма координации
  • ожидание на condition_variable делается через wait(lock, pred)
  • есть понятный протокол завершения, который будит всех ожидающих
  • нет пользовательских колбэков под замком
  • есть стресс-тесты и отдельный прогон под TSAN
  • Делайте “невозможное” наблюдаемым

    Почти всегда помогает:

  • логирование с std::this_thread::get_id() в ключевых переходах состояния
  • явные счётчики событий (инкременты можно делать memory_order_relaxed, если это только статистика)
  • минимальная телеметрия времени ожидания замков и очередей
  • Справка: std::this_thread::get_id.

    Начинайте с простого и проверяемого

  • если можно решить через mutex и короткую критическую секцию, это часто лучший старт
  • переход к lock-free оправдан только после измерений и только с планом reclamation
  • Это напрямую связано с предыдущими статьями про ABA и hazard pointers: сложность обычно не в CAS, а в времени жизни памяти.

    Что дальше

    Эта статья закрывает курс практическим “контуром качества”:

  • дизайн с явными инвариантами
  • тесты, которые создают реальную конкуренцию
  • санитайзеры для автоматического поиска классов ошибок
  • профилирование, которое разделяет CPU, ожидания и кеш-контеншен
  • Если вы удерживаете этот контур, то большинство проблем из статьи про дедлоки, starvation и false sharing становятся не мистикой, а диагностируемыми инженерными задачами.