屏障与闩:C++20中的std::barrier与std::latch

多线程同步这件事,说白了就是让一堆线程「对齐脚步」。

我早年做游戏服务器时,经常要处理一波线程同时到达某个点、再一起往下走的场景。那时候只能用条件变量加计数器,代码又臭又长,还容易出bug。C++20终于给了我们两个好东西——std::latchstd::barrier。它们就是专门干这个的。

先说说std::latch:一次性闸门

std::latch是个「一次性」的同步工具。你设定一个计数,多个线程各自干活,干完了就调用count_down()。当计数归零时,所有等待在wait()上的线程一起被释放。

嗯,这里要注意:latch只能用一次。计数归零后,它就永久处于「打开」状态了。

核心特点

  • 一次性使用,不可重置
  • 支持多个线程同时等待
  • 计数可以一次减多(count_down(n)
  • 线程可以既做「减数者」又做「等待者」

我举个例子。假设你要加载游戏资源,主线程必须等所有资源加载完才能继续。用latch就特别自然:

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

void load_resources() {
    const int task_count = 4;
    std::latch completion_latch(task_count);

    std::vector<std::jthread> workers;
    for (int i = 0; i < task_count; ++i) {
        workers.emplace_back([&completion_latch, i] {
            // 模拟加载资源
            std::this_thread::sleep_for(std::chrono::milliseconds(100 * (i + 1)));
            printf("Worker %d done\n", i);
            completion_latch.count_down();  // 干完活就减一
        });
    }

    // 主线程等待所有worker完成
    completion_latch.wait();
    printf("All resources loaded, game starts!\n");
}

你看,代码干净利落。没有条件变量,没有锁,没有复杂的标志位。这就是latch的价值。

再说std::barrier:可复用的多阶段同步

std::barrier比latch更灵活。它可以重复使用,适合「多阶段同步」的场景。

什么叫多阶段同步?你想想看,一个流水线:第一阶段所有线程处理完数据,然后一起进入第二阶段。第二阶段处理完,再一起进入第三阶段。每个阶段结束,所有线程都要「对齐」一下。

barrier就是干这个的。它每次到达计数点后,会执行一个「完成回调」,然后自动重置,进入下一轮。

核心特点

  • 可重复使用,自动重置
  • 支持完成回调(arrive_and_wait后执行)
  • 每个线程可以提前离开(arrive_and_drop)
  • 适合迭代计算、流水线处理

我曾经在做一个物理仿真引擎时,每个时间步需要分三步:碰撞检测、约束求解、位置更新。每步之间所有线程必须同步。用barrier简直完美:

#include <barrier>
#include <thread>
#include <vector>

void physics_simulation() {
    const int num_threads = 4;
    int phase = 0;

    // 完成回调:每次所有线程到达屏障后执行
    auto completion_fn = [&phase]() noexcept {
        ++phase;
        printf("Phase %d complete, moving to next\n", phase);
    };

    std::barrier sync_point(num_threads, completion_fn);

    std::vector<std::jthread> workers;
    for (int t = 0; t < num_threads; ++t) {
        workers.emplace_back([&sync_point, t] {
            for (int step = 0; step < 3; ++step) {
                // 阶段1:碰撞检测
                printf("Thread %d: collision detection\n", t);
                sync_point.arrive_and_wait();

                // 阶段2:约束求解
                printf("Thread %d: constraint solving\n", t);
                sync_point.arrive_and_wait();

                // 阶段3:位置更新
                printf("Thread %d: position update\n", t);
                sync_point.arrive_and_wait();
            }
        });
    }
}

注意看,每个线程在每个阶段结束时调用arrive_and_wait()。当所有线程都到达后,barrier自动执行回调,然后所有线程继续往下走。干净、安全、可读性强。

latch vs barrier:怎么选?

特性 std::latch std::barrier
可重用 否,一次性 是,自动重置
完成回调 有,每次到达后执行
线程数固定 计数可大于线程数 计数必须等于参与线程数
典型场景 一次性初始化、资源加载 迭代计算、多阶段流水线
提前退出 不支持 支持arrive_and_drop

我个人习惯这样判断:如果只需要「等一次」,用latch。如果需要「反复对齐」,用barrier。

小技巧

latch的计数可以大于实际线程数。比如你有4个线程,但每个线程要处理2个任务,可以设计数为8,每个线程完成一个任务就count_down一次。barrier不行,barrier的计数必须等于参与线程数。

避坑指南:我踩过的几个坑

坑一:barrier的回调必须是noexcept

我曾经在回调里抛了个异常,程序直接崩了。标准规定barrier的完成回调必须是noexcept的,否则是未定义行为。所以回调里别做可能抛异常的事。

坑二:latch的count_down和wait可以分开调用

同一个线程可以先count_down()wait(),这没问题。但如果你先wait()count_down(),那就死锁了——因为wait()会阻塞直到计数归零,而计数还没减呢。

坑三:barrier的arrive_and_drop要慎用

arrive_and_drop()会让当前线程永久退出barrier的参与集。如果你不小心调多了,barrier的计数会变成0,然后所有等待的线程都被释放。我调试过一个bug,就是某个线程误调了arrive_and_drop,导致其他线程提前「越狱」了。

重要提醒

barrier的完成回调是在某个到达的线程上执行的,具体哪个线程不确定。不要在回调里做耗时操作,否则会拖慢所有线程。回调里只做轻量级的「阶段切换」逻辑。

多阶段同步的实战场景

我最近在做一个图像处理管线,每个线程处理图像的一块区域。处理流程分三步:滤波、边缘检测、特征提取。每步之间必须同步,因为下一步依赖上一步的全局结果。

用barrier实现起来非常直观:

void image_pipeline() {
    const int num_workers = 8;
    std::barrier stage_barrier(num_workers);

    auto worker = [&stage_barrier](int id) {
        // 阶段1:滤波
        apply_filter(id);
        stage_barrier.arrive_and_wait();

        // 阶段2:边缘检测(依赖滤波后的完整图像)
        edge_detection(id);
        stage_barrier.arrive_and_wait();

        // 阶段3:特征提取(依赖边缘检测结果)
        extract_features(id);
        stage_barrier.arrive_and_wait();
    };

    std::vector<std::jthread> threads;
    for (int i = 0; i < num_workers; ++i)
        threads.emplace_back(worker, i);
}

你看,每个阶段结束,所有线程在barrier处对齐。没有锁,没有条件变量,没有忙等待。代码读起来就像在描述「我们要做什么」,而不是「怎么同步」。这才是现代C++该有的样子。

性能考量

latch和barrier内部通常用原子操作和futex实现,性能比手写条件变量好得多。我做过简单测试:在64线程的机器上,barrier的同步开销大约在几百纳秒级别,而手写条件变量版本要几微秒。

不过要注意一点:barrier的完成回调是在临界区内执行的。如果回调里做了耗时操作,会延长所有线程的等待时间。所以回调要尽量轻量。

我的建议

能用latch和barrier的地方,就别自己造轮子了。标准库的实现经过了充分优化和测试,比你手写的条件变量+计数器方案要可靠得多。我早期项目里自己写的同步原语,后来重构时全换成了标准库版本,bug少了一大半。

多阶段同步的核心逻辑

下面这张图展示了barrier在多阶段同步中的工作流程。每个线程在阶段边界处对齐,然后一起进入下一阶段。

std::barrier 多阶段同步流程 阶段1:碰撞检测 线程1 线程2 线程3 线程4 屏障(barrier)—— 所有线程在此对齐 执行完成回调 阶段2:约束求解 线程1 线程2 线程3 线程4 屏障(barrier)—— 再次对齐,进入阶段3 • 每个阶段结束后,所有线程在屏障处等待 • 当所有线程到达后,自动执行完成回调 • 屏障自动重置,进入下一阶段 • 整个过程无需手动管理锁或条件变量

从图中可以清楚看到:每个线程独立执行阶段任务,然后在屏障处「对齐」。屏障自动执行回调,然后所有线程一起进入下一阶段。这就是多阶段同步的核心逻辑。


好了,关于latch和barrier就聊这么多。这两个工具虽然简单,但用好了能大幅简化多线程同步代码。我个人建议你多在实际项目中试试,特别是那些「等所有人到齐再继续」的场景。你会发现,代码变得好写多了,bug也少多了。