OPC Client第12讲【C++并发与多线程2】:数据共享问题;互斥量死锁;日志单例模式补充;condition_variable(生产者与消费者模型);跨平台线程池
一、互斥量解决多线程数据共享问题
3. 互斥量解决多线程数据共享问题_哔哩哔哩_bilibili
所有笔记见下:
1、数据共享问题分析

按道理来说上述代码运行结束后输出是20000,但是输出结果如下图。
问题产生的原因:由于a变量是全局变量,因此在两个线程中共享。对于这种共享的情况,需要使用互斥量等同步机制来确保多个线程之间对共享数据的访问是安全的。如果不使用同步机制,就会出现数据竞争问题,导致得到错误的结果。

2、数据共享问题对象:任何被多个线程同时访问(尤其是同时读写)的共享数据
1>全局变量和静态变量
这是最典型的共享数据。

2>堆上分配的动态内存:通过 new 或 malloc 分配的对象,如果指针被多个线程共享,也会导致数据竞争

3>类的成员变量(当对象被多个线程操作时)
如果一个对象实例被多个线程同时访问,其成员变量就是共享数据。

4>函数中的静态局部变量

3、如何避免数据竞争?【解决1、】——互斥量(mutex)

1>互斥量
2>lock()
3>unlock()
二、互斥量死锁
- 获取**的所有权= 对**进行加锁


如上图,运行代码后无法输出任何东西。为什么?
如果两个线程同时执行,就会出现死锁问题。
- 因为 func_1获取了m1的所有权,但是无法获取m2的所有权;
- 而 func_2 获取了m2的所有权,但是无法获取mtx1的所有权;
- 两个线程互相等待对方释放互斥量,导致死锁。
2、解决方案:让两个线程按照相同的顺序获取互斥量的所有权
即func_1和func_2内部的代码完全一样
三、std::lock_guard 与 std::unique_lock
5.lock_guard与unique_lock_哔哩哔哩_bilibili
5、C++11 lock_guard 与 std::unique_lock
1、std::lock_guard
不需要手动加锁和解锁。
-
当构造函数被调用时,该互斥量会被自动锁定。
-
当析构函数被调用时,该互斥量会被自动解锁。
std::lock_guard对象不能复制或移动,因此它只能在局部作用域中使用。

2、std::unique_lock【推荐,1、能做的它都能】
自动加解锁【同1、】
比1、多的功能:延迟加锁、条件变量、超时加锁等
1>成员函数
查看std::unique_lock源码,如下图。

1》defer_lock_t:构造但是不加锁【如上图】
必须自己手动加锁,如下图。

2》延迟加锁try_lock_for(duration):尝试在指定时间内获取锁。
- 如果成功,返回
true,当前线程持有锁; - 如果超时仍未获得锁,返回
false,不会阻塞。
源码看上上图。
如下图,为什么输出是3?


总结:
最开始t1成功在1s内获取锁,等待两秒。耗时只需要两秒!
等待的2s过程中,t2在1s内无法获取锁,直接 +1(无锁!)
3》try_lock_until:类似2》,这里是等待到一个具体的时间点
2>构造函数:unique_lock()其本身的一些性质
查看其源码和文档
四、call_once:用在单例模式【例如日志记录。更简便的方法:直接使用饿汉模式】
OPC Client第7讲(wxwidgets):Logger.h日志记录文件(单例模式);登录后的主界面_wxwidgets 单例模式-CSDN博客
.NET6 WebApi第3讲:控制反转(IOC)和依赖注入(DI)、依赖倒置、服务(如何使用?三种生命周期)、typeof()、Autofac(增强IOC容器)_net6 ioc-CSDN博客
1、单例模式
1>定义
- 整个应用程序生命周期内只创建一次实例。
- 创建对象【强调的是过程】 = 实例化一个类 → 得到一个实例【更偏向于结果】
- 所有后续请求(包括不同 HTTP 请求、不同线程)都共享同一个实例。
2>为什么日志记录用单例模式?

3>单例模式的经典实现方式:“懒汉模式”和“饿汉模式”
禁止实例复制和=,以保证单例模式,如下图

1》懒汉模式:一次调用 GetInstance() 时才创建对象
- 第一次调用 GetInstance() 时才创建对象。
- 节省资源(按需创建)。
- 需要考虑线程安全问题(多线程环境下可能重复创建)。
- 假设线程 A 和线程 B 几乎同时调用 G
etInstance(),此时log还是nullptr。-
步骤分解(非原子):
- 线程 A 执行
if (!log)→ 判断为 true。- 但是还没有来得及执行log = new Log;
- 线程 B 也执行
if (!log)→ 同样判断为 true(因为 A 还没来得及赋值)。 - 线程 A 执行
new Log(),把地址赋给instance。 - 线程 B 也执行
new Log(),再次创建对象,并覆盖instance。
- 线程 A 执行
-
结果:
- 创建了两个 Singleton 对象(违反单例原则)。
- 可能发生内存泄漏(第一个对象的指针被覆盖,无法释放)。
- 指令重排序(更隐蔽的问题)
- 即使在单线程中,编译器或 CPU 可能将
new Log()拆分为:- 1 分配内存
- 2 将指针赋给
instance - 3 调用构造函数
- 若步骤 2 在 3 之前完成,其他线程可能拿到一个“未完全构造”的对象。
- 即使在单线程中,编译器或 CPU 可能将
-
- 假设线程 A 和线程 B 几乎同时调用 G

《1》解决懒汉模式产生的线程安全问题:2、
2》【推荐】饿汉模式:类加载时就立即创建实例【师傅代码的日志就是用的饿汉模式】
- 程序启动时就创建单例对象(即类加载时就初始化)。
- “饿”得等不及,一开始就吃
- 线程安全(因为对象在 main 函数执行前就已创建,不存在多线程竞争问题)。
- C++ 标准规定:所有具有静态存储期的对象(如全局变量、静态成员)必须在 main() 函数执行前完成初始化。
- 此时还没有任何用户线程被创建(主线程都还没正式“开始”逻辑执行),因此不存在并发竞争。
- C++第2讲:核心编程(面向对象);(结合内存四区)静态成员;职工管理系统-CSDN博客
- 缺点:如果该单例对象从未被使用,会造成资源浪费。

2、std::call_once:解决1》懒汉模式产生的线程安全问题
- 配合
std::once_flag,确保某段代码在整个程序生命周期中只执行一次。 - 线程安全,由标准库保证原子性和内存顺序。
- 只能在线程函数中使用,在main函数中去调用会报错。
6、 C++11 std::call_once 与其使用场景
- std::call_once具体用法见此文档
- 文档中“单例模式的线程安全问题”有误,第一段代码是饿汉模式,本身就是线程安全的,无需额外使用
std::call_once。
6.call_once与其使用场景_哔哩哔哩_bilibili


五、condition_variable使用场景
7 、C++11 condition_variable 使用场景
7.condition_variable与其使用场景_哔哩哔哩_bilibili
1、生产者与消费者模型

2、condition_variable
std::condition_variable 主要用于 线程间同步,尤其适用于以下场景:
- 生产者-消费者模型(如上图)
- 任务队列调度:工作线程等待任务到来
- 线程池中的空闲唤醒
- 事件驱动的线程通信:一个线程等待某个条件成立,另一个线程在条件满足时通知它
⚠️ 注意:
std::condition_variable必须配合std::mutex使用,且只能与std::unique_lock<std::mutex>一起使用(这是 C++ 标准的要求)。
3、实现生产-消费者模型
1>基本结构
如下图所示,假设生产者生产10个任务,消费者不断从队列里取任务。
- 此刻共享变量队列可能会产生问题:消费者取的同时生产者正好在放。所以需要加上互斥锁,如下下图。

2>加上互斥锁

3>通过condition_variable通知线程、等待线程
7 、C++11 condition_variable 使用场景



六、跨平台线程池
使用线程池的目的:
- 线程池提前维护一个线程的数组和队列,不停地让线程去完成队列里的任务。
为什么要使用线程池?
- 减少线程创建/销毁的开销。所以提前开辟好一堆线程,专门等着任务进来去完成任务,这样可以提高效率。
1、什么是线程池?
如下图,左边池子就是线程池。

线程池的核心思想是:
- 复用线程:避免频繁创建/销毁线程带来的开销。
- 控制并发数量:限制同时运行的线程数,防止资源耗尽。
- 任务队列机制:未立即执行的任务被放入队列,由空闲线程取出执行。
在下述链接实现中:
- 使用
std::vector<std::thread>存储工作线程; - 使用
std::queue<std::function<void()>>作为任务队列; - 通过
std::mutex和std::condition_variable实现线程安全的任务调度; - 所有线程在后台循环等待任务,直到线程池被析构。
2、为什么要使用线程池?
1>减少线程创建/销毁的开销
- 创建线程涉及系统调用、分配内核资源(如栈空间),开销较大。
- 频繁创建短生命周期线程会导致性能下降。
- 线程池复用已有线程,显著提升效率。
2> 控制资源使用,防止系统过载
- 若不限制并发线程数(例如每来一个请求就开一个线程),可能导致:
- 内存耗尽(每个线程默认占用几 MB 栈空间);
- CPU 过度上下文切换,降低吞吐量。
- 线程池通过固定或可配置的线程数,实现可控并发。
3>提高响应速度
- 任务提交后可立即由空闲线程执行,无需等待线程创建。
4>便于任务管理和调度
- 支持异步任务提交(如
enqueue); - 可扩展支持优先级队列、定时任务、拒绝策略等高级功能。


3、实现线程池
1>threads.emplace_back(...) 相比 threads.push_back(...) 更节省资源
主要原因在于避免了不必要的临时对象构造和拷贝(或移动)操作。


更多推荐


所有评论(0)