Примитивы синхронизации в C++: lock'и, shared/recursive, semaphore¶
std::mutex + std::lock_guard — базис, который покрывает значительную часть случаев, но реальный код упирается в его
ограничения быстро: нужно одновременно захватывать пару мьютексов без deadlock'а, разрешить читателям конкурировать,
рекурсивно входить в критическую секцию из public-метода, передавать lock между функциями. Стандарт C++ предоставляет
полный набор: <mutex> содержит четыре RAII-обёртки и три вида мьютексов, <shared_mutex> добавляет reader/writer
блокировку, <semaphore> — counting/binary семафоры на futex'е. Эта статья — обзор всего этого арсенала, того как оно
устроено и когда что выбирать.
Базовые понятия (std::mutex, futex, condition variable) разобраны в thread_synchronization.md.
Здесь — следующий слой.
RAII guards¶
C++ предлагает четыре RAII-обёртки над lock'ом. Каждая решает свою задачу — выбор определяется тем, нужно ли управлять блокировкой вручную, владеть ли несколькими мьютексами, и нужен ли shared-доступ.
| Guard | Введён | Размер* | Move | unlock/relock | Для CV | Сценарий |
|---|---|---|---|---|---|---|
std::lock_guard |
C++11 | 8 байт | нет | нет | нет | простейший scope-bound lock |
std::unique_lock |
C++11 | 16 байт | да | да | да | CV, deferred lock, move out of fn |
std::scoped_lock |
C++17 | N×8 байт | нет | нет | нет | один или несколько мьютексов сразу |
std::shared_lock |
C++14 | 16 байт | да | да | нет | reader-сторона shared_mutex |
* на x86-64 с обычным std::mutex*; реальный размер зависит от типа мьютекса.
std::mutex m;
std::shared_mutex sm;
// 1. lock_guard — простейший случай, scope-bound
{
std::lock_guard<std::mutex> lk(m);
// критическая секция
} // unlock в деструкторе
// 2. unique_lock — нужен для condition_variable
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return ready; }); // wait делает unlock+relock через lk
lk.unlock(); // можно отпустить раньше scope'а
// ... тяжёлая работа без блокировки ...
lk.lock(); // и взять обратно
}
// 3. scoped_lock — один или несколько мьютексов, deadlock-free
{
std::scoped_lock lk(m1, m2); // эквивалент std::lock(m1,m2)+two guards
// обе блокировки захвачены атомарно
}
// 4. shared_lock — RAII для reader'а shared_mutex
{
std::shared_lock<std::shared_mutex> lk(sm);
// несколько потоков могут быть здесь одновременно
}
В новом коде для одного мьютекса обычно берут std::scoped_lock — он эквивалентен lock_guard (тот же overhead, та же
семантика), но единообразен с multi-mutex случаем. lock_guard остался по причинам совместимости.
std::recursive_mutex¶
Обычный std::mutex UB-нет, если тот же поток попытается захватить его второй раз — это deadlock с самим собой.
std::recursive_mutex хранит идентификатор владеющего потока и счётчик владений; владелец может вызвать lock()
повторно — счётчик инкрементится, реальной блокировки не происходит. unlock() декрементит — мьютекс освобождается
только когда счётчик возвращается к нулю.
┌────────────────────────────────────────────────────────────┐
│ recursive_mutex internals │
├────────────────────────────────────────────────────────────┤
│ owner_tid : pthread_t ← кто держит, 0 если свободен │
│ count : uint32_t ← глубина рекурсии │
│ state : futex word ← как у обычного мьютекса │
└────────────────────────────────────────────────────────────┘
lock():
if owner_tid == self_tid: ──▶ ++count, return
else ──▶ обычный mutex lock
owner_tid = self_tid
count = 1
unlock():
--count
if count == 0: ──▶ owner_tid = 0
обычный mutex unlock
Типичный сценарий — public-методы класса, которые могут вызывать друг друга:
class Registry {
std::recursive_mutex m_;
std::map<std::string, int> data_;
public:
void set(const std::string& k, int v) {
std::lock_guard<std::recursive_mutex> lk(m_);
data_[k] = v;
}
void set_many(const std::map<std::string, int>& batch) {
std::lock_guard<std::recursive_mutex> lk(m_);
for (const auto& [k, v] : batch)
set(k, v); // повторный lock того же потока — ok
}
};
Цена — дополнительные поля (TID + счётчик) и проверка владельца на каждом lock(). На x86-64 это ~2× медленнее обычного
мьютекса в fast path.
Использование recursive_mutex обычно говорит о том, что дизайн классов размывает границу между «методом, который
блокирует» и «методом, который предполагает блокировку уже взятой». Чище паттерн — private-методы с суффиксом _locked,
которые не берут lock:
class Registry {
std::mutex m_;
std::map<std::string, int> data_;
void set_locked(const std::string& k, int v) { data_[k] = v; }
public:
void set(const std::string& k, int v) {
std::lock_guard lk(m_);
set_locked(k, v);
}
void set_many(const std::map<std::string, int>& batch) {
std::lock_guard lk(m_);
for (const auto& [k, v] : batch)
set_locked(k, v);
}
};
std::recursive_timed_mutex добавляет try_lock_for() / try_lock_until() — рекурсивная блокировка с таймаутом.
std::shared_mutex¶
Reader/writer lock реализует политику «много читателей или один писатель». Если данные читаются часто и обновляются
редко (cache, конфигурация, routing table), shared_mutex позволяет читателям не блокировать друг друга.
stateDiagram-v2
[*] --> Free
Free: Free<br/>readers=0, writer=no
Shared: Shared (N readers)<br/>readers > 0, writer = no
Exclusive: Exclusive (writer)<br/>readers = 0, writer = yes
Free --> Shared: lock_shared()
Shared --> Free: unlock_shared() (last reader)
Free --> Exclusive: lock()
Exclusive --> Free: unlock()
API двусторонний: writer берёт мьютекс через lock() / unlock(), reader — через lock_shared() / unlock_shared().
На практике это оборачивают в RAII:
#include <shared_mutex>
class DNSCache {
mutable std::shared_mutex m_;
std::unordered_map<std::string, std::string> cache_;
public:
// Reader — параллельно с другими reader'ами
std::optional<std::string> get(const std::string& host) const {
std::shared_lock<std::shared_mutex> lk(m_);
auto it = cache_.find(host);
return it == cache_.end()
? std::nullopt
: std::optional{it->second};
}
// Writer — эксклюзивно
void put(std::string host, std::string ip) {
std::unique_lock<std::shared_mutex> lk(m_);
cache_[std::move(host)] = std::move(ip);
}
};
shared_mutex появился в C++17. В C++14 был shared_timed_mutex — то же самое плюс timed-API; в C++17 timed-вариант
остался, а «обычный» shared_mutex стал отдельным типом без timed-overhead.
Внутри¶
Распространённая реализация — atomic word, содержащий счётчик читателей и writer-bit. Reader делает CAS, инкрементирующий счётчик, если writer-bit не выставлен; writer выставляет бит и ждёт, пока счётчик не упадёт до нуля. Под капотом — тот же futex (Linux) / WaitOnAddress (Windows), что и у обычного мьютекса; подробности futex в thread_synchronization.md.
shared_mutex state word (упрощённо)
┌──────────────┬───────────────────────────────────────┐
│ writer_bit │ reader_count (31 бит) │
│ 1 бит │ │
└──────────────┴───────────────────────────────────────┘
lock_shared() fast path:
loop:
s = load(state)
if s & writer_bit: park on futex ──▶ slow path
if CAS(state, s, s + 1) ok: return ──▶ fast path
lock() (writer):
set writer_bit (ждать если уже выставлен)
while reader_count > 0: park on futex
Подводные камни¶
Writer starvation. Если читатели приходят непрерывно, writer может никогда не получить эксклюзивный доступ. Поведение зависит от реализации: libstdc++ блокирует новых читателей, как только writer попросил lock — fair-ish; libc++ не даёт такой гарантии. Стандарт это не специфицирует.
Upgrade/downgrade не поддерживается. Нет способа взять shared_lock, прочитать, и атомарно превратить его в writer
lock. Нужно отпустить shared, взять unique, перепроверить состояние (мог измениться).
Не всегда быстрее. При низком contention reader-fast-path требует CAS на одной кэш-линии — то же что у обычного
мьютекса. Если writers редкие, выигрыша почти нет; если писателей много, shared_mutex ощутимо медленнее обычного
из-за более сложного протокола.
Не подходит для condition variable. Standard CV работает только с unique_lock<mutex> / unique_lock<timed_mutex>,
не с shared_lock. Есть отдельный std::condition_variable_any, который принимает любой Lockable — это путь, если нужны
оба паттерна.
std::lock и захват нескольких мьютексов¶
Классический deadlock: поток A берёт m1, потом m2; поток B берёт m2, потом m1. Если оба захватили первый
мьютекс одновременно — ни один не сможет взять второй.
sequenceDiagram
participant A as Поток A
participant B as Поток B
A->>A: m1.lock()
B->>B: m2.lock()
A->>B: ждёт m2 (его держит B)
B->>A: ждёт m1 (его держит A)
Note over A,B: deadlock
Дисциплинированное решение — lock ordering: всегда захватывать мьютексы в одном глобально согласованном порядке (например, по адресу). Это работает, но требует, чтобы все участники знали об ordering и его соблюдали.
std::lock(m1, m2, ...) — стандартный способ взять несколько мьютексов атомарно, без требований к ordering. Алгоритм
try-and-back-off:
std::lock(m1, m2) — пример с двумя мьютексами
loop:
m1.lock() ──▶ заняли первый
if m2.try_lock() ok: ──▶ успех, выходим
else: ──▶ m1.unlock()
yield / короткий sleep
поменять порядок: пробовать с m2 сначала
поменять порядок:
m2.lock()
if m1.try_lock() ok: ──▶ успех
else: ──▶ m2.unlock(), пробовать снова с m1
Алгоритм гарантирует отсутствие deadlock'а: если не удалось взять второй мьютекс — первый отпускается, чтобы другой поток мог продвинуться. В реальной реализации (libstdc++) последовательность немного хитрее — она запоминает индекс мьютекса, который удалось взять, и стартует следующую попытку с него, чтобы минимизировать число retry.
// До C++17: std::lock + adopt_lock
std::lock(m1, m2); // атомарный захват
std::lock_guard<std::mutex> l1(m1, std::adopt_lock);
std::lock_guard<std::mutex> l2(m2, std::adopt_lock);
// C++17: scoped_lock — то же одним выражением
std::scoped_lock lk(m1, m2);
adopt_lock_t говорит guard'у: мьютекс уже захвачен (через std::lock), просто оберни его в RAII — не лочь повторно,
но unlock в деструкторе сделай.
Алгоритм std::lock в деталях¶
Стандарт C++ не специфицирует конкретный алгоритм для std::lock(m1, m2, ...) — он требует только отсутствия deadlock'а
при произвольном interleaving потоков. Реализации идут двумя путями: try-and-back-off (libstdc++, MSVC) и
ordered locking (так называемый Phil's algorithm).
Try-and-back-off — каноническая реализация. Берётся первый мьютекс блокирующе, остальные через try_lock. При первом
fail вся последовательность откатывается, начальный индекс ротируется, попытка повторяется. Псевдокод для N мьютексов:
std::lock(m_0, m_1, ..., m_{N-1}):
start = 0
loop:
locks[start].lock() ──▶ блокирующий lock на «опорный»
failed = -1
for i in (start+1, ..., start+N-1) mod N:
if not locks[i].try_lock(): ──▶ пробуем без блокировки
failed = i
break
if failed == -1: ──▶ все взяты
return
for j in (i, i-1, ..., start) mod N: ──▶ откатываем всё в обратном порядке
locks[j].unlock()
start = failed ──▶ следующий раз начинаем с того,
yield() на котором споткнулись
Ротация start = failed — важная деталь. Она минимизирует число retry'ев: поток в следующей итерации блокирующе ждёт
именно того мьютекса, который был занят, вместо того чтобы крутиться на try_lock'ах. Это превращает spin в обычное
ожидание в slow path фьютекса.
Phil's algorithm (он же Phil's Lock, или address ordering) — альтернатива: отсортировать мьютексы по адресу (или любому глобально-сравнимому идентификатору) и захватывать их в этом порядке блокирующе. Никаких try_lock, никаких откатов:
phil_lock(m_0, m_1, ..., m_{N-1}):
sorted = sort_by_address(m_0, ..., m_{N-1})
for m in sorted:
m.lock()
Если все потоки в системе используют один и тот же порядок (по адресу), deadlock невозможен по построению — это
классический lock ordering, формализованный для произвольного набора. Цена — мьютексы должны иметь сравнимую identity
(адрес стабилен, тип однороден), а реализация платит за sort (N×log N) на каждом вызове. Для N=2 это просто сравнение
указателей.
Стандарт оставил выбор реализациям, потому что у обоих алгоритмов есть trade-off'ы:
| Свойство | try-and-back-off | Phil's algorithm |
|---|---|---|
| Worst case | livelock theoretically possible | O(N log N) sort, deterministic |
| Fast path (uncontend) | N lock'ов | N lock'ов + sort |
| Под contention | retry'и, yield'ы | одна попытка, ждёт в порядке адреса |
| Требования к mutex'у | только Lockable | Lockable + сравнимая identity |
| Fairness | none | none (зависит от mutex'а) |
libstdc++ vs libc++. GCC's libstdc++ исторически использует try-and-back-off с ротацией начального индекса —
__try_lock_impl в <bits/std_mutex.h>. Под высоким contention он может делать несколько retry'ев подряд, что заметно в
профиле как лишние sched_yield. libc++ (LLVM) использует похожую схему, но с другим выбором первого мьютекса и
агрессивнее переходит в blocking режим — это иногда быстрее под heavy contention, иногда медленнее при coexisting
spurious wake-up'ах. Различия наблюдаемы, но в обоих случаях это implementation detail без гарантий.
scoped_lock vs lock_guard: стоимость¶
Для одного мьютекса разницы нет. scoped_lock<M> с одним аргументом — это эквивалент lock_guard<M>: тот же
размер (sizeof = sizeof(M*)), тот же конструктор m.lock(), тот же деструктор m.unlock(). Никаких лишних веток или
проверок.
Для нескольких мьютексов scoped_lock<M1, M2, ...> вызывает std::lock(m1, m2, ...) в конструкторе, и в деструкторе
каждый мьютекс получает свой unlock(). Это даёт безопасность от deadlock'а ценой алгоритма выше — на двух мьютексах под
contention это ~35 ns против ~15 ns у одного.
scoped_lock<M1, M2>::scoped_lock(M1& a, M2& b):
std::lock(a, b) ──▶ try-and-back-off / Phil's
─────────────────────────────────
// в полях хранится tuple<M1&, M2&>
// адопт-семантика: lock уже взят
scoped_lock<M1, M2>::~scoped_lock():
b.unlock() ──▶ обратный порядок относительно конструктора
a.unlock()
Когда выбирать какой:
lock_guard— один мьютекс, простейший RAII. Существует, потому что был до C++17 и оставлен для backward compatibility. В новом коде заменяется наscoped_lockбез перекомпиляции (тот же ABI fast path).scoped_lock— С++17 и новее. Для одного мьютекса работает какlock_guard, для нескольких — единственно правильный способ их захвата. Унифицирует интерфейс.unique_lock— нужны manual lock/unlock, move, передача в condition variable, или deferred lock.
До C++17 «правильный» способ взять несколько мьютексов был громоздок — std::lock существовал, но guard'ы приходилось
адоптировать вручную:
// C++11/14: грязно
std::lock(m1, m2);
std::lock_guard<std::mutex> l1(m1, std::adopt_lock);
std::lock_guard<std::mutex> l2(m2, std::adopt_lock);
// C++17: чисто
std::scoped_lock lk(m1, m2);
Правило «scoped_lock когда несколько мьютексов» имеет смысл только начиная с C++17 — в более ранних стандартах
эквивалентный по семантике паттерн требует явного std::lock с adopt_lock тегом на каждом guard'е.
unique_lock против lock_guard¶
lock_guard минимален — указатель на мьютекс и ничего больше; конструктор делает lock(), деструктор делает unlock().
Move не поддерживается, ручного управления нет.
unique_lock богаче: хранит указатель на мьютекс плюс boolean owns_lock_, поддерживает move, ручной lock/unlock,
несколько типов конструкторов:
std::mutex m;
// 1. Стандартный конструктор — lock в конструкторе (= lock_guard)
std::unique_lock<std::mutex> a(m);
// 2. defer_lock — взять обёртку без захвата
std::unique_lock<std::mutex> b(m, std::defer_lock);
// ... позже ...
b.lock(); // ручной захват
// 3. try_to_lock — попробовать без блокировки
std::unique_lock<std::mutex> c(m, std::try_to_lock);
if (c.owns_lock()) { /* успех */ }
// 4. adopt_lock — мьютекс уже захвачен извне (через std::lock)
m.lock();
std::unique_lock<std::mutex> d(m, std::adopt_lock);
// 5. Timed
std::unique_lock<std::timed_mutex> e(tm, std::chrono::milliseconds(100));
// Manual
a.unlock(); // отпустить раньше scope'а
a.lock(); // взять обратно
bool owns = a.owns_lock(); // в каком состоянии guard
std::mutex* raw = a.release(); // отдать ownership, НЕ unlock'нуть
// Move
std::unique_lock<std::mutex> f = std::move(a); // a больше не owns
Когда unique_lock обязателен:
condition_variable::waitтребуетunique_lock<mutex>— он внутри делает unlock на время сна и lock при пробуждении.lock_guardэто не поддерживает.- Возврат lock'а из функции. Move-семантика позволяет
unique_lockпересекать функциональные границы;lock_guardне move'абелен. - Временный unlock внутри scope'а —
lk.unlock() / lk.lock()— для тяжёлой работы, не требующей блокировки.
Overhead unique_lock — лишние 8 байт (boolean + padding) и одна проверка owns_lock_ в деструкторе. Это
не наблюдается в hot-loop, но для simple scope-bound случая lock_guard / scoped_lock чуть дешевле.
std::counting_semaphore¶
C++20 добавил семафоры в стандарт. std::counting_semaphore<MaxValue> — счётный семафор, параметризованный максимальным
значением (compile-time константа). std::binary_semaphore — алиас для counting_semaphore<1>.
#include <semaphore>
std::counting_semaphore<10> sem(0); // max=10, initial=0
std::binary_semaphore sig(0); // 0 или 1
sem.acquire(); // wait если 0, иначе decrement
sem.release(); // increment на 1, wake одного waiter
sem.release(3); // increment на 3, wake до 3 waiters
bool got = sem.try_acquire();
bool got = sem.try_acquire_for(std::chrono::milliseconds(100));
bool got = sem.try_acquire_until(deadline);
Под капотом — обычно atomic counter + futex (Linux) / WaitOnAddress (Windows). Fast path для acquire() при ненулевом
счётчике — один atomic CAS. Это тот же механизм, что используют std::atomic::wait / notify_* — единый kernel-level
parking.
Bounded buffer на семафорах¶
Классическая иллюстрация — producer-consumer с фиксированной очередью. Два семафора: slots (свободные места) и items
(заполненные места):
template <typename T, std::size_t N>
class BoundedQueue {
std::array<T, N> buf_;
std::size_t head_ = 0, tail_ = 0;
std::mutex m_;
std::counting_semaphore<N> slots_{N}; // изначально все слоты свободны
std::counting_semaphore<N> items_{0}; // изначально 0 элементов
public:
void push(T x) {
slots_.acquire(); // ждём свободного слота
{
std::lock_guard lk(m_);
buf_[tail_] = std::move(x);
tail_ = (tail_ + 1) % N;
}
items_.release(); // сигналим consumer'у
}
T pop() {
items_.acquire(); // ждём элемент
T x;
{
std::lock_guard lk(m_);
x = std::move(buf_[head_]);
head_ = (head_ + 1) % N;
}
slots_.release(); // освобождаем слот
return x;
}
};
Семафор здесь делает то, что цикл cv.wait(lk, predicate) сделал бы менее естественно: для счётных условий («есть
свободные слоты», «есть элементы») семафор — прямой подбор.
binary_semaphore как notification¶
std::binary_semaphore done{0};
std::jthread worker([&]{
// ... тяжёлая работа ...
done.release(); // сигналим завершение
});
done.acquire(); // ждём worker'а
Это часто чище, чем cv + bool + mutex для one-shot notification. Для повторных сигналов лучше CV.
Семафор через condition_variable¶
До C++20 семафоров в стандарте не было — их собирали на CV. Пример образовательный: показывает почему CV-интерфейс устроен именно так.
class Semaphore {
std::mutex m_;
std::condition_variable cv_;
int count_;
public:
explicit Semaphore(int initial) : count_(initial) {}
void acquire() {
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [&]{ return count_ > 0; }); // (1)
--count_;
}
void release() {
{
std::lock_guard<std::mutex> lk(m_);
++count_;
}
cv_.notify_one(); // (2)
}
};
acquire через cv.wait(lk, predicate)
thread A: acquire()
┌────────────────────────────────────────────────────┐
│ lk.lock() │
│ while (count_ <= 0): │
│ lk.unlock() ──▶ park on futex (cv internal) │
│ ... ждёт notify_one ... │
│ wake │
│ lk.lock() │
│ // тут predicate == true │
│ --count_ │
│ lk.unlock() (scope exit) │
└────────────────────────────────────────────────────┘
Два неочевидных момента:
(1) Зачем predicate-форма wait(lk, pred)? Защита от spurious wakeups (ложных пробуждений без notify). Стандарт
не гарантирует, что wait спит до тех пор, пока его не разбудили — поток может проснуться сам. Predicate-форма
эквивалентна while (!pred()) cv.wait(lk); — это правильный pattern. Без него код может декрементить count_, когда он
уже == 0.
(2) Почему unique_lock, а не lock_guard? cv.wait() должен атомарно: освободить мьютекс, заснуть, и
захватить мьютекс при пробуждении. Для этого CV нужно манипулировать lock'ом — unlock() перед сном, lock() после.
Интерфейс lock_guard этого не позволяет (нет публичных методов unlock/lock), а у unique_lock они есть.
Что у production-семафора отличается¶
- Thundering herd.
notify_oneбудит одного,notify_all— всех; всех будить плохо, если ждать должны не все. Реальныйcounting_semaphore::release(k)точечно разбудит доkожидающих, минимизируя лишние пробуждения. - Без mutex'а в fast path. Futex-based семафор делает acquire через CAS на счётчике без захвата мьютекса; mutex
здесь — это то, чего пытаются избежать. CV-based реализация всегда платит за
lk.lock() / unlock()— два atomic'а даже в неблокирующем случае. - Композиция с другими locks. Если поток в
acquire()держит ещё какой-то мьютекс, classic deadlock паттерн снова актуален; production-код документирует ordering.
std::latch (C++20)¶
std::latch — однократный countdown counter. Конструктор задаёт начальное значение N, потоки делают count_down(k)
(уменьшает счётчик на k) и wait() (блокирует пока счётчик > 0). Когда счётчик достигает нуля, latch переходит в
permanently signaled state — все wait()'ы возвращаются мгновенно, любой последующий wait() не блокирует. Reset
не поддерживается — это one-shot примитив.
#include <latch>
std::latch start_gate(1); // shared gun-shot: один count_down открывает всех
std::latch finish_line(N); // N работников должны отчитаться
void worker() {
start_gate.wait(); // ждём общего старта
do_work();
finish_line.count_down(); // отчитываемся
}
void coordinator() {
spawn(N, worker);
start_gate.count_down(); // открываем гонку
finish_line.wait(); // ждём финиша всех
}
API:
| Метод | Что делает |
|---|---|
latch(N) |
конструктор с начальным значением (ptrdiff_t) |
count_down(k = 1) |
вычитает k из счётчика; ноль будит всех ждущих |
wait() |
блокирует пока счётчик > 0 |
try_wait() |
non-blocking проверка: true если счётчик уже == 0 |
arrive_and_wait(k=1) |
атомарно count_down(k); wait() — частый шаблон |
stateDiagram-v2
[*] --> Counting
Counting: counting (n > 0)<br/>wait() blocks
Signaled: signaled (n == 0)<br/>wait() returns
Counting --> Signaled: count_down(n)
Signaled --> [*]: permanent —<br/>never returns
Под капотом — обычно atomic counter + futex на этой же ячейке. count_down делает fetch_sub(k); если результат стал
≤ 0 — futex_wake(INT_MAX) будит всех waiter'ов одним syscall'ом. wait() крутит counter, и при ненулевом значении
делает futex_wait на адресе счётчика.
Use cases:
- Барьер инициализации. Главный поток создаёт N подсистем (БД, кэш, сетевые соединения, prometheus exporter), каждая
инициализируется в своём потоке и делает
count_down; main делаетwait()и переходит к accept'у соединений только когда всё готово. - Synchronized start. Бенчмарк — N потоков должны начать работу одновременно.
latch(1)создаётся, каждый поток делаетwait(), main делаетcount_down()— гонка стартует синхронно. - Test fixture teardown. N асинхронных операций должны завершиться до teardown'а — latch с count = N, каждая
операция вызывает
count_downпо завершении.
std::barrier (C++20)¶
std::barrier — re-usable barrier. В отличие от latch'а, после прохождения всеми N участниками barrier автоматически
переходит в новую фазу и может использоваться снова. Это классический примитив для итеративных параллельных алгоритмов:
все потоки выполняют фазу 1 → синхронизируются → выполняют фазу 2 → синхронизируются → и т.д.
#include <barrier>
constexpr int N = 4;
std::barrier sync_point(N, []() noexcept {
// completion functor — выполняется один раз каждым phase'ом,
// выбранным потоком, после того как все N достигли barrier'а
log_phase_complete();
});
void worker(int id) {
for (int phase = 0; phase < 10; ++phase) {
compute_chunk(id, phase);
sync_point.arrive_and_wait(); // ждём остальных, переходим к следующей фазе
}
}
API:
| Метод | Что делает |
|---|---|
barrier(N, F) |
конструктор: N — expected count, F — completion functor (опциональный) |
arrive(k=1) |
отметиться (вычесть k из текущего счётчика), вернуть arrival_token |
wait(token) |
ждать конца текущей фазы по token'у |
arrive_and_wait() |
атомарно arrive(); wait(token) — частый шаблон |
arrive_and_drop() |
вычесть себя и из текущей, и из всех будущих фаз — поток покидает группу |
Фазовая структура и роль completion functor:
sequenceDiagram
participant T1
participant T2
participant T3
participant T4
participant B as barrier
Note over T1,B: phase k
T1->>B: compute → arrive_and_wait()
T2->>B: compute → arrive_and_wait()
T3->>B: compute → arrive_and_wait()
T4->>B: compute → arrive_and_wait() (last)
Note over B: completion functor F()<br/>выполняется ровно один раз<br/>за фазу — потоком T4 (последним)<br/>expected_count резетится в N
B-->>T1: phase k+1
B-->>T2: phase k+1
B-->>T3: phase k+1
B-->>T4: phase k+1
Completion functor вызывается ровно один раз за фазу, в потоке последнего arrive'a, и обязан быть noexcept —
исключение из него вызовет std::terminate. Это удобное место для:
- агрегации частичных результатов фазы (например, sum по локальным аккумуляторам),
- логирования прогресса,
- swap буферов (например, в double-buffered алгоритмах),
- инициализации общего состояния для следующей фазы.
Под капотом — два счётчика: текущий arrival count и фазовый token (обычно epoch number). arrive делает
fetch_sub на arrival counter'е; когда счётчик становится 0, последний поток вызывает completion functor, обновляет
epoch, восстанавливает arrival count в N и делает broadcast wake'е через futex_wake(INT_MAX). Waiter'ы крутят epoch и
просыпаются, когда он изменился.
Use cases:
- MapReduce shuffle. Workers'ы делают map → barrier → shuffle (completion functor распределяет данные) → workers'ы делают reduce → barrier → следующая итерация.
- Параллельные численные методы. Iterative solvers (Jacobi, conjugate gradient) — каждая итерация = фаза; в completion проверка сходимости.
- Game engines. Each frame: physics → barrier → render → barrier → swap. Completion functor swap'ит framebuffer.
arrive_and_drop() — для динамически уменьшающихся групп. Поток сообщает «я выхожу, не ждите меня в будущем» — N
уменьшается на 1 для всех последующих фаз.
wait_for и wait_until: precision¶
Timed-методы (cv.wait_for, cv.wait_until, mutex.try_lock_for, semaphore.try_acquire_for, latch.wait_for) —
часть всех ожидающих примитивов. У них есть неочевидный нюанс — какие часы используются под капотом.
std::chrono предоставляет несколько clock'ов; для синхронизации релевантны два:
std::steady_clock— монотонные часы. Гарантируется, что между двумя вызовамиnow()время не уменьшается. Не привязаны к wall clock. Не корректируются NTP, не меняются при переводе времени, не реагируют на suspend/resume в смысле «прыжка вперёд» (на LinuxCLOCK_MONOTONICостанавливается во время suspend,CLOCK_BOOTTIME— нет).std::system_clock— wall clock. ЭквивалентCLOCK_REALTIMEна Linux. Может прыгнуть назад или вперёд: NTP коррекция, ручное изменение даты, leap second.
Разница критична для абсолютных deadline'ов:
// КОРРЕКТНО: deadline в монотонном времени
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
cv.wait_until(lk, deadline, predicate);
// ОПАСНО: deadline в wall clock
auto deadline = std::chrono::system_clock::now() + std::chrono::seconds(30);
cv.wait_until(lk, deadline, predicate);
// если NTP подвинет системные часы на минуту назад,
// поток просидит лишнюю минуту
// если на час вперёд — wait вернётся немедленно
wait_for(N) всегда транслируется в wait_until(steady_now + N) — это единственно корректный способ выразить
относительный таймаут. Implementations реализуют это через CLOCK_MONOTONIC в pthread_cond_timedwait (через
pthread_condattr_setclock) или через FUTEX_WAIT_BITSET с FUTEX_CLOCK_REALTIME флагом — но логика «прибавляем N к
текущему монотонному времени» одна и та же.
wait_until(system_clock_time) корректно только для случаев, когда deadline действительно привязан к wall clock —
например, «выполнить операцию до 12:00 GMT». Для большинства inter-thread синхронизаций это неправильный выбор.
Performance¶
Приближённые цифры на x86-64 (Skylake, single thread, без contention):
| Примитив | lock+unlock | Под капотом |
|---|---|---|
std::mutex + lock_guard |
~15 ns | 1× CAS + 1× store, futex slow path |
std::mutex + unique_lock |
~17 ns | то же + проверка owns_lock_ |
std::scoped_lock (1 m) |
~15 ns | то же что lock_guard |
std::scoped_lock (2 m) |
~35 ns | try-and-back-off |
std::recursive_mutex |
~30 ns | TID compare + count++ или обычный lock |
std::shared_mutex rd |
~20 ns | CAS на reader counter |
std::shared_mutex wr |
~25 ns | writer-bit + wait readers |
std::counting_semaphore |
~10 ns | atomic decrement, futex slow path |
Под contention абсолютные числа уходят в сотни наносекунд (syscall + park/unpark). Reader-fast-path у shared_mutex
выигрывает только если несколько потоков читают одновременно и кэш-линия не пинг-понгает — иначе simple mutex
быстрее.
Подводные камни¶
recursive_mutex + condition_variable несовместимы. cv.wait делает один unlock(), не разворачивая счётчик
владений. Если поток держит recursive_mutex с count=3, wait отпустит только один уровень — другой поток не сможет
взять мьютекс. Для CV нужен plain std::mutex.
shared_mutex может быть медленнее обычного. Если writers редкие, но reader'ов мало, или contention низкий — простой
mutex обычно выигрывает. Профилировать.
counting_semaphore ≠ general CV. CV позволяет произвольный predicate (queue.size() > 0 && !stop_flag). Semaphore
умеет только «считать единицы» — для сложных условий он не работает.
unique_lock(defer_lock) + забытый lock(). Конструктор unique_lock(m, std::defer_lock) не захватывает мьютекс.
Если дальше код работает с защищёнными данными без явного lk.lock() — это просто race condition, никакого
compile-time warning'а не будет.
release() у unique_lock ≠ unlock(). release() отдаёт raw-указатель и обнуляет owns_lock_ — мьютекс остаётся
заблокированным, ответственность за unlock() переходит к вызывающему. Это удобно для перевода ownership, но
не то же, что отпустить блокировку.
scoped_lock() без аргументов компилируется. До C++17 nullary scoped_lock<> — корректный no-op guard. В C++17
это часто непреднамеренный bug (забыли передать мьютекс) — scoped_lock lk; блокирует ничего, scope не защищён.
Рабочий пример
Полные компилируемые демонстрации:
- мьютексы,
shared_mutex,scoped_lock:examples/q03_cpp_mutexes/mutexes.cpp - condition variable и семафор (producer-consumer):
examples/q04_condvar_semaphore/condvar_sem.cpp
Собрать и запустить: cd examples && make q03 q04 && ./bin/q03_cpp_mutexes
Связанные темы¶
- Синхронизация: мьютексы, семафоры, futex — базовые
std::mutex, POSIX-семафоры, futex и его быстрый/медленный путь - Atomic операции и memory model — какие memory orderings используют lock'и внутри; acquire/release семантика
- Потоки (основы) —
std::thread,std::jthread, создание и join потоков
Источники¶
- cppreference:
<mutex> - cppreference:
<shared_mutex> - cppreference:
<semaphore> - cppreference:
std::lockalgorithm - libstdc++ headers:
bits/std_mutex.h,shared_mutex,semaphore— реальные реализации - Anthony Williams, C++ Concurrency in Action, 2nd ed., главы 3–4