Latches and Barriers in C++ Thread Synchronization
Concurrent programming in C++ gained powerful synchronization
mechanisms with C++20 through the introduction of
std::latch and std::barrier. These primitives
allow groups of threads to coordinate execution points and synchronize
phases without the overhead and complexity of manual mutexes and
condition variables. This article explains how both primitives operate,
their structural differences, and how to implement them to manage thread
coordination efficiently.
Understanding
std::latch
A std::latch is a downward counter used to synchronize
threads through a single-use countdown mechanism. Once initialized with
an integral counter value, threads can decrement this count until it
reaches zero.
A latch is strictly single-use; its counter cannot be reset or reused once it hits zero. Key operations include:
count_down(n = 1): Decrements the internal counter bynwithout blocking the calling thread.wait(): Blocks the calling thread until the counter reaches zero. If the counter is already zero, it returns immediately.arrive_and_wait(n = 1): Atomically decrements the counter bynand blocks until the counter reaches zero.
Latches are ideal for fork-join patterns, such as waiting for a pool of worker threads to finish data initialization before allowing processing to continue.
#include <iostream>
#include <thread>
#include <vector>
#include <latch>
void worker(std::latch& work_done, int id) {
// Perform task
work_done.count_down();
}
int main() {
constexpr int num_threads = 4;
std::latch work_done(num_threads);
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(worker, std::ref(work_done), i);
}
work_done.wait(); // Blocks until all 4 workers call count_down()
std::cout << "All workers completed.\n";
}Understanding
std::barrier
A std::barrier is a reusable phase-synchronization
primitive designed for iterative algorithms where a fixed set of threads
must execute in lockstep across repeated cycles.
Unlike a latch, a barrier resets its counter automatically at the end of each phase. It also accepts an optional completion function (a callable object) that executes atomically on one of the arriving threads after all threads have arrived, but before any threads are released for the next phase.
Key operations include:
arrive_and_wait(): Arrives at the synchronization point, decrements the expected counter, and blocks until the phase completion step finishes.arrive(n = 1): Decrements the counter without blocking, allowing a thread to signal completion of its phase early.arrive_and_drop(): Decrements the counter for the current phase and permanently decreases the expected count for all subsequent phases, removing the calling thread from the synchronization group.
#include <iostream>
#include <barrier>
#include <thread>
#include <vector>
void on_phase_complete() noexcept {
static int phase = 1;
std::cout << "Phase " << phase++ << " completed.\n";
}
void step_worker(std::barrier<void(*)() noexcept>& sync_point) {
for (int step = 0; step < 2; ++step) {
// Perform work for the current step
sync_point.arrive_and_wait(); // Wait for all threads to finish step
}
}
int main() {
constexpr int num_threads = 3;
std::barrier sync_point(num_threads, on_phase_complete);
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(step_worker, std::ref(sync_point));
}
}Choosing Between Latches and Barriers
Select std::latch when you have a one-off dependency
where one or more threads need to wait for a fixed number of operations
to finish before proceeding. It acts as an irreversible checkpoint.
Select std::barrier when multiple threads repeatedly run
in parallel stages, needing to rendezvous at intermediate steps before
transitioning together into the next computation phase.