跳到正文

Wiki

semaphore、latch 与 barrier

约 8 分钟阅读

C++20 在:

<semaphore>
<latch>
<barrier>

中加入了几种更高层的同步工具:

std::counting_semaphore
std::binary_semaphore
std::latch
std::barrier

它们和 mutex 解决的问题不一样。

可以先用一句话记住:

mutex
→ 一次只允许一个线程进入临界区

semaphore
→ 一共有多少张“通行证”

latch
→ 等大家一次性全部到齐

barrier
→ 每一轮都要等大家到齐后再进入下一轮

所以:

semaphore
→ 许可数量控制

latch
→ 一次性同步

barrier
→ 可重复阶段同步

这些工具很多都可以用:

mutex + condition_variable + counter

手动实现。

但既然标准库已经提供了现成抽象,就没必要每次自己重新设计状态、计数和通知协议。


1.semaphore:许可数量

1.1.semaphore:控制“许可数量”

1.2.std::counting_semaphore

std::counting_semaphore 内部维护一个计数。

这个计数可以理解成:

当前还有多少张可用通行证。

例如:

当前计数 = 3

就可以理解成:

目前还有 3 个线程可以获得许可

线程想进入受限制区域之前调用:

semaphore.acquire();

如果当前计数:

> 0

那么:

计数减 1
线程继续执行

如果当前计数:

== 0

那么:

暂时没有许可
线程阻塞等待

等使用完以后:

semaphore.release();

表示:

归还一个许可。

于是:

计数加 1

并且可能唤醒一个正在等待许可的线程。

所以 semaphore 最核心的流程就是:

acquire()
→ 拿一张通行证

执行受限制的工作

release()
→ 把通行证还回去

1.3.counting_semaphore 最典型的用途:限制并发数量

例如:

最多只允许 2 个线程同时执行某段任务。

#include <chrono>
#include <print>
#include <semaphore>
#include <thread>
#include <vector>

using namespace std::chrono_literals;

std::counting_semaphore<2> slots(2);

void worker(int id)
{
    slots.acquire();

    std::println("start {}", id);

    std::this_thread::sleep_for(500ms);

    std::println("end {}", id);

    slots.release();
}

int main()
{
    std::vector<std::thread> threads;

    for (int i = 0; i < 5; ++i)
    {
        threads.emplace_back(worker, i);
    }

    for (auto& thread : threads)
    {
        thread.join();
    }
}

可能输出:

start 0
start 1
end 0
start 2
end 1
start 3
end 2
start 4
end 3
end 4

虽然我们创建了:

5 个线程

但:

std::counting_semaphore<2> slots(2);

初始只有 2 个许可。

所以最多只有:

2 个线程

能同时处于:

slots.acquire();
...
slots.release();

之间。

可以理解成:

一开始有 2 张通行证

线程 A 拿走一张
剩 1 张

线程 B 拿走一张
剩 0 张

线程 C 来了
没票
只能等

A 或 B 归还一张

C 获得许可继续

1.4.semaphore 和 mutex 最大的区别

mutex 通常表达:

同一时刻只能有一个线程进入临界区。

而:

std::counting_semaphore<N>

可以表达:

同一时刻最多允许 N 个线程进入。

所以:

mutex
≈ 只有 1 把钥匙

counting_semaphore
≈ 有 N 张通行证

但它们还有一个更重要的语义区别:

mutex
→ 强调锁的所有权

semaphore
→ 强调许可数量

对于 mutex:

谁 lock
通常就应该由谁 unlock

而 semaphore:

谁 acquire
不要求必须由同一个线程 release

也就是说:

semaphore 的许可可以由一个线程获取,由另一个线程归还。

这使它不仅可以限制并发数量,也可以做线程间信号通知。


1.5.模板参数和初始计数不是一回事

例如:

std::counting_semaphore<10> semaphore(3);

这里:

10

不是:

当前有 10 个许可

它表示的是:

这个 semaphore 所支持的最大计数上界至少为 10。

而:

3

才是:

初始计数。

所以这个对象刚创建时:

semaphore.acquire();
semaphore.acquire();
semaphore.acquire();

前三次可以直接成功。

第四次:

semaphore.acquire();

就需要等待别人:

semaphore.release();

所以记住:

std::counting_semaphore<10> semaphore(3);

10
→ 计数上限相关参数

3
→ 当前初始许可数量

不要把它们混在一起。


1.6.try_acquire()

如果你不想:

没有许可就一直阻塞

可以用:

if (semaphore.try_acquire())
{
    // 成功获得许可

    semaphore.release();
}

它的逻辑是:

有许可
→ 拿走一个
→ 返回 true

没许可
→ 立刻返回 false
→ 不等待

这和:

mutex.try_lock();

很像。


1.7.带超时的 acquire

还可以:

semaphore.try_acquire_for(...);

或者:

semaphore.try_acquire_until(...);

可以理解成:

try_acquire_for()
→ 最多等一段时间

try_acquire_until()
→ 最多等到某个时间点

例如:

最多等 100ms

拿到许可
→ 继续

100ms 还没拿到
→ 返回失败
→ 执行备用逻辑

适合:

资源池暂时没资源

连接数已满

并发额度暂时耗尽

不能无限等待

这类场景。


1.8.std::binary_semaphore

C++20 还提供:

std::binary_semaphore

它可以理解成:

计数最多只有 1 的 semaphore。

也就是它只有两种状态:

0
→ 没有许可

1
→ 有一个许可

例如:

std::binary_semaphore signal(0);

初始:

0 个许可

所以:

signal.acquire();

会阻塞。

另一个线程执行:

signal.release();

以后:

许可变成 1

一个等待线程可以继续

1.9.binary_semaphore 可以理解成一个“门”

例如:

std::binary_semaphore signal(0);

可以想成:

门一开始是关着的

线程 A:

signal.acquire();

相当于:

门没开
→ 在门口等

线程 B:

signal.release();

相当于:

发放一个许可
→ 让一个等待线程通过

所以 binary semaphore 很适合表达:

线程 A 等一个信号

线程 B 发出信号

1.10.binary_semaphore 不是 mutex

虽然 binary semaphore 的计数最多为 1,看起来很像:

std::mutex

但它们不是一回事。

mutex

强调所有权

哪个线程获得锁
通常由哪个线程释放

binary_semaphore

强调“有没有许可”

释放许可的线程
可以不是 acquire 的那个线程

可以这样记:

mutex
→ 谁锁门,谁开门

semaphore
→ 系统里有几张通行证

所以不要因为:

binary_semaphore 的值只有 0 / 1

就把它当成 mutex 的完全替代品。


1.11.semaphore 不会自动让共享对象线程安全

例如:

std::vector<int> data;

多个线程同时:

data.push_back(...);

可能产生数据竞争。

即使你用了:

std::counting_semaphore<4> semaphore(4);

它也不会神奇地让:

std::vector

变成线程安全。

semaphore 只解决:

最多允许多少个线程获得许可

并不自动解决:

共享对象的一致性保护

所以很常见的组合是:

semaphore
→ 控制最多多少个任务同时运行

mutex
→ 保护任务访问的共享数据结构

两者职责完全不同。


1.12.acquire 以后一定要记得 release

例如:

semaphore.acquire();

// 工作

semaphore.release();

如果中间发生:

return

异常

复杂分支

导致:

release()

没有执行,那么:

许可被拿走以后永远没有归还

可用许可会越来越少。

例如本来:

2 个许可

漏掉一次 release 后:

永远只剩 1 个

再漏一次:

永远变成 0

后续线程全部可能卡住。

所以 semaphore 也要认真考虑:

异常安全

提前 return

许可归还

2.latch:一次性同步

2.1.latch:一次性倒计时同步

std::latch 可以理解成:

一个只能使用一次的倒计时门。

例如:

std::latch done(3);

表示:

初始计数 = 3

每完成一个参与者:

done.count_down();

计数减 1。

过程类似:

3

2

1

0

当计数变成:

0

等待 latch 的线程就可以继续。


2.2.latch 最适合表达什么

它最适合这种需求:

等 N 件事情全部做完,然后继续。

例如:

启动 3 个初始化任务

任务 1 完成
→ count_down()

任务 2 完成
→ count_down()

任务 3 完成
→ count_down()

计数变成 0

主线程继续

2.3.latch 示例

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

std::latch done(3);

void worker(int id)
{
    std::println("worker {} done", id);

    done.count_down();
}

int main()
{
    std::vector<std::thread> threads;

    for (int i = 0; i < 3; ++i)
    {
        threads.emplace_back(worker, i);
    }

    done.wait();

    std::println("all workers reached the latch");

    for (auto& thread : threads)
    {
        thread.join();
    }
}

可能输出:

worker 1 done
worker 0 done
worker 2 done
all workers reached the latch

前三行顺序不确定。

但:

all workers reached the latch

一定发生在:

3 次 count_down()

全部完成以后。

所以主线程根本不关心:

哪个 worker 先完成

它只关心:

计数归零了吗?

2.4.count_down()

done.count_down();

表示:

我完成了自己的这一份工作,把计数减掉。

例如:

初始 3

第一个线程 count_down()
→ 2

第二个线程 count_down()
→ 1

第三个线程 count_down()
→ 0

当计数到:

0

latch 被永久打开。


2.5.wait()

done.wait();

表示:

如果 latch 计数还没有归零,就等待。

如果此时:

count > 0

线程阻塞。

如果已经:

count == 0

那么:

直接返回

而且 latch 一旦变成 0:

以后一直保持“打开”

不会重新关上。


2.6.arrive_and_wait()

如果当前线程:

既是参与者

又要等待其他参与者

可以使用:

latch.arrive_and_wait();

它可以理解成:

我已经到达

计数减一

然后我也等待其他人到达

也就是类似:

latch.count_down();
latch.wait();

不过 arrive_and_wait() 表达意图更直接。


2.7.latch 是一次性的

这是 latch 最重要的特点:

只能用一次

例如:

std::latch done(3);

最终:

3 → 2 → 1 → 0

到 0 以后:

不会重新回到 3

所以 latch 特别适合:

等待一批初始化任务完成

等待几个线程启动就绪

等待一次性并行任务全部结束

而不适合:

第一轮同步
第二轮同步
第三轮同步
...

这种场景应该用:

std::barrier

2.8.latch 可以理解成“一次性开门”

例如:

初始 latch(3)

还差 3 人
门关着

还差 2 人
门关着

还差 1 人
门关着

最后 1 人到达
计数变 0

门打开

以后永远保持打开

所以:

latch
≈ 一次性会合点

3.barrier:可重复阶段同步

3.1.barrier:可重复的阶段同步

std::barrier 解决的是:

多个线程需要一轮一轮同步。

例如有三个线程:

线程 A
线程 B
线程 C

每个线程都要执行:

第 1 阶段

等待所有人完成

第 2 阶段

等待所有人完成

第 3 阶段

这时:

std::barrier

非常合适。


3.2.barrier 可以理解成“每轮都要集合”

假设:

std::barrier sync_point(3);

表示:

每一轮都要等 3 个参与者到达

第一轮:

A 到了
B 到了
C 到了

3 人到齐

全部放行进入下一轮

然后 barrier 自动重新准备:

下一轮继续等 3 人

所以:

latch
→ 只集合一次

barrier
→ 每一轮都重新集合

3.3.barrier 示例

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

std::barrier sync_point(3);

void worker(int id)
{
    std::println("phase 1: {}", id);

    sync_point.arrive_and_wait();

    std::println("phase 2: {}", id);

    sync_point.arrive_and_wait();

    std::println("phase 3: {}", id);
}

int main()
{
    std::vector<std::thread> threads;

    for (int i = 0; i < 3; ++i)
    {
        threads.emplace_back(worker, i);
    }

    for (auto& thread : threads)
    {
        thread.join();
    }
}

可能输出:

phase 1: 2
phase 1: 0
phase 1: 1

phase 2: 1
phase 2: 2
phase 2: 0

phase 3: 0
phase 3: 2
phase 3: 1

每个阶段内部:

谁先打印

不确定。

但是一定满足:

所有 phase 1 都完成

才可能出现 phase 2

所有 phase 2 都完成

才可能出现 phase 3

所以不会出现:

线程 A 已经进入 phase 2

但线程 B 连 phase 1 的 barrier 都还没到

3.4.arrive_and_wait()

barrier 最常见的操作:

sync_point.arrive_and_wait();

表示:

我已经完成这一阶段

记录一次到达

然后等其他参与者

当本阶段所有参与者都到达以后:

所有等待线程一起进入下一阶段

所以:

arrive_and_wait();

非常符合:

“这一轮我做完了,现在等大家。”

这种语义。


3.5.barrier 会自动进入下一轮

这是它和 latch 最大的不同。

latch:

计数归零

永久打开

结束

barrier:

本轮所有人到达

本轮完成

自动开始下一轮

重新等待参与者

所以:

latch
→ 一次性同步

barrier
→ 循环同步

3.6.completion function

std::barrier 还可以设置:

阶段完成函数(completion function)

它会在:

本轮所有参与者都到达

以后执行一次。

概念上:

所有线程完成第 N 阶段

completion function

进入第 N+1 阶段

这适合:

所有线程完成本轮计算

统一更新一次共享状态

所有线程开始下一轮

例如并行算法可能需要:

第 1 轮:
每个线程算自己那部分

所有人到 barrier

completion:
交换 / 汇总本轮结果

第 2 轮:
基于新结果继续计算

3.7.completion function 不要做太重的事情

因为:

所有参与者都在等这个阶段真正结束

所以 completion function 如果:

执行很慢

做长时间 IO

sleep

等待其他锁

执行复杂阻塞操作

那么所有参与者都会一起被拖住。

所以 completion function 更适合:

很短的状态更新

交换阶段数据

维护轮次

轻量汇总

3.8.arrive_and_drop()

假设 barrier 初始有:

3 个参与者

但某个线程执行完当前阶段以后:

后面几轮不再参加了。

这时不能直接:

退出线程

因为 barrier 还以为:

下一轮仍然要等 3 人

但实际上以后只有:

2 人

于是其他两个线程永远等不到第三个。

所以需要:

barrier.arrive_and_drop();

表示:

我完成了本轮到达

并且:

以后每一轮都不用再等我

也就是把未来参与者数量永久减掉。


4.选择与对比

4.1.latch 和 barrier 最核心的区别

可以直接这样记:

latch
→ 一次性的

barrier
→ 可重复的

例如:

4.1.1.latch

等 4 个模块初始化完成

程序正式启动

初始化只发生一次。

所以:

std::latch

很合适。

4.1.2.barrier

4 个线程一起做第 1 轮

集合

一起做第 2 轮

集合

一起做第 3 轮

集合

每一轮都要同步。

所以:

std::barrier

更合适。


4.2.semaphore、latch、barrier 怎么选

可以直接看这张表:

需求 推荐工具
最多允许 N 个线程同时获得资源 std::counting_semaphore
一个简单的 0 / 1 许可或线程间信号 std::binary_semaphore
等 N 个参与者一次性全部完成 std::latch
多个线程每轮都要同步 std::barrier
保护共享对象一致性 std::mutex
等复杂共享条件成立 std::condition_variable

4.3.和 mutex 的区别

mutex 关注的是:

谁能进入临界区

例如:

std::scoped_lock lock(mutex);

表达:

这段共享数据同时只允许一个线程操作

而 semaphore 关注:

还有多少许可

latch 关注:

还有多少参与者没完成

barrier 关注:

这一轮所有参与者到齐了吗

所以这些工具:

不是 mutex 的不同写法

而是:

解决不同同步模式

4.4.和 condition_variable 的关系

理论上很多同步器都可以自己手搓。

例如 latch 可以大概用:

counter

mutex

condition_variable

实现:

counter 每完成一个任务减 1

counter == 0

notify_all()

barrier 也可以自己维护:

当前轮次
参与者数量
已到达数量
condition_variable

实现。

但这很容易产生:

计数错误

边界条件错误

通知丢失

轮次状态错误

异常退出后人数不一致

所以:

condition_variable
→ 通用零件

semaphore / latch / barrier
→ 已经封装好的常见同步模式

如果标准库已经有你想表达的模型:

优先使用标准抽象

通常会让代码:

更短

更清楚

更不容易写错

更容易让别人看懂

4.5.semaphore 的典型场景

counting_semaphore 常见于:

限制同时下载任务数量

限制数据库连接数

限制 GPU / 加速器任务并发数

资源池可用对象数量

限制同时进行的网络请求

控制同时执行的重任务数量

例如:

线程可以有 100 个

但数据库连接池只有 10 个连接

→ semaphore 初始许可 = 10

这就很自然。


4.6.latch 的典型场景

latch 常见于:

多个模块并行初始化

主线程等待所有初始化结束

多个并行任务全部完成后统一继续

启动多个 worker,等待全部 ready

关键词是:

一次性

4.7.barrier 的典型场景

barrier 常见于:

并行数值计算

迭代算法

分块处理

多线程仿真

每一轮都依赖上一轮完整结果

关键词是:

一轮一轮同步

5.常见错误

5.1.错误:把 semaphore 当 mutex

错误理解:

用了 semaphore
→ 共享 vector 自动线程安全

不对。

semaphore 只控制:

许可数量

共享对象的一致性仍然可能需要:

std::mutex

5.2.错误:acquire 以后忘记 release

例如:

semaphore.acquire();

if (error)
{
    return; // 忘了 release
}

这样会永久损失一个许可。


5.3.错误:认为 binary semaphore 有 mutex 的所有权规则

binary semaphore 虽然只有:

0 / 1

但它仍然:

不是 mutex

许可可以由不同线程 acquire / release。


5.4.错误:把 latch 当成可以重置的工具

std::latch

只能归零一次

归零以后不会:

重新恢复初始计数

需要循环同步时用:

std::barrier

5.5.错误:barrier 的参与人数写错

例如:

std::barrier barrier(4);

但实际上只有:

3 个线程

会调用:

arrive_and_wait();

那么:

永远差第 4 个参与者

所有线程都可能永久卡住。


5.6.错误:线程退出了,却没 arrive_and_drop()

如果某个 barrier 参与者:

以后不再参加后续轮次

却直接退出,那么 barrier 仍然会:

在后续每一轮等它

应该:

arrive_and_drop();

5.7.错误:completion function 太慢

completion function 执行期间:

所有参与者都在等阶段完成

所以里面不适合:

长时间 IO

sleep

复杂阻塞

重计算

危险的锁嵌套

6.最后总结

这三个 C++20 同步工具可以这样记:

semaphore
→ 有几张通行证

latch
→ 一次性等大家到齐

barrier
→ 每一轮都等大家到齐

更具体一点:

std::counting_semaphore
→ 允许同时存在多个许可

std::binary_semaphore
→ 只有 0 / 1 个许可

std::latch
→ 一次性倒计时,归零后永久打开

std::barrier
→ 每轮所有参与者到达后,自动开始下一轮

它们和 mutex 的关系:

mutex
→ 保护共享状态

semaphore / latch / barrier
→ 表达更高层的同步模式

如果能用标准同步原语直接表达你的需求:

优先使用标准工具,而不是手写 mutex + condition_variable + counter

最后可以用一句话彻底区分:

mutex
→ “谁能进?”

semaphore
→ “还能进几个?”

latch
→ “这一次人到齐了吗?”

barrier
→ “这一轮人到齐了吗?”