C++ STL 之 latch / barrier / semaphore 详解(C++20 并发新原语)

一、为什么需要新的并发原语

C++11 提供了 std::threadstd::mutexstd::condition_variablestd::future,但这些工具在面对"等待一组任务完成"、"限制并发数量"等常见场景时,要么需要手写复杂的同步逻辑,要么性能不够理想。C++20 从 POSIX 和 Java 等生态引入了三个标准库同步原语:

原语 作用 是否可复用
std::latch 一次性同步点,N 个线程到达后放行
std::barrier 可复用同步点,每轮到达触发回调
std::counting_semaphore 计数信号量,控制资源访问数

它们都定义在 <latch><barrier><semaphore> 头文件中,底层通常基于原子操作或 futex 实现,比条件变量方案更轻量。

二、latch——一次性栅栏

std::latch 是一个向下计数的一次性屏障。构造时指定初始计数,线程调用 count_down() 减少计数,调用 wait() 阻塞直到计数归零。计数到达零后永久保持,不可重置。

#include <latch>
#include <thread>
#include <vector>
#include <iostream>

void demo_latch() {
    std::latch done{3};  // 等待 3 个任务

    std::vector<std::jthread> workers;
    for (int i = 0; i < 3; ++i) {
        workers.emplace_back([&, i] {
            // 模拟工作
            std::this_thread::sleep_for(std::chrono::milliseconds(100 * i));
            std::cout << "worker " << i << " done\n";
            done.count_down();  // 计数减 1
        });
    }

    done.wait();  // 等待所有 3 个 worker
    std::cout << "all workers done, proceed\n";
}

关键 API:

  • count_down(n = 1) —— 计数减 n,不阻塞
  • wait() —— 阻塞直到计数为 0
  • arrive_and_wait(n = 1) —— 等价于 count_down(n) + wait()
  • try_wait() —— 非阻塞检查计数是否为 0

典型场景:初始化加载、多路并行计算后合并结果、所有线程就绪后同时开始。

三、barrier——可复用栅栏

std::barrier 是 latch 的升级版。它像一个"回合制"同步点:每轮 N 个线程全部到达后,一起放行,然后自动进入下一轮。每轮结束时还会执行一个完成回调(completion function)。

#include <barrier>
#include <thread>
#include <iostream>

void demo_barrier() {
    constexpr int N = 3;
    int round = 0;
    std::barrier sync{N, [&]() noexcept {
        std::cout << "=== round " << ++round << " done ===\n";
    }};

    std::vector<std::jthread> workers;
    for (int i = 0; i < N; ++i) {
        workers.emplace_back([&, i] {
            for (int r = 0; r < 3; ++r) {
                std::this_thread::sleep_for(std::chrono::milliseconds(50 * i));
                std::cout << "worker " << i << " arrives at round " << r << "\n";
                sync.arrive_and_wait();  // 到达并等待本轮所有人
            }
        });
    }
}

每次 arrive_and_wait() 返回时,所有线程都位于同一轮次的同一时间点——这就是 barrier 的核心保证。

线程 1 到达

barrier 等待

线程 2 到达

线程 3 到达

执行完成回调

所有线程放行

线程 1 下一轮

线程 2 下一轮

线程 3 下一轮

关键 API 与 latch 的差异:

  • arrive(n = 1) —— 到达,返回 std::barrier::arrival_token(用于 wait() 分离)
  • arrive_and_wait() —— 到达并等待
  • arrive_and_drop() —— 永久减少本轮参与线程数(退出专用)

典型场景:迭代计算中每轮结束后同步、游戏帧循环中的多线程阶段同步、流水线作业中所有阶段对齐。

四、semaphore——计数信号量

std::counting_semaphore<LeastMaxValue> 是一个计数信号量,模板参数 LeastMaxValue 指定最大计数的下限。std::binary_semaphore 是其特化 counting_semaphore<1>

#include <semaphore>
#include <thread>
#include <queue>
#include <iostream>

std::counting_semaphore<10> sem{3};   // 最多 3 个并发
std::mutex mtx;
std::queue<int> q;

void producer() {
    for (int i = 0; i < 10; ++i) {
        sem.acquire();                 // P 操作:计数减 1,满则阻塞
        {
            std::lock_guard lk{mtx};
            q.push(i);
            std::cout << "produce " << i << "\n";
        }
    }
}

void consumer() {
    for (int i = 0; i < 10; ++i) {
        int val;
        {
            std::lock_guard lk{mtx};
            if (!q.empty()) {
                val = q.front();
                q.pop();
            }
        }
        sem.release();                 // V 操作:计数加 1,唤醒等待者
        std::cout << "consume " << val << "\n";
    }
}

acquire()

计数 > 0 ?

计数减 1
继续执行

阻塞等待

有 release() 调用

release()

计数加 1
唤醒等待者

关键 API:

  • acquire() —— P 操作,计数减 1,不足时阻塞
  • release(n = 1) —— V 操作,计数加 n,唤醒等待者
  • try_acquire() —— 非阻塞版 acquire,失败返回 false
  • try_acquire_for(dur) —— 带超时的 acquire

典型场景:线程池任务队列限流、连接池资源分配、生产者-消费者协调。

五、与 C++11 条件变量方案对比

5.1 latch vs. condition_variable

用条件变量实现"等待 N 个线程完成":

// C++11 方式
int count = 3;
std::mutex mtx;
std::condition_variable cv;
{
    std::unique_lock lk{mtx};
    if (--count == 0)
        cv.notify_all();
    else
        cv.wait(lk, [&]{ return count == 0; });
}

问题:

  • 需要额外 mutexint 计数器
  • 虚假唤醒(spurious wakeup)必须用谓词循环检查
  • notify_all() 唤醒所有等待者,即使计数没到零

std::latch

std::latch done{3};
done.count_down();     // 任何线程
done.wait();           // 等待线程
  • 零额外变量,零虚假唤醒
  • 底层通常基于 std::atomic<int> + futex,无锁路径

5.2 barrier vs. 手写循环

手写可复用栅栏需要条件变量 + 计数器 + 轮次标志,逻辑极易出错。std::barrier 封装了全部状态机,且完成回调的执行保证:恰好有一个线程执行回调,其他线程在回调完成后才被放行。

5.3 semaphore vs. condition_variable

维度 condition_variable counting_semaphore
状态 无状态,需外部变量 自带计数状态
虚假唤醒 可能发生,需谓词检查 不产生
多资源管理 需自行计算 acquire/release 原生支持
超时 支持 wait_for/until 同上

六、面试常考题

Q1:latch 和 barrier 的根本区别是什么?

latch 一次性使用,计数归零后永久放行;barrier 每轮结束后自动重置,支持多轮同步且可设完成回调。

Q2:barrier 的 completion function 由哪个线程执行?

由每轮最后一个调用 arrive_and_wait() 的线程执行,且执行期间其他线程仍处于阻塞状态。

Q3:binary_semaphore 可以替代 mutex 吗?

不能直接替代。binary_semaphore 的 release() 可以在任意线程调用,而 mutex 要求同一线程 unlock。但正因如此,binary_semaphore 适合实现信号量传递(如生产者通知消费者)。

Q4:counting_semaphore<5> 和 counting_semaphore<10> 在运行时有区别吗?

没有。模板参数只是最大计数的下限声明,运行时行为取决于初始值和 release 调用。不同特化类型的对象也不能相互赋值。

Q5:C++20 的并发原语能替代 std::future 吗?

不能完全替代。latch/barrier 关注线程同步点,future 关注异步结果传递。它们解决的问题正交,常配合使用。

Q6:什么场景下 barrier 比 latch 更适合?

任何需要多轮同步的场景:比如迭代求解(每轮结束后广播中间结果)、帧动画渲染(每帧多线程分工后合并、下一帧继续)。latch 只适合一次性的"等待初始化完成"或"等待所有任务提交"。

七、总结

latch/barrier/semaphore 填补了 C++11 标准库在同步原语上的空白。它们比手写条件变量方案更简洁、性能更好(无虚假唤醒、原子操作更快),并且语义明确、不易出错。在新项目中,凡是遇到"等待 N 个线程"或"限制并发数量"的需求,优先考虑这三个标准原语。

更多推荐