多线程:按序打印问题(信号量)

目录

题目描述

回归本质------什么是信号量?

构建依赖链条

[C++ 代码实现](#C++ 代码实现)

[信号量 vs. 互斥锁](#信号量 vs. 互斥锁)

补充:std::binary_semaphore

[它的本质:只有 0 和 1 的计数器](#它的本质:只有 0 和 1 的计数器)

两个核心操作

[为什么它比 Mutex 更适合"同步"?](#为什么它比 Mutex 更适合“同步”?)

解决"虚假唤醒"的天生优势

[在 Foo 题中的具体应用细节](#在 Foo 题中的具体应用细节)


题目描述

给你一个类:

cpp 复制代码
class Foo {
public:
    Foo() {
        
    }

    void first(function<void()> printFirst) {
        
        // printFirst() outputs "first". Do not change or remove this line.
        printFirst();
    }

    void second(function<void()> printSecond) {
        
        // printSecond() outputs "second". Do not change or remove this line.
        printSecond();
    }

    void third(function<void()> printThird) {
        
        // printThird() outputs "third". Do not change or remove this line.
        printThird();
    }
};

三个不同的线程 A、B、C 将会共用一个 Foo 实例。

  • 线程 A 会调用 first() 方法;

  • 线程 B 会调用 second() 方法;

  • 线程 C 会调用 third() 方法;

请设计修改程序,以确保 second() 方法在 first() 方法之后被执行,third() 方法在 second() 方法之后被执行。

示例 1:

输入:nums = [1,2,3]

输出:"firstsecondthird"

解释:

有三个线程会被异步启动。输入 [1,2,3] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 second() 方法,线程 C 将会调用 third() 方法。正确的输出是 "firstsecondthird"。

示例 2:

输入:nums = [1,3,2]

输出:"firstsecondthird"

解释:

输入 [1,3,2] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 third() 方法,线程 C 将会调用 second() 方法。正确的输出是 "firstsecondthird"。

(来源:Leecode)


如果说互斥锁(Mutex)的本质是"排他性"(保护资源不被同时访问), 那么信号量(Semaphore)的本质就是"许可证管理"。

让我们再次运用第一性原理,从零推导信号量的解法。

https://blog.csdn.net/2402_88047672/article/details/156681074?fromshare=blogdetail&sharetype=blogdetail&sharerId=156681074&sharerefer=PC&sharesource=2402_88047672&sharefrom=from_link


回归本质------什么是信号量?

从第一性原理出发,信号量其实就是一个简单的计数器,它只有两个核心原子操作:

  1. 等待 (Wait / P 操作)
  • 如果计数器 > 0,就把计数器减 1,然后通过。

  • 如果计数器 == 0,就停下来等,直到计数器变成大于 0。

  1. 释放 (Signal / Post / V 操作)
  • 把计数器加 1。

  • 如果有线程在等,就叫醒它。

推论: 如果我们把信号量的初始值设为 0,那么任何试图进行"等待"操作的线程都会立刻被卡住。只有当另一个线程执行了"释放"操作,把 0 变成 1 时,被卡住的线程才能拿到这张"许可证"继续前进。


构建依赖链条

我们要实现:first -> second -> third

  1. 分析 second :它需要一张来自 first 的许可证。

  2. 分析 third :它需要一张来自 second 的许可证。

  3. 分析初始状态

  • second 对应的许可证初始应该是 0 (没完成 first 前不准进)。

  • third 对应的许可证初始应该是 0 (没完成 second 前不准进)。


C++ 代码实现

在 C++20 中,标准库终于引入了 <semaphore>。我们使用 std::binary_semaphore(二进位信号量),它的值只有 0 和 1,非常适合这种"点对点"通知。

1. 定义工具

我们需要两个信号量来充当"接力棒"。

cpp 复制代码
#include <semaphore>

class Foo {
private:
    // 初始值都设为 0
    std::binary_semaphore sem1to2{0};
    std::binary_semaphore sem2to3{0};

public:
    Foo() {}

2. first 的逻辑:我是发令枪

first 不需要等任何人,它执行完后负责"投递"第一张许可证。

cpp 复制代码
void first(function<void()> printFirst) {
        // printFirst() outputs "first".
        printFirst();

        // 完成后,给 sem1to2 加 1,释放许可证
        sem1to2.release(); 
    }

3. second 的逻辑:承上启下

second 必须先拿到 first 投递的许可证,执行完后再给 third 投递一张。

cpp 复制代码
void second(function<void()> printSecond) {
        // 试图拿证,如果 first 没写完,这里就是 0,会阻塞等待
        sem1to2.acquire(); 

        printSecond();

        // 完成后,释放下一张证给 third
        sem2to3.release();
    }

4. third 的逻辑:终点站

它只管盯着 second 的许可证。

cpp 复制代码
void third(function<void()> printThird) {
        // 等待来自 second 的许可证
        sem2to3.acquire();

        printThird();
    }
};

信号量 vs. 互斥锁

为什么这道题用信号量看起来比 std::condition_variable 简洁这么多?

  1. 无状态化
  • 在使用互斥锁时,我们需要额外维护一个 int step 变量来记录进度。

  • 信号量本身就自带状态(计数器的值),它把"锁"和"条件判断"合二为一了。

  1. 解耦
  • 互斥锁通常要求"谁上锁谁解锁"。

  • 信号量没有"所有权"概念。线程 A 可以 release(增加),而线程 B 执行 acquire(减少)。这种跨线程的信号传递是信号量的天职。


补充:std::binary_semaphore

既然我们已经用"许可证"的逻辑推导出了信号量的解法,那么 std::binary_semaphore 就是 C++20 正式引入的、专门为了解决这种"二元通知"问题的利器。

从第一性原理来看,binary_semaphore 是对"状态同步"最纯粹的抽象。

它的本质:只有 0 和 1 的计数器

在 C++ 中,std::binary_semaphore 实际上是 std::counting_semaphore<1> 的一个别名。

  • 核心约束: 它的计数器值只能是 0 或 1。

  • 物理类比: 它就像一个次只能放一个球的漏斗。如果里面已经有一个球了(值为 1),你再放也放不进去;如果里面没球(值为 0),想拿球的人就得在那等。


两个核心操作

在 C++ 里,它不再叫 P/V 操作,也不叫 Lock/Unlock,而是叫:

acquire() (获取/等待)

  • 逻辑: "我要拿走那个唯一的许可证。"

  • 行为: * 如果当前值是 1,把它变成 0,线程继续执行。

    • 如果当前值是 0,线程阻塞(挂起),直到有人把它变成 1。

release() (释放/增加)

  • 逻辑: "我把许可证还回去(或者新发一张证)。"

  • 行为: * 把值从 0 变成 1。

    • 如果此时有线程在 acquire() 那里等着,那个线程会被唤醒。

为什么它比 Mutex 更适合"同步"?

这是一个非常关键的底层逻辑差异:

| 特性 | std::mutex | std::binary_semaphore |
| 所有权 | 有。谁上锁,必须由谁解锁。 | 无。线程 A 可以在这里等,线程 B 可以在那边发证。 |
| 初始状态 | 默认是开着的(Unlocked)。 | 你可以自由指定。通常设为 0 来拦截线程。 |

语义 保护一段资源不被同时访问。 协调两个动作之间的先后顺序。

解决"虚假唤醒"的天生优势

还记得我们在讲 condition_variable 时提到的"虚假唤醒"吗?我们需要写一个 while 循环或传递一个 lambda 表达式来反复检查条件。

信号量不需要。

信号量的操作是由操作系统内核保证原子性的。如果 acquire() 返回了,那一定是因为计数器确实变成了 1 且被你成功减到了 0。它内部自带了"记忆",不需要你额外写 step == 2 这样的逻辑判断。


在 Foo 题中的具体应用细节

回到我们的代码片段:

cpp 复制代码
// 初始值为 0,意味着"门关着"
std::binary_semaphore sem1to2{0}; 

void first(...) {
    printFirst();
    sem1to2.release(); // 门开了,信号量变 1
}

void second(...) {
    sem1to2.acquire(); // 拿走信号量,变回 0。如果此时它还是 0,我就在这儿等。
    printSecond();
}

这里有一个第一性原理的微妙之处:它是如何处理"抢跑"的?

  • 如果 second 抢跑了:它执行 acquire(),发现是 0,乖乖睡觉。

  • 如果 first 先跑完了:它执行 release(),信号量变 1。稍后 second 跑过来执行 acquire(),发现是 1,瞬间通过,完全不会被阻塞。

这种"状态被保留"的特性,让它在处理这类异步时序问题时,比条件变量更直观。

std::binary_semaphore 是 C++ 给我们的一种轻量级、无所有权的信号传递工具

  • 如果你想保护一个变量不被同时改写,用 Mutex

  • 如果你想让线程 A 等着线程 B 完成某个任务,用 Semaphore

相关推荐
2301_773730312 分钟前
系统编程—在线商城信息查询系统
c++·html
郝学胜-神的一滴3 分钟前
深入理解Linux中的Try锁机制
linux·服务器·开发语言·c++·程序人生
散峰而望44 分钟前
【算法竞赛】顺序表和vector
c语言·开发语言·数据结构·c++·人工智能·算法·github
cpp_25011 小时前
B3927 [GESP202312 四级] 小杨的字典
数据结构·c++·算法·题解·洛谷
Cx330❀1 小时前
《C++ 递归、搜索与回溯》第2-3题:合并两个有序链表,反转链表
开发语言·数据结构·c++·算法·链表·面试
小六子成长记1 小时前
【C++】:多态的实现
开发语言·c++
chen_2271 小时前
动态桌面方案
c++·qt·ffmpeg·kanzi
liulilittle1 小时前
OPENPPP2 Code Analysis Three
网络·c++·网络协议·信息与通信·通信
꧁Q༒ོγ꧂1 小时前
算法详解(一)--算法系列开篇:什么是算法?
开发语言·c++·算法
橘颂TA1 小时前
【剑斩OFFER】算法的暴力美学——力扣:1047 题:删除字符串中的所有相邻重复项
c++·算法·leetcode·职场和发展·结构于算法