Урок по треку C++ 6 мин

Многопоточность и модель памяти

thread, mutex, condition_variable, atomic, memory_order, гонки данных и дедлоки.

С C++11 потоки и модель памяти часть стандарта. На собесе middle и senior почти всегда спрашивают про гонки, мьютексы, условные переменные и атомики, а иногда про memory_order.

Потоки

std::thread t([] { work(); });
t.join();                         // дождаться завершения
std::jthread jt([](std::stop_token st) { while (!st.stop_requested()) step(); });

Если объект std::thread уничтожается в состоянии joinable (не вызвали join или detach), вызывается std::terminate. std::jthread (C++20) в деструкторе запрашивает остановку и делает join сам. detach отпускает поток в свободное плавание: он не должен использовать ссылки на локальные переменные создателя.

Гонка данных

Гонка данных (data race): два потока обращаются к одной ячейке памяти без синхронизации, и хотя бы один пишет. По стандарту это неопределённое поведение всей программы, а не просто «неточный счётчик». Даже ++counter для int это чтение, сложение и запись, которые могут перемешаться между потоками и потерять обновления. volatile от гонок не защищает: оно про оптимизации доступа к памяти, а не про синхронизацию.

Мьютексы

std::mutex m;
{
    std::lock_guard lock(m);       // RAII: захват в конструкторе, освобождение в деструкторе
    shared.push_back(x);
}
  • std::lock_guard простой RAII-замок; std::unique_lock умеет отпускать и захватывать заново, нужен для условных переменных; std::scoped_lock (C++17) захватывает несколько мьютексов сразу с алгоритмом избегания взаимной блокировки.
  • std::shared_mutex + std::shared_lock для «много читателей, один писатель».
  • Дедлок возникает, когда потоки захватывают мьютексы в разном порядке. Лечится единым порядком захвата или std::scoped_lock(a, b).

Условные переменные

std::unique_lock lock(m);
cv.wait(lock, [&] { return !queue.empty() || stopped; });

wait атомарно отпускает мьютекс и засыпает, а проснувшись, снова его захватывает. Пробуждения бывают ложными (spurious), и уведомление может прийти до того, как поток начал ждать, поэтому всегда ждут с предикатом, проверяющим состояние под мьютексом. Изменять это состояние тоже нужно под тем же мьютексом.

Атомики

std::atomic<T> делает операции над одной переменной неделимыми: load, store, fetch_add, exchange, compare_exchange_weak/strong (CAS). Для простых счётчиков и флагов это быстрее мьютекса. is_lock_free() показывает, реализован ли атомик без внутренней блокировки. Атомарность одной операции не делает атомарной последовательность: if (x.load() > 0) x--; всё ещё гонка логики, нужен CAS-цикл.

Модель памяти и memory_order

Компилятор и процессор переставляют операции с памятью. Модель памяти определяет, какие перестановки видны другим потокам, через отношение happens-before.

  • memory_order_seq_cst (по умолчанию): все атомарные операции выстроены в единый глобальный порядок. Проще всего рассуждать, иногда дороже.
  • memory_order_release при записи и memory_order_acquire при чтении того же атомика: если чтение увидело записанное значение, всё, что поток-писатель сделал до release, видно потоку-читателю после acquire. Классика: публикация данных через флаг.
  • memory_order_relaxed: только атомарность самой операции, никакого порядка с остальной памятью. Годится для статистических счётчиков.
data = 42;                                   // обычная запись
ready.store(true, std::memory_order_release);
// другой поток:
while (!ready.load(std::memory_order_acquire)) {}
assert(data == 42);                          // гарантировано

Высокоуровневые средства

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

Частые ошибки

  • Общая переменная без мьютекса или атомика.
  • wait без предиката и изменение условия без мьютекса.
  • Разный порядок захвата мьютексов.
  • Вызов чужого кода (колбэка) под мьютексом.
  • detach с захватом локальных переменных по ссылке.
  • Двойная проверка без атомика при ленивой инициализации (используйте static-локальные переменные или std::call_once).

Что спросят на собесе

  • Что такое гонка данных и почему это UB.
  • Как избежать дедлока.
  • Зачем предикат в condition_variable::wait.
  • Чем атомик отличается от мьютекса, что такое CAS.
  • memory_order acquire/release и relaxed на примере.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.