跳到正文

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
→ “这一轮人到齐了吗?”