【Linux系统】单例式线程池
现在,我们将基于之前完成的封装来设计一个线程池。在正式编码前,需要做好以下准备工作:
- 完成线程的基本封装
- 实现锁和条件变量的封装
- 引入日志系统,完善线程功能封装
这些准备工作我们已经做完了,下面我们就来设计一个线程池
1. 线程池概念
核心概念与产生背景
线程池是一种基于池化技术(Pooling)管理线程的并发编程模型。其核心思想是:预先创建好一定数量的线程,放入一个“池子”中统一管理。当有任务需要执行时,不是直接创建一个新线程,而是从池中获取一个空闲线程来执行任务;任务执行完毕后,线程并不立即被销毁,而是返回池中等待执行下一个任务。
产生背景:
在早期并发模型中,“即时创建,即时销毁”的线程生命周期管理方式存在显著瓶颈:
资源消耗大:线程的创建和销毁是昂贵的操作,涉及操作系统内核的调用、内存分配、资源初始化等。频繁操作会消耗大量系统资源。
响应延迟高:当任务到达时,需要先等待线程创建完毕才能执行,增加了任务的响应时间。
系统稳定性风险:无限制地创建线程会耗尽系统资源(如内存、CPU时间片)。每个线程都需要占用一定的内存(如JVM中每个线程有自己的栈空间),大量线程会导致内存溢出(OOM),且过多的线程上下文切换会加剧CPU负载,导致系统效率急剧下降甚至崩溃。
线程池技术通过复用线程、控制并发数量、统一管理生命周期,完美地解决了上述问题,成为了高并发应用不可或缺的基础组件。
核心优势与价值
降低资源消耗:通过重复利用已创建的线程,极大地减少了线程频繁创建和销毁所造成的系统开销。
提高响应速度:当任务到达时,无需等待线程创建,任务可以立即由空闲线程执行,减少了任务执行的延迟。
提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会降低系统的稳定性。线程池允许对线程进行统一的分配、调优和监控。例如,可以控制线程的最大并发数,防止过度调度;可以监控线程的运行状态,进行任务队列的管理等。
提供更强大的功能:线程池提供了丰富的扩展点,例如支持定时、延时、周期性的任务执行(如
ScheduledThreadPoolExecutor
)。
线程池的典型应用场景
1. 高并发短任务处理
典型示例:Web服务器请求处理
- 特点:单个请求处理时间短(通常<100ms),但请求量巨大
- 优势:避免了为每个请求创建新线程的开销
- 实际案例:Apache Tomcat默认使用线程池处理HTTP请求
2. 实时性要求高的应用
典型示例:金融交易系统
- 特点:需要极低延迟(通常要求<10ms响应)
- 优势:线程池中的线程始终处于就绪状态,可以立即处理任务
- 实现方式:通常配合工作队列和优先级调度机制
3. 突发流量处理
典型示例:电商秒杀活动
- 特点:短时间内请求量激增(可能达到平时100倍)
- 优势:通过限制最大线程数防止系统过载
- 保护机制:当请求超过处理能力时,可以采用拒绝策略
不适合的场景:
- 长时间任务(如Telnet会话):任务执行时间远超过线程创建时间,线程池优势不明显 。
线程池类型详解
1. 固定大小线程池(FixedThreadPool)
实现原理:
- 创建时指定固定数量的线程
- 使用无界队列保存待处理任务
- 线程空闲时不会被回收
适用场景:
- 需要严格控制资源使用的场景
- 任务量可预测的长期运行服务
- 示例:数据库连接池
特点:
- 优点:实现简单,资源消耗可控
- 缺点:任务堆积可能导致内存溢出
2. 可伸缩线程池(CachedThreadPool)
实现原理:
- 核心线程数为0,最大线程数为Integer.MAX_VALUE
- 使用同步移交队列
- 空闲线程60秒后自动回收
适用场景:
- 执行大量短生命周期的异步任务
- 示例:并行计算任务
特点:
- 优点:弹性伸缩,适应突发流量
- 缺点:线程数无限制可能导致资源耗尽
此处,我们选择固定线程个数的线程池。
2. 实现线程池
2.1 线程池框架
这里我们实现线程池时,使用5个固定数量的线程
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include "Log.hpp"
#include "Cond.hpp"
#include "Thread.hpp"
#include "Mutex.hpp"namespace ThreadPoolModule
{using namespace ThreadModlue;using namespace LogModule;using namespace CondModule;using namespace MutexModule;static const int gnum = 5; // 预创建5个线程template <class T>class ThreadPool{public:ThreadPool(int num = gnum): _num(num){for (int i = 0; i < _num; i++){_threads.emplace_back([this](){HandlerTask();});}}void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 队列为空while (_taskq.empty()){_cond.Wait(_mutex);}// 从任务队列中取任务t = _taskq.front();_taskq.pop();}// 处理任务,不需要在临界区内部,为什么?t();}}void Start(){for(auto& thread : _threads){thread.Start();LOG(LogLevel::INFO) << "new thread start: " << thread.Name();}}~ThreadPool() {}private:std::vector<Thread> _threads; // 管理线程int _num; // 线程的数量std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;};
}
分析:
核心成员变量
_threads
:std::vector<Thread>
类型,存储和管理工作线程对象_num
:整数类型,记录线程池中的线程数量_taskq
:std::queue<T>
类型,作为任务队列,存储待处理的任务_cond
和_mutex
:条件变量和互斥锁,用于线程间同步和任务队列的线程安全访问
构造函数:构造函数接受一个整数参数num,表示线程池中线程的数量,默认值为gnum(5)。在构造函数中,我们创建了num个线程,并将每个线程的执行函数设置为HandlerTask(一个不断从任务队列中取任务并执行的函数)。这里使用了lambda表达式来包装HandlerTask。
成员函数HandlerTask:这是每个线程的工作函数。它在一个无限循环中不断从任务队列中取出任务并执行。在取任务时,需要先获取互斥锁,然后检查任务队列是否为空。如果为空,则调用条件变量的Wait方法等待;否则,从队列中取出一个任务,然后释放锁(通过LockGuard的作用域),接着执行任务。
注意:
在锁外执行任务处理(
t()
),避免任务执行时间过长阻塞其他线程
Start函数:启动所有线程。遍历线程向量,调用每个线程的Start方法,并打印日志。
对于成员函数HandlerTask,我们不想被外部调用,我们可以将其私有
2.2 线程池退出
我们新增一个成员变量,作为运行标志位,线程池运行时为true,停止为false
static const int gnum = 5; // 预创建5个线程template <class T>class ThreadPool{private:void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 队列为空while (_taskq.empty()){_cond.Wait(_mutex);}// 从任务队列中取任务t = _taskq.front();_taskq.pop();}// 处理任务,不需要在临界区内部,为什么?//t();}}public:ThreadPool(int num = gnum): _num(num),_isrunning(false){for (int i = 0; i < _num; i++){_threads.emplace_back([this](){HandlerTask();});}}void Start(){if(_isrunning)return;_isrunning = true;for(auto& thread : _threads){thread.Start();LOG(LogLevel::INFO) << "new thread start: " << thread.Name();}}void Stop(){if(!_isrunning)return;_isrunning = false;}~ThreadPool() {}private:std::vector<Thread> _threads; // 管理线程int _num; // 线程的数量std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;bool _isrunning; // 运行标志位};
成员函数HandlerTask,它在一个无限循环中不断从任务队列中取出任务并执行。
void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 队列为空或者while (_taskq.empty()){_cond.Wait(_mutex);}// 从任务队列中取任务t = _taskq.front();_taskq.pop();}// 处理任务,不需要在临界区内部,为什么?//t();}}
要么是在循环等任务,要么就是在执行任务,那我们要怎么退出呢?
我们先来分析一下,当我们的线程池退出时,也就是将运行标志位置为false,我们的线程处于什么状态呢?
可能是在等待,有可能在等待唤醒,也有可能在执行任务
所以我们要想线程池退出,不能只是简单的将所有线程停止或取消,我们应该让任务队列中的任务都被执行完了,并且运行标志位也被置为false,这时候才能让线程池退出,也就是说如果我们队列中还有任务,或者运行标志位为true,那我们就不能退出
void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 队列为空while (_taskq.empty()){_cond.Wait(_mutex);}// 线程被唤醒// 判断线程池是否退出——如果线程池要退出,并且任务队列为空就退出if(!_isrunning && _taskq.empty()){LOG(LogLevel::INFO) << name << "退出了,线程池想退出且任务队列为空";break;}// 从任务队列中取任务t = _taskq.front();_taskq.pop();}// 处理任务,不需要在临界区内部,为什么?//t();}}
但是如果我们的任务队列为空,此时所有线程都在条件变量Wait处等待唤醒,此时我们将线程池退出Stop,也就是将运行标志位置为false,那此时所有线程都会被阻塞在条件变量处休眠,等待被唤醒,那不就退出不了了吗?
所以我们线程池退出时还需要将那些在Wait的线程唤醒,判断条件也需要改,因为如果线程被唤醒,但是我们任务队列仍然为空,那就会再次进入循环继续Wait,但是我们线程池要退出呀,不能再让线程继续去Wait,所以还需要判断线程池是否退出
template <class T>class ThreadPool{private:void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 队列为空, 或者线程池没有退出,我们才需要waitwhile (_taskq.empty() && _isrunning){_sleepernum++;_cond.Wait(_mutex);_sleepernum--;}// 线程被唤醒// 判断线程池是否退出——如果线程池要退出,并且任务队列为空就退出if (!_isrunning && _taskq.empty()){LOG(LogLevel::INFO) << name << "退出了,线程池想退出且任务队列为空";break;}// 从任务队列中取任务t = _taskq.front();_taskq.pop();}// 处理任务,不需要在临界区内部,为什么?// t();}}void WakeUpAllThread(){LockGuard lockguard(_mutex);if (_sleepernum){_cond.Broadcast();LOG(LogLevel::INFO) << "唤醒所有线程";}}public:ThreadPool(int num = gnum): _num(num), _isrunning(false){for (int i = 0; i < _num; i++){_threads.emplace_back([this](){HandlerTask();});}}void Start(){if (_isrunning)return;_isrunning = true;for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "new thread start: " << thread.Name();}}void Stop(){if (!_isrunning)return;_isrunning = false;// 唤醒所有线程WakeUpAllThread();}~ThreadPool() {}private:std::vector<Thread> _threads; // 管理线程int _num; // 线程的数量std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;bool _isrunning; // 运行标志位int _sleepernum; // 线程休眠的数量};
在学习了线程控制章节后,我们知道线程退出后,需要join等待线程退出,这里我们也需要等待,代码如下:
void Join(){for(auto& thread : _threads){thread.Join();}}
下面我们先来测试一下:
#include "Log.hpp"
#include "ThreadPool.hpp"using namespace LogModule;
using namespace ThreadPoolModule;int main()
{Enable_Console_Log_Strategy();ThreadPool<int> *tp = new ThreadPool<int>();tp->Start();sleep(5);tp->Stop();tp->Join();return 0;
}
运行结果:
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_thread/ThreadPool$ ./threadpool
[2025-09-12 16:50:17] [DEBUG] [91391] [Thread.hpp] [71] - create thread success
[2025-09-12 16:50:17] [INFO] [91391] [ThreadPool.hpp] [90] - new thread start: thread-1
[2025-09-12 16:50:17] [DEBUG] [91391] [Thread.hpp] [71] - create thread success
[2025-09-12 16:50:17] [INFO] [91391] [ThreadPool.hpp] [90] - new thread start: thread-2
[2025-09-12 16:50:17] [DEBUG] [91391] [Thread.hpp] [71] - create thread success
[2025-09-12 16:50:17] [INFO] [91391] [ThreadPool.hpp] [90] - new thread start: thread-3
[2025-09-12 16:50:17] [DEBUG] [91391] [Thread.hpp] [71] - create thread success
[2025-09-12 16:50:17] [INFO] [91391] [ThreadPool.hpp] [90] - new thread start: thread-4
[2025-09-12 16:50:17] [DEBUG] [91391] [Thread.hpp] [71] - create thread success
[2025-09-12 16:50:17] [INFO] [91391] [ThreadPool.hpp] [90] - new thread start: thread-5
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [64] - 唤醒所有线程
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [45] - thread-2退出了,线程池想退出且任务队列为空
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [45] - thread-1退出了,线程池想退出且任务队列为空
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [45] - thread-3退出了,线程池想退出且任务队列为空
[2025-09-12 16:50:22] [DEBUG] [91391] [Thread.hpp] [109] - join success
[2025-09-12 16:50:22] [DEBUG] [91391] [Thread.hpp] [109] - join success
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [45] - thread-4退出了,线程池想退出且任务队列为空
[2025-09-12 16:50:22] [DEBUG] [91391] [Thread.hpp] [109] - join success
[2025-09-12 16:50:22] [INFO] [91391] [ThreadPool.hpp] [45] - thread-5退出了,线程池想退出且任务队列为空
[2025-09-12 16:50:22] [DEBUG] [91391] [Thread.hpp] [109] - join success
[2025-09-12 16:50:22] [DEBUG] [91391] [Thread.hpp] [109] - join success
2.3 任务入队列
那接下来就需要将任务入到任务队列中
bool Enqueue(const T& in){LockGuard lockguard(_mutex);// 如果线程池退出就不能再将任务入队列if(_isrunning){_taskq.push(in);// 有线程在休眠,就唤醒if(_sleepernum > 0){_cond.Signal();}return true;}return false;}
那我们再来个任务试试,就和之前进程间通信时的任务一样,这里我们就只用一个任务来测试
#pragma once#include <functional>
#include "Log.hpp"using namespace LogModule;// 定义了一个任务类型,返回值void,参数为空
using task_t = std::function<void()>;void Download()
{LOG(LogLevel::DEBUG) << "这是一个下载的任务...";
}
下面我们再来测试一下
#include "Log.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"using namespace LogModule;
using namespace ThreadPoolModule;int main()
{Enable_Console_Log_Strategy();ThreadPool<task_t> *tp = new ThreadPool<task_t>();tp->Start();int count = 10;while(count--){tp->Enqueue(Download);sleep(1);}tp->Stop();tp->Join();return 0;
}
运行结果:
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_thread/ThreadPool$ ./threadpool
[2025-09-12 17:31:52] [DEBUG] [92986] [Thread.hpp] [71] - create thread success
[2025-09-12 17:31:52] [INFO] [92986] [ThreadPool.hpp] [90] - new thread start: thread-1
[2025-09-12 17:31:52] [DEBUG] [92986] [Thread.hpp] [71] - create thread success
[2025-09-12 17:31:52] [INFO] [92986] [ThreadPool.hpp] [90] - new thread start: thread-2
[2025-09-12 17:31:52] [DEBUG] [92986] [Thread.hpp] [71] - create thread success
[2025-09-12 17:31:52] [INFO] [92986] [ThreadPool.hpp] [90] - new thread start: thread-3
[2025-09-12 17:31:52] [DEBUG] [92986] [Thread.hpp] [71] - create thread success
[2025-09-12 17:31:52] [INFO] [92986] [ThreadPool.hpp] [90] - new thread start: thread-4
[2025-09-12 17:31:52] [DEBUG] [92986] [Thread.hpp] [71] - create thread success
[2025-09-12 17:31:52] [INFO] [92986] [ThreadPool.hpp] [90] - new thread start: thread-5
[2025-09-12 17:31:52] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:53] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:54] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:55] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:56] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:57] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:58] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:31:59] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:32:00] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:32:01] [DEBUG] [92986] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [64] - 唤醒所有线程
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [45] - thread-1退出了,线程池想退出且任务队列为空
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [45] - thread-2退出了,线程池想退出且任务队列为空
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [45] - thread-3退出了,线程池想退出且任务队列为空
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [45] - thread-4退出了,线程池想退出且任务队列为空
[2025-09-12 17:32:02] [INFO] [92986] [ThreadPool.hpp] [45] - thread-5退出了,线程池想退出且任务队列为空
[2025-09-12 17:32:02] [DEBUG] [92986] [Thread.hpp] [109] - join success
[2025-09-12 17:32:02] [DEBUG] [92986] [Thread.hpp] [109] - join success
[2025-09-12 17:32:02] [DEBUG] [92986] [Thread.hpp] [109] - join success
[2025-09-12 17:32:02] [DEBUG] [92986] [Thread.hpp] [109] - join success
[2025-09-12 17:32:02] [DEBUG] [92986] [Thread.hpp] [109] - join success
3. 线程安全的单例模式
3.1 单例模式概念
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取该实例。这种模式解决了需要全局唯一对象的场景,避免多个实例造成的资源浪费或状态不一致问题。
关键设计要点:
- 私有构造函数:防止外部通过
new
创建实例 。 - 静态实例变量:存储类的唯一实例 。
- 静态访问方法(如
getInstance()
):提供全局访问入口,控制实例的创建逻辑 。
应用场景:配置文件加载、线程池管理、数据库连接池、Session 实现等需全局唯一资源的场景 。
3.2 单例模式的特点
(1)唯一性
- 任何时刻仅存在一个类的实例,通过静态变量维护 。
- 例如:管理上百 GB 内存数据的服务器类,需单例避免重复加载 。
(2)全局访问点
- 通过静态方法(如
getInstance()
)提供统一访问入口,确保所有代码使用同一实例 。
(3)资源优化
- 减少开销:避免频繁创建/销毁对象(如数据库连接)。
- 数据一致性:唯一实例保证共享资源状态统一(如配置信息)。
(4)线程安全挑战
- 多线程环境下需额外机制(如锁、双重检查)防止创建多个实例 。
生活实例:正如一个男人只能有一个媳妇(在一夫一妻制社会中),某些系统组件也只需要一个实例。
服务器开发应用:在很多服务器开发场景中,经常需要让服务器加载大量数据(上百GB)到内存中。例如电商平台的商品信息、社交网络的用户关系图等。此时往往要用一个单例的类来管理这些数据,避免重复加载造成内存浪费。
3.3 饿汉与懒汉实现方式
饿汉方式 (Eager Initialization)
特点:在类加载时就创建实例
优点:线程安全,无需担心多线程问题
缺点:如果实例一直未被使用,会造成资源浪费
适用场景:实例初始化耗时短,且程序运行过程中一定会使用该实例
懒汉方式 (Lazy Initialization)
特点:在第一次使用时才创建实例
优点:资源利用率高,避免不必要的初始化
缺点:需要处理多线程安全问题
适用场景:实例初始化耗时长或资源占用大,且可能不会被立即使用
类比:
- 饿汉式 → 饭后立刻洗碗:下次直接使用,但可能洗了未用的碗 。
- 懒汉式 → 下次用餐前洗碗:节省资源,但需临时处理 。
3.4 饿汉方式实现单例模式
template <typename T>
class Singleton {static T data; // 静态成员变量,在程序开始时初始化
public:static T* GetInstance() {return &data;}// 删除拷贝构造函数和赋值运算符Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;private:Singleton() = default; // 构造函数私有化~Singleton() = default; // 析构函数私有化
};
关键点分析:
静态成员初始化:
static T data
是静态成员变量,在程序启动时(main函数执行前)就完成初始化这确保了实例的早期创建,避免了多线程环境下的竞争条件
线程安全性:
由于实例在程序启动时就创建,不存在多线程同时创建实例的问题
天然线程安全,无需额外的同步机制
访问控制:
构造函数和析构函数私有化,防止外部创建或销毁实例
删除拷贝构造函数和赋值运算符,防止通过拷贝方式创建新实例
获取实例:
GetInstance()
方法直接返回静态实例的地址,简单高效
优缺点:
优点:实现简单,线程安全,性能高(无锁)
缺点:如果实例很大或初始化耗时,会延长程序启动时间;即使不使用也会占用资源
3.5 懒汉方式实现单例模式(基础版本)
template <typename T>
class Singleton {static T* inst; // 静态指针,初始为nullptr
public:static T* GetInstance() {if (inst == nullptr) {inst = new T(); // 第一次调用时创建实例}return inst;}// 删除拷贝构造函数和赋值运算符Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;private:Singleton() = default;~Singleton() = default;
};// 初始化静态成员
template <typename T>
T* Singleton<T>::inst = nullptr;
关键点分析:
延迟初始化:
使用静态指针
inst
初始化为nullptr
只有在第一次调用
GetInstance()
时才创建实例
线程安全问题:
这是基础版本的主要缺陷
如果多个线程同时检查
inst == nullptr
,都可能通过检查,导致创建多个实例违反了单例模式的基本原则
内存管理:
使用
new
创建实例,但没有相应的delete
操作可能导致内存泄漏(虽然程序结束时操作系统会回收内存)
优缺点:
优点:延迟初始化,节省资源
缺点:线程不安全,可能创建多个实例
3.6 懒汉方式实现单例模式(线程安全版本)
#include <mutex>template <typename T>
class Singleton {volatile static T* inst; // volatile防止编译器过度优化static std::mutex lock; // 互斥锁保证线程安全public:static T* GetInstance() {if (inst == nullptr) { // 第一次检查,避免不必要的锁竞争std::lock_guard<std::mutex> guard(lock); // RAII方式加锁if (inst == nullptr) { // 第二次检查,确保只有一个线程创建实例inst = new T();}}return inst;}// 删除拷贝构造函数和赋值运算符Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;private:Singleton() = default;~Singleton() = default;
};// 初始化静态成员
template <typename T>
volatile T* Singleton<T>::inst = nullptr;template <typename T>
std::mutex Singleton<T>::lock;
关键点分析:
双重检查锁定模式:
第一重检查
if (inst == nullptr)
避免不必要的锁竞争只有实例未创建时才进入同步块
第二重检查确保只有一个线程创建实例
线程安全:
使用
std::mutex
和std::lock_guard
保证线程安全lock_guard
采用 RAII 技术,自动管理锁的生命周期
volatile 关键字:
防止编译器对指令进行重排序优化
确保多线程环境下读取的是最新值,而不是寄存器中的缓存值
内存屏障问题:
在 C++11 之前,双重检查锁定可能存在指令重排序问题
inst = new T()
可能被重排序为:分配内存 → 赋值给 inst → 调用构造函数这可能导致其他线程看到非空但未完全构造的实例
C++11 的内存模型解决了这个问题,但使用
volatile
是额外的保障
构造函数和析构函数私有化:
防止外部创建实例
防止通过拷贝构造或赋值操作创建新实例
优缺点:
优点:线程安全,延迟初始化,性能较好(大部分情况下无需加锁)
缺点:实现相对复杂,需要注意指令重排序问题
这种实现方式既保证了线程安全,又避免了不必要的锁竞争,是懒汉单例模式的经典实现。
4. 单例式线程池
线程池本身是系统关键资源,创建多个线程池实例会导致:
线程数量过多,增加上下文切换开销
内存资源浪费(每个线程都需要分配栈空间)
难以监控和统计整体线程使用情况
多个线程池可能竞争相同的系统资源
任务分配不均衡,可能导致某些线程池过载而其他空闲
减少复杂的线程间协调和同步问题
实现单例式线程池是为了统一管理线程资源、提高系统效率、保证稳定性,并提供一个简洁全局的并发编程接口。
下面我们实现线程安全的懒汉方式来实现单例式线程池
首先需要将构造函数和析构函数私有,Start函数也需要私有,同时拷贝构造和赋值重载需要显式删除
private:ThreadPool(int num = gnum): _num(num), _isrunning(false){for (int i = 0; i < _num; i++){_threads.emplace_back([this](){HandlerTask();});}}~ThreadPool() {}// 删除拷贝构造函数和赋值运算符ThreadPool(const ThreadPool&) = delete;ThreadPool& operator=(const ThreadPool&) = delete;void Start(){if (_isrunning)return;_isrunning = true;for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "new thread start: " << thread.Name();}}
定义静态指针,实现单例模式
class ThreadPool{...private:std::vector<Thread> _threads; // 管理线程int _num; // 线程的数量std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;bool _isrunning; // 运行标志位int _sleepernum; // 线程休眠的数量static ThreadPool<T>* _inst; // 单例指针static Mutex _lock;};// 静态成员类外初始化template<class T>ThreadPool<T>* ThreadPool<T>::_inst = nullptr;template<class T>Mutex ThreadPool<T>::_lock;
实现Getinstance函数
static ThreadPool* GetInstance(){if(_inst == nullptr){LockGuard lockguard(_lock);LOG(LogLevel::DEBUG) << "获取单例...";if(_inst == nullptr){LOG(LogLevel::DEBUG) << "首次使用,创建单例...";_inst = new ThreadPool<T>();_inst->Start();}}return _inst;}
测试一下:
int main()
{Enable_Console_Log_Strategy();int count = 10;while(count--){ThreadPool<task_t>::GetInstance()->Enqueue(Download);sleep(1);}ThreadPool<task_t>::GetInstance()->Stop();ThreadPool<task_t>::GetInstance()->Join();return 0;
}
运行结果:
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_thread/ThreadPool$ ./threadpool
[2025-09-12 22:33:16] [DEBUG] [107860] [ThreadPool.hpp] [105] - 获取单例...
[2025-09-12 22:33:16] [DEBUG] [107860] [ThreadPool.hpp] [108] - 首次使用,创建单例...
[2025-09-12 22:33:16] [DEBUG] [107860] [Thread.hpp] [71] - create thread success
[2025-09-12 22:33:16] [INFO] [107860] [ThreadPool.hpp] [51] - new thread start: thread-1
[2025-09-12 22:33:16] [DEBUG] [107860] [Thread.hpp] [71] - create thread success
[2025-09-12 22:33:16] [INFO] [107860] [ThreadPool.hpp] [51] - new thread start: thread-2
[2025-09-12 22:33:16] [DEBUG] [107860] [Thread.hpp] [71] - create thread success
[2025-09-12 22:33:16] [INFO] [107860] [ThreadPool.hpp] [51] - new thread start: thread-3
[2025-09-12 22:33:16] [DEBUG] [107860] [Thread.hpp] [71] - create thread success
[2025-09-12 22:33:16] [INFO] [107860] [ThreadPool.hpp] [51] - new thread start: thread-4
[2025-09-12 22:33:16] [DEBUG] [107860] [Thread.hpp] [71] - create thread success
[2025-09-12 22:33:16] [INFO] [107860] [ThreadPool.hpp] [51] - new thread start: thread-5
[2025-09-12 22:33:16] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:17] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:18] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:19] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:20] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:21] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:22] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:23] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:24] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:25] [DEBUG] [107860] [Task.hpp] [13] - 这是一个下载的任务...
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [95] - 唤醒所有线程
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [76] - thread-1退出了,线程池想退出且任务队列为空
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [76] - thread-2退出了,线程池想退出且任务队列为空
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [76] - thread-3退出了,线程池想退出且任务队列为空
[2025-09-12 22:33:26] [DEBUG] [107860] [Thread.hpp] [109] - join success
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [76] - thread-5退出了,线程池想退出且任务队列为空
[2025-09-12 22:33:26] [DEBUG] [107860] [Thread.hpp] [109] - join success
[2025-09-12 22:33:26] [INFO] [107860] [ThreadPool.hpp] [76] - thread-4退出了,线程池想退出且任务队列为空
[2025-09-12 22:33:26] [DEBUG] [107860] [Thread.hpp] [109] - join success
[2025-09-12 22:33:26] [DEBUG] [107860] [Thread.hpp] [109] - join success
[2025-09-12 22:33:26] [DEBUG] [107860] [Thread.hpp] [109] - join success
从运行结果可以看到没得问题,并且在使用日志向控制台输出后,也没有出现打印信息全都混在一起的情况。
5. 线程安全和重入问题
5.1 概念
线程安全:指多个线程同时访问共享资源时,能够正确执行而不会相互干扰或破坏彼此的执行结果。通常情况下,当多个线程并发执行仅包含局部变量的同一段代码时,不会产生不同的结果。但如果对全局变量或静态变量进行操作且未加锁保护,就容易出现线程安全问题。
重入:指同一个函数被不同执行流调用时,在前一个流程尚未执行完成时,又有其他执行流进入该函数。若一个函数在重入情况下仍能保持运行结果一致且不出现任何问题,则称为可重入函数,反之则为不可重入函数。
目前我们已经能够理解重入主要分为两种情况:
- 多线程重入函数
- 信号导致执行流重复进入函数
常见线程不安全的情况
• 未对共享变量进行保护的函数
• 函数状态随调用次数发生变化的函数
• 返回静态变量指针的函数
• 调用其他线程不安全函数的函数
常见不可重入的情况
• 调用malloc/free函数,因为它们使用全局链表管理堆内存
• 调用标准I/O库函数,因其实现常依赖不可重入的全局数据结构
• 函数内部使用了静态数据结构
常见线程安全的情况
• 仅读取全局/静态变量而无写入操作
• 类或接口提供原子性操作
• 多线程切换不会导致接口执行结果产生歧义
常见可重入的情况
• 不使用全局或静态变量
• 不使用动态内存分配(malloc/new)
• 不调用不可重入函数
• 不返回静态/全局数据,所有数据由调用方提供
• 使用局部数据,或通过全局数据的本地副本来保护全局状态
5.2 可重入性与线程安全的关系解析
不要被专业术语的复杂性吓到,通过仔细分析你会发现这些概念本质上是相互关联的。让我们深入探讨可重入函数与线程安全之间的关系。
可重入与线程安全的联系
基本对应关系
• 可重入函数必然线程安全:如果一个函数被设计为可重入的,那么它自然就是线程安全的。这是最核心的要点,掌握这一点就抓住了关键。
示例:一个只使用局部变量的纯计算函数,既可以被多个线程安全调用,也可以在信号处理程序中安全使用。
• 不可重入函数潜在风险:不可重入的函数不能被多个线程同时使用,否则可能引发数据竞争、内存污染等线程安全问题。
典型例子:使用静态缓冲区的strtok()
函数,在多线程环境下会导致不可预期的结果。
• 全局变量的影响:使用全局变量的函数会同时丧失可重入性和线程安全性,因为全局状态会被所有调用者共享。
例如:一个使用static int counter
来统计调用次数的函数,在多线程环境下计数会出错。
可重入与线程安全的区别
概念范围
• 包含关系:可重入函数是线程安全函数的一个子集。所有可重入函数都是线程安全的,但并非所有线程安全函数都是可重入的。
类比:就像所有正方形都是矩形,但并非所有矩形都是正方形。
• 锁机制的影响:
通过加锁实现的线程安全函数:这些函数在多线程环境下是安全的,但如果涉及到重入(如信号处理程序中调用),可能导致死锁。
示例场景:一个已获得互斥锁的线程在执行期间被信号中断,信号处理程序又试图获取同一个锁。
真正的可重入函数:不依赖锁机制,通常通过避免共享状态或使用线程本地存储来实现。
特别注意事项
应用场景考量
• 信号处理的影响:在大多数情况下,如果不考虑信号导致执行流重入的特殊情况,线程安全和可重入在安全性角度可以不做严格区分。
• 关注点差异:
线程安全:侧重描述多线程并发访问共享资源时的安全特性,反映的是程序在并发环境中的行为表现。
应用场景:设计多线程服务器时,确保共享数据结构的线程安全。
可重入:描述的是函数能否被安全地"重复进入"的特性,体现的是函数本身的设计特点。
应用场景:编写信号处理函数或递归算法时,必须使用可重入函数。
实际开发建议
- 在信号处理程序中只使用明确标注为"可重入"的函数
- 多线程编程时优先选择线程安全的函数版本
- 对于性能关键代码,可重入实现通常比加锁的线程安全实现更高效
6. 常见锁概念
6.1 死锁
死锁是指在多进程/线程系统中的一种资源竞争状态,当一组进程中的每个进程都持有至少一个不可抢占的资源(即该资源在被使用过程中不会被系统强制收回),同时又在等待获取该组中其他进程所占用的资源时,就会形成环形等待链,导致所有相关进程都无法继续执行下去,系统进入永久阻塞的状态。
为便于说明,假设线程A和线程B必须同时获取锁1和锁2,才能继续访问后续资源
申请单把锁是原子操作,但申请多把锁则未必能保证原子性。
这个时候造成的结果是:
6.2 死锁四个必要条件
互斥条件(Mutual Exclusion):这是死锁产生的四个必要条件之一,指在并发环境中,一个资源(如打印机、共享内存、文件等)在同一时间只能被一个执行流(线程或进程)独占使用。当某个执行流已经获取该资源时,其他执行流必须等待,直到该资源被释放。这个条件保证了资源的独占性,但同时也可能导致死锁的发生。
请求与保持条件(Request and Hold Condition)也是死锁产生的四个必要条件之一,指的是在并发系统中,当一个执行流(如进程或线程)因为请求新的资源而被阻塞时,仍然保持着已获得的资源不放。这种状况会导致多个执行流相互等待对方释放资源,从而形成死锁。
具体来说,请求与保持条件包含两个关键方面:
- 请求新资源:执行流在持有某些资源的同时,又尝试申请新的资源
- 保持已有资源:在申请新资源未成功时,不会释放已持有的资源
不剥夺条件(Non-preemptive Condition) 也称为不可抢占条件。该条件要求一个进程在执行过程中已获得的资源,在未使用完毕之前,其他进程或系统不能强行剥夺或抢占该资源。只有在进程主动释放资源后,其他进程才能获取这些资源。
关键点说明
资源持有状态:
- 进程在执行期间可能占用某些资源(如内存、I/O设备、文件锁等)。
- 这些资源一旦被分配,除非进程主动释放,否则系统不能强制收回。
剥夺的影响:
- 如果系统允许强行剥夺资源(如CPU时间片轮转),可能导致进程执行异常或数据不一致。
- 例如,一个进程正在写入文件时,若突然被剥夺磁盘访问权限,可能导致文件损坏。
循环等待条件(Circular Wait Condition)是多线程编程或操作系统资源分配中常见的一种死锁情况。具体表现为:有多个执行流(线程或进程)同时运行,每个执行流都在等待其他执行流释放资源,而这些等待关系形成了一个闭合的环形链。
6.3 避免死锁
死锁通常发生在多个进程或线程互相等待对方释放资源时。预防死锁需要从资源分配和请求策略入手。
破坏互斥条件
某些资源可以通过共享方式使用,避免独占。例如,只读文件可以允许多个进程同时访问,减少竞争。
破坏占有并等待条件
进程在开始执行前必须一次性申请所有所需资源。如果无法满足,则暂时不分配任何资源。这种方式可能导致资源利用率降低。
破坏非抢占条件
如果进程无法获得额外资源,必须释放已占有的资源。这种策略适用于状态容易保存和恢复的资源,如CPU寄存器。
破坏循环等待条件
对资源类型进行线性排序,要求进程按照编号顺序申请资源。例如,进程只能先申请编号较小的资源,再申请编号较大的资源。
避免死锁的算法
银行家算法
通过模拟资源分配检查系统是否处于安全状态。每次资源分配前,算法会判断剩余资源是否能满足至少一个进程的最大需求,从而避免进入不安全状态。
资源分配图算法
通过维护资源分配图检测是否存在环路。如果图中没有环路,则系统不会发生死锁;若存在环路,则可能发生死锁。
检测与恢复策略
定期检测死锁
通过资源分配图或等待图算法定期扫描系统状态。一旦检测到死锁,立即采取恢复措施。
终止进程
强制终止一个或多个死锁进程,释放其占用的资源。可以选择终止代价最小的进程,例如运行时间最短或资源占用最少的进程。
资源抢占
从某些进程中抢占资源分配给其他进程。被抢占资源的进程可能需要回滚到之前的检查点重新执行。
实际应用建议
- 在编写多线程程序时,尽量按照固定顺序获取锁。
- 使用超时机制,避免线程无限期等待资源。
- 减少锁的粒度,使用细粒度锁代替粗粒度锁。
- 优先使用高级并发工具(如信号量、条件变量)而非直接操作锁。
通过合理设计资源管理策略,可以显著降低死锁发生的概率。
7. STL、智能指针和线程安全
STL容器是否具备线程安全性?
答案是否定的。
这是由于STL在设计时优先考虑性能优化,而线程安全所需的锁机制会显著影响性能表现。此外,不同容器类型(如哈希表的表锁与桶锁,锁整个表(粗粒度)或锁单个桶(细粒度))需要采用不同的加锁策略,性能影响也各不相同。
因此,STL默认不提供线程安全保障。若要在多线程环境中使用,开发者需要自行实现线程安全机制。
智能指针是否是线程安全的?
unique_ptr 是线程安全的,这是因为:
- 所有权单一性:unique_ptr 采用独占所有权模式,任何时候一个资源只能由一个 unique_ptr 拥有
- 局部作用域:unique_ptr 的生命周期通常限定在当前代码块范围内,不会跨线程共享
- 转移所有权时的安全性:当通过 std::move 转移所有权时,操作是原子性的,不会产生竞态条件
shared_ptr 的线程安全性更为复杂,主要体现在以下几个方面:
引用计数的原子性:
- 标准库使用原子操作(CAS, Compare-And-Swap)保证引用计数操作是线程安全的
- 引用计数的增减操作是原子的,不会出现竞态条件
控制块的线程安全:
- shared_ptr 的实现包含一个控制块,其中存储引用计数和弱引用计数
- 控制块的修改都通过原子操作保护
数据访问的非原子性:
- 虽然引用计数操作是线程安全的,但对托管对象的访问仍需要额外同步
- 多个线程同时访问同一个 shared_ptr 管理的对象时,需要单独的互斥锁
使用建议
unique_ptr:当不需要跨线程共享所有权时优先使用,完全线程安全
shared_ptr:
- 引用计数操作本身是线程安全的
- 但需要共享数据时,仍需要额外的同步机制保护数据访问
- 跨线程传递 shared_ptr 时,最好通过复制而非引用传递
性能考虑:
- shared_ptr 的原子操作会有一定性能开销
- 在不需要线程安全的场景可以考虑使用 boost::shared_ptr 的非线程安全版本
8. 其他常见的各种锁(了解)
1. 悲观锁
核心思想:“总有刁民想害朕”。
工作方式:我认为只要我去操作数据(无论是读还是写),肯定会有其他线程来和我争抢、修改数据。所以,在操作数据之前,我一定会先加锁,把数据“锁”起来,这样其他线程就会被阻塞在外,无法操作。等我操作完释放锁之后,其他线程才能进来。
比喻:就像你去一个只有一个坑位的公共卫生间,你悲观地认为肯定有人会来抢。所以你一进去就把门从里面反锁(加锁),这样别人就只能在外面等着(被阻塞)。等你上完厕所开门出来(释放锁),下一个人才能进去。
常见实现:
synchronized
关键字、ReentrantLock
等。优点:简单粗暴,能保证绝对的线程安全。
缺点:加锁和释放锁本身有性能开销,并且如果锁竞争激烈,会导致大量线程挂起和唤醒,非常耗时。
2. 乐观锁
核心思想:“应该没人会改吧,我先干了再说”。
工作方式:我认为在我操作数据的时候,大概率不会有其他线程来修改它。所以我在操作数据前不上锁,直接就去读。但在更新数据的时候,我会判断一下这个数据在我读完之后、到我更新之前,有没有被别人动过。如果没动过,我就安心更新;如果动过了,我的更新就失败,然后我会选择重试或者报错。
比喻:就像你用云笔记(如Git、Google Docs)。你打开文档直接编辑(不上锁)。当你写完点击“保存”时,系统会检查一下从你打开文档到现在,有没有别人也保存过(判断版本)。如果没有人保存过,你的内容就顺利存上去。如果别人已经保存过了,系统会提示你“你的版本已过期”,让你基于最新的版本重新编辑(重试)。
常见实现:版本号机制、CAS操作。
优点:在读多写少的场景下,性能极高,因为它避免了加锁的巨大开销。
缺点:如果写操作非常频繁,更新失败重试的次数就会很多,反而可能降低性能(俗称“CPU空转”)。
3. CAS操作
是什么:Compare-And-Swap(比较并交换),是乐观锁最常用的一种具体实现技术。
工作流程:它包含三个操作数:
V:内存中的当前值(我准备要更新的那个变量现在的值)
A:我原先读取到的旧值(我期望内存中的值还是这个)
B:我想要更新成的新值
CAS的操作是原子性的(由CPU硬件指令保证)。它的逻辑是:“如果现在内存位置V的值等于我预期的旧值A,那么我就把它更新为新值B。否则,什么都不做,然后告诉我现在的实际值是多少。”
比喻:你看中了一件商品,库存显示只剩1件(V = 1)。你赶紧下单,在最终付款时,系统会检查一下库存现在还是不是1(Compare)。如果是,就扣减库存,让你购买成功(Swap);如果不是(比如已经被别人买走了),就告诉你失败。
缺点:
ABA问题:别人可能把库存从1件买光,然后又补了1件回来,你看库存还是1,但已经不是当初那个1了。对于敏感业务,需要用版本号来辅助解决。
自旋时间长开销大:如果一直不成功,CAS会不停重试,消耗CPU。
只能保证一个共享变量的原子操作。
4. 自旋锁
是什么:它是一种“傻等” 的锁,是悲观锁的一种实现方式。
工作方式:当一个线程尝试获取锁失败时,它不会立刻被挂起(进入阻塞状态),而是会执行一个忙循环(自旋),不停地尝试获取锁,直到成功为止。
比喻:你在等洗手间,里面有人。悲观锁的做法是:你去找个沙发躺着睡觉(线程被挂起),等里面的人出来大声叫你(唤醒)。而自旋锁的做法是:你不去睡觉,就在门口不停地敲门问“好了没?好了没?好了没?”(循环尝试)。
适用场景:非常适合锁被持有时间非常短的情况。因为线程挂起和唤醒的代价远大于它自旋一小会儿的代价。
缺点:如果锁被持有时间很长,自旋的线程就会白白浪费CPU时间。
5. 读写锁
是什么:一种特殊的悲观锁,它将锁的操作细分为读锁和写锁。
工作规则(核心规则):
共享读:多个线程可以同时持有读锁,进行读取操作。
独占写:写锁是独占的。一个线程持有写锁时,其他所有线程(无论是想读还是想写)都必须等待。
读写互斥:一个线程持有读锁时,其他线程可以读,但不能写。一个线程持有写锁时,其他线程既不能读也不能写。
比喻:一个黑板报。
读:很多同学可以同时看黑板报(共享读)。
写:如果一个同学要上去修改黑板报(写),他必须等所有看的同学都走开(释放读锁),并且他会独占黑板报,不让别人看也不让别人写(独占写)。
适用场景:读多写少的场景,能极大提升性能(因为读操作可以并发进行)。
常见实现:
ReadWriteLock
接口及其实现ReentrantReadWriteLock
。
总结对比
锁类型 | 核心思想 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
悲观锁 | 先加锁,再操作 | 保证强一致性,简单安全 | 性能开销大 | 写多读少,临界区操作耗时 |
乐观锁 | 先操作,更新时检查 | 性能高,无锁开销 | 存在ABA问题,竞争激烈时重试开销大 | 读多写少,竞争不激烈 |
CAS | 比较并交换(乐观锁的实现) | 硬件实现,高效 | ABA问题,自旋开销 | 实现原子操作,无锁数据结构 |
自旋锁 | 失败后循环尝试(悲观锁的实现) | 避免线程切换开销 | 占用CPU空转 | 锁持有时间极短的场景 |
读写锁 | 读共享,写独占 | 允许多线程并发读,大幅提升读性能 | 实现相对复杂,写操作可能饿死 | 读多写少的并发场景 |