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 by n without 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 by n and 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.