前言:欢迎 各位光临本博客,这里小编带你直接手撕**,文章并不复杂,愿诸君**耐其心性,忘却杂尘,道有所长!!!!

IF'Maxue :个人主页
🔥 个人专栏 :
《C语言》
《C++深度学习》
《Linux》
《数据结构》
《数学建模》
⛺️生活是默默的坚持,毅力是永久的享受。不破不立!
文章目录
-
- 深入理解线程池与单例模式:线程安全的设计与实践
-
- 一、线程池的退出机制
-
- [1. 线程的三种状态](#1. 线程的三种状态)
- [2. 线程池的退出条件](#2. 线程池的退出条件)
- [3. 唤醒休眠线程](#3. 唤醒休眠线程)
- [4. 线程池的暂停与恢复](#4. 线程池的暂停与恢复)
- [5. 线程池的完整生命周期](#5. 线程池的完整生命周期)
- [6. 线程池的使用示例](#6. 线程池的使用示例)
- 二、单例模式:确保唯一实例
-
- [1. 单例模式的应用场景](#1. 单例模式的应用场景)
- [2. 饿汉式实现](#2. 饿汉式实现)
- [3. 懒汉式实现(线程不安全)](#3. 懒汉式实现(线程不安全))
- [4. 线程安全的懒汉式单例(双检锁模式)](#4. 线程安全的懒汉式单例(双检锁模式))
- [5. 单例模式的使用](#5. 单例模式的使用)
- [6. 单例模式的核心思想](#6. 单例模式的核心思想)
- 三、线程安全与重入
-
- [1. 线程安全 (Thread-Safe)](#1. 线程安全 (Thread-Safe))
- [2. 可重入 (Reentrant)](#2. 可重入 (Reentrant))
- [3. 两者关系](#3. 两者关系)
- 四、死锁 (Deadlock)
-
- [1. 死锁的定义](#1. 死锁的定义)
- [2. 死锁的例子](#2. 死锁的例子)
- [3. 死锁的四个必要条件](#3. 死锁的四个必要条件)
- [4. 如何避免死锁](#4. 如何避免死锁)
- 五、STL、智能指针与线程安全
-
- [1. STL容器的线程安全](#1. STL容器的线程安全)
- [2. 智能指针的线程安全](#2. 智能指针的线程安全)
深入理解线程池与单例模式:线程安全的设计与实践
在多线程编程中,线程池和单例模式是两个非常基础且重要的概念。线程池用于高效地管理和复用线程,而单例模式则确保一个类只有一个实例。本文将结合代码和图示,详细解析线程池的退出机制、单例模式的实现以及线程安全相关的问题。
一、线程池的退出机制
线程池中的线程在运行过程中会处于不同的状态,当我们需要关闭线程池时,必须妥善处理这些线程,以确保程序的健壮性和资源的正确释放。
1. 线程的三种状态
线程池中的线程主要有以下三种状态:
- 等待状态 (Waiting):线程正在等待任务队列中有新的任务到来。
- 等待唤醒状态 (Waiting to be awakened):线程因某些条件(如任务队列空)而被阻塞,需要等待一个信号来唤醒。
- 处理任务状态 (Processing a task):线程正在执行一个具体的任务。
2. 线程池的退出条件
一个设计良好的线程池,其退出应该满足以下核心条件:
- 线程池已发出退出信号 (例如,设置一个
is_running标志为false)。 - 任务队列已为空。
只有当这两个条件同时满足时,线程池中的所有线程才能安全地退出。

如上图所示,线程在循环中检查这两个条件。如果线程池正在运行(is_running 为 true)且任务队列为空,线程会进入等待状态。
3. 唤醒休眠线程
当我们决定停止线程池时,仅仅设置 is_running 为 false 是不够的。因为此时可能有多个线程正处于等待(休眠)状态,它们永远不会再去检查 is_running 标志。
因此,在停止线程池时,必须主动唤醒所有正在等待的线程。这通常通过条件变量(Condition Variable)的 notify_all() 方法来实现。

被唤醒的线程会再次检查退出条件。此时 is_running 为 false,并且如果任务队列也为空,线程就会执行退出逻辑。
4. 线程池的暂停与恢复
除了退出,线程池有时也需要支持暂停和恢复功能。
- 暂停:当线程池暂停时,正在执行任务的线程应该继续执行完当前任务,而处于等待状态的线程则应该保持休眠,不再获取新任务。
- 恢复:恢复操作则是唤醒所有等待的线程,让它们可以继续从任务队列中获取任务执行。

实现暂停和恢复的关键在于,在线程的主循环中增加对"暂停"标志的检查。

如上图所示,线程在每次循环开始时,会先检查是否处于暂停状态,如果是,则等待恢复信号。
5. 线程池的完整生命周期
将上述逻辑整合起来,线程池的完整生命周期管理(运行、暂停、恢复、停止)可以用下面的流程图来表示。

6. 线程池的使用示例
下面是一个简单的 main 函数,展示了如何创建和使用线程池。
cpp
#include "ThreadPool.h"
#include <iostream>
#include <chrono>
void task_func(int id) {
std::cout << "Thread " << std::this_thread::get_id() << " is processing task " << id << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟任务执行
std::cout << "Task " << id << " completed." << std::endl;
}
int main() {
// 创建一个拥有4个线程的线程池
ThreadPool pool(4);
// 向线程池添加10个任务
for (int i = 0; i < 10; ++i) {
pool.add_task(task_func, i);
}
std::cout << "All tasks have been added to the pool." << std::endl;
// 主线程等待一段时间,让任务有机会执行
std::this_thread::sleep_for(std::chrono::seconds(3));
std::cout << "Pausing the thread pool..." << std::endl;
pool.pause(); // 暂停线程池
// 暂停期间添加的任务会被放入队列,但不会被执行
pool.add_task(task_func, 100);
pool.add_task(task_func, 101);
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Resuming the thread pool..." << std::endl;
pool.resume(); // 恢复线程池
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Stopping the thread pool..." << std::endl;
pool.stop(); // 停止线程池
std::cout << "Main thread exiting." << std::endl;
return 0;
}

二、单例模式:确保唯一实例
单例模式是一种创建型设计模式,它保证一个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取这个实例。
1. 单例模式的应用场景
单例模式通常用于管理共享资源,例如:
- 配置文件管理器:整个程序只需要一个配置实例。
- 日志系统:所有日志输出都应该通过同一个日志实例。
- 数据库连接池:全局只需要一个连接池来管理所有数据库连接。
- 线程池:如我们上一节所述,线程池通常也设计为单例。
2. 饿汉式实现
饿汉式单例在类被加载时就立即创建实例。
优点:
- 实现简单。
- 天然是线程安全的,因为实例在程序启动时就已创建,不存在多线程竞争。
缺点:
- 无论实例是否被使用,它都会被创建,可能会造成资源浪费。

3. 懒汉式实现(线程不安全)
懒汉式单例在第一次被使用时才创建实例,实现了"延迟加载"。
优点:
- 节约资源,实例在需要时才创建。
缺点:
- 实现相对复杂,尤其是在多线程环境下。
- 非线程安全。

问题分析 :当多个线程同时调用 getInstance() 方法时,都可能通过 instance == nullptr 的检查,从而创建多个实例,这违反了单例模式的核心原则。
4. 线程安全的懒汉式单例(双检锁模式)
为了解决懒汉式的线程安全问题,我们需要引入同步机制。
实现步骤:
-
私有化构造函数、拷贝构造函数和赋值运算符 ,防止外部创建新实例或拷贝。
cppprivate: ThreadPool() {} ThreadPool(const ThreadPool&) = delete; ThreadPool& operator=(const ThreadPool&) = delete; -
使用静态指针
instance来持有唯一实例。 -
使用静态互斥锁
mutex来保护实例的创建过程。 -
双重检查锁定(Double-Checked Locking, DCL) :
- 第一次检查
instance是否为nullptr,如果不为空,直接返回,避免每次都加锁,提高性能。 - 如果为空,则加锁。
- 加锁后,再次检查
instance是否为nullptr。这是因为在第一次检查和加锁之间,可能有另一个线程已经创建了实例。 - 第二次检查通过后,才创建实例。
- 第一次检查



5. 单例模式的使用
一旦单例模式实现,就可以在程序的任何地方通过 getInstance() 方法来获取唯一的实例。
cpp
#include "ThreadPool.h"
int main() {
// 获取线程池的唯一实例
ThreadPool& pool = ThreadPool::getInstance();
// 使用线程池
pool.add_task(...);
// ...
return 0;
}

6. 单例模式的核心思想
总结一下,单例模式的核心就是通过一系列机制,强制一个类只能有一个实例,并提供一个统一的访问入口。



当线程池本身被设计为单例时,多线程环境下对它的访问就变得非常自然和安全。

三、线程安全与重入
在多线程编程中,"线程安全"和"可重入"是两个紧密相关但又有所区别的概念。
1. 线程安全 (Thread-Safe)
一个函数或数据结构被称为线程安全的,如果它可以被多个线程同时调用或访问,而不会导致数据竞争、死锁或其他未定义行为。
简单来说:多个线程同时操作同一个对象或函数,结果是正确的、可预测的。
例如,一个线程安全的计数器,多个线程同时调用 increment() 方法,最终的计数值应该是正确的累加结果。要实现线程安全,通常需要使用互斥锁(mutex)来保护临界区代码。

在单例模式的 getInstance() 方法中,我们使用互斥锁来确保实例创建的线程安全。

2. 可重入 (Reentrant)
一个函数被称为可重入的,如果它可以被同一个执行流(线程)在没有完成第一次调用的情况下再次调用,并且不会产生错误。
简单来说:函数可以"打断自己",然后安全地再次进入。
可重入性通常要求函数不使用任何非const的静态或全局变量,所有状态都通过参数传递或存储在调用栈上。

3. 两者关系
- 一个可重入的函数不一定是线程安全的。例如,一个函数不使用全局变量,但使用了一个共享的、非线程安全的数据结构。它可以被同一个线程重入,但不能被多个线程同时调用。
- 一个线程安全的函数不一定是可重入的。例如,一个函数使用了全局锁。多个线程可以同时调用它(因为锁保证了互斥),但同一个线程如果在持有锁的情况下再次调用自己,就会导致死锁,因此它不是可重入的。
- 一个函数可以既是线程安全的,又是可重入的。这通常是最高的要求,意味着函数的设计非常健壮。
结论:可重入性是比线程安全性更严格的要求。

四、死锁 (Deadlock)
死锁是多线程编程中一个经典且棘手的问题。
1. 死锁的定义
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的状态。若无外力作用,它们都将无法继续执行下去。
2. 死锁的例子
一个经典的例子是"哲学家进餐问题"。这里我们用一个更简单的场景来说明:
假设有两个线程,线程A和线程B,它们都需要同时持有锁1和锁2才能完成任务。
- 线程A成功获取了锁1。
- 几乎同时,线程B成功获取了锁2。
- 接下来,线程A尝试获取锁2,但锁2已被线程B持有,所以线程A阻塞。
- 线程B尝试获取锁1,但锁1已被线程A持有,所以线程B也阻塞。
此时,线程A和线程B都在等待对方释放自己需要的锁,它们将永远等待下去,这就是死锁。

另一个生活中的例子:

3. 死锁的四个必要条件
死锁的发生必须同时满足以下四个条件,缺一不可:
- 互斥条件:资源必须是互斥使用的,即同一时间只能有一个线程占用。
- 请求与保持条件:线程已经持有了至少一个资源,但又提出了新的资源请求,而这个资源已被其他线程占用。
- 不可剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺。
- 循环等待条件:存在一个线程等待序列,使得每个线程都在等待下一个线程所持有的资源,形成一个闭环。


4. 如何避免死锁
避免死锁的关键在于破坏死锁的四个必要条件中的任意一个。在实践中,最常用且最有效的方法是破坏"循环等待条件"。
方法:按序申请资源
规定所有线程在获取多个资源时,必须按照一个固定的、全局一致的顺序来申请。
在上面的例子中,如果我们规定"必须先获取锁1,再获取锁2":
- 线程A获取锁1。
- 线程B尝试获取锁1,但锁1已被线程A持有,所以线程B阻塞。
- 线程A获取锁2,执行任务,完成后释放锁2和锁1。
- 线程B被唤醒,成功获取锁1,然后获取锁2,执行任务。
这样就避免了死锁。

其他避免死锁的方法还包括:
- 超时等待:当线程尝试获取锁时,设定一个超时时间。如果在超时时间内没有获取到锁,就放弃并释放已持有的资源,然后重试。
- 银行家算法:一种比较复杂的算法,用于在分配资源前进行安全性检查,确保不会进入死锁状态。
五、STL、智能指针与线程安全
C++标准库(STL)和智能指针在多线程环境下的行为是一个常见的误区。
1. STL容器的线程安全
结论:STL容器本身不是线程安全的。
这意味着:
- 你不能从多个线程同时对同一个容器进行写入操作(如
push_back,erase,operator[]等)。 - 你也不能在一个线程写入的同时,从另一个线程读取。
如果你需要在多线程中共享一个STL容器,你必须自己提供同步机制,例如使用 std::mutex 来保护对容器的所有访问。
2. 智能指针的线程安全
智能指针的线程安全特性与STL容器不同,需要具体分析。
-
std::shared_ptr:- 引用计数的修改是线程安全的 。也就是说,多个线程可以同时对同一个
shared_ptr对象进行拷贝、赋值或销毁操作,这会原子地增加或减少其内部的引用计数。 - 指向对象的访问不是线程安全的 。如果你有多个线程通过
shared_ptr访问同一个对象,你仍然需要自己保证对该对象操作的线程安全。
- 引用计数的修改是线程安全的 。也就是说,多个线程可以同时对同一个
-
std::unique_ptr:- 由于
unique_ptr不允许拷贝,其所有权的转移是通过std::move实现的。 - 它的线程安全特性类似于一个普通的指针。你不应该从多个线程同时对同一个
unique_ptr对象进行操作(如reset,release或移动赋值)。
- 由于

总结:
- 不要假设STL容器是线程安全的,必须手动加锁保护。
std::shared_ptr的引用计数是线程安全的,但它指向的对象不是。std::unique_ptr不是线程安全的。