开篇介绍:
hello 大家,那么在上一篇博客中,我们实现了线程池,而在本篇博客中,我们将对线程池进一步扩展------单例模式的线程池。
在C++后端开发中,"线程池"和"单例模式"是两个高频出现的核心技术。线程池负责高效管理线程资源,避免频繁创建销毁线程的开销;单例模式则保证一个类全局唯一实例,解决资源浪费和状态不一致问题。当两者结合,就形成了工业级开发中极具实用价值的"单例模式线程池"------比如Web服务器的请求处理、后台任务调度、日志收集等场景,几乎都能看到它的身影。
"单独写线程池能搞定,单独写单例也会,但把两者结合起来就乱了,要么出现多个线程池实例,要么多线程下创建单例时崩溃,甚至任务提交后线程根本不执行"。其实问题的核心是没理清"单例的唯一性保障"与"线程池的并发调度"之间的协同逻辑------比如单例的静态访问方法怎么保证线程安全?线程池的构造函数为什么要私有?静态锁和普通锁的区别是什么?
一、开篇铺垫:为什么需要"单例模式+线程池"?
在讲具体实现前,我们先搞懂一个核心问题:普通线程池有什么痛点?单例模式又能解决什么问题?只有理清需求,才能理解后续设计的合理性------毕竟编程不是"为了用设计模式而用设计模式",而是用设计模式解决实际问题。
1.1 普通线程池的"致命痛点"
我们之前实现的普通线程池(非单例),构造函数是公有的,这意味着在代码中可以随意创建实例,比如这样:
// 普通线程池(非单例)的错误用法示例
ThreadPool<Task> pool1(5); // A模块创建线程池,5个线程
ThreadPool<Task> pool2(8); // B模块创建线程池,8个线程
这种写法在实际开发中会引发3个严重问题,尤其是在大型项目的多模块协作中,几乎是"灾难级"的:
-
资源浪费严重:每个线程池都会创建一批独立的工作线程,多个实例会导致线程数量爆炸。比如上面的代码,本想总共用5个线程处理任务,结果两个实例搞出13个线程------这些线程都会占用内存(每个线程默认栈大小1MB),闲置时还会消耗CPU资源做线程调度,完全是"吃力不讨好"。
-
任务调度混乱:不同模块的任务会提交到不同的线程池,无法全局统一调度。比如A模块的任务堆积在pool1的队列里,而pool2的8个线程却处于闲置状态,导致资源利用率极低;更严重的是,若多个模块处理同一类任务(比如数据入库),可能出现任务重复执行的情况,比如同一个数据被两个线程池分别入库一次,造成数据冗余。
-
状态管理失控:多个线程池独立管理自己的运行状态(启动/停止),停止程序时需要逐个调用Stop方法。一旦遗漏某个实例,该线程池的工作线程会一直运行,无法正常退出,引发资源泄漏------比如程序都退出了,线程还在循环等待任务,导致进程无法正常终止。
1.2 单例模式:精准解决普通线程池的痛点
单例模式的核心思想其实特别简单,就一句话:一个类在整个程序运行期间,只能创建唯一一个实例,并且提供一个全局访问入口。把这个思想应用到线程池,就能完美解决普通线程池的痛点:
-
实例唯一:整个程序只有一个线程池实例,无论哪个模块(A模块、B模块)需要提交任务,都只能访问这一个实例,从根源上避免线程数量爆炸,也不会出现多个队列分散任务的问题。
-
全局访问 :无需把线程池对象作为参数在各个模块间传递(比如从main函数传到A模块,再从A模块传到B模块),任何地方只需通过统一接口(比如
ThreadPool::GetInc())就能获取实例,极大降低代码耦合度。新手可能没体会过"参数传递地狱"------一个对象要在10个模块间传递,改一处接口就要改10处代码,单例模式直接解决了这个痛点。 -
统一调度与状态管理 :所有模块的任务都提交到同一个任务队列,工作线程统一处理,资源利用率更高;停止程序时,只需调用一次Stop方法,就能统一管理所有工作线程的生命周期,避免资源泄漏。比如服务器 shutdown 时,只需调用
ThreadPool::GetInc()->StopThreadPool(),就能让所有工作线程优雅退出。
1.3 这些场景,一定要用"单例+线程池"
不是所有场景都需要单例线程池,强行用反而会增加代码复杂度。但以下3种场景,用它准没错:
-
多模块共享任务调度:比如一个Web服务器,网络模块(接收请求)、业务处理模块(处理请求)、日志模块(记录请求日志)都需要提交任务,此时需要一个全局线程池统一调度,避免重复创建线程。要是每个模块都搞个线程池,服务器的CPU和内存很快就会被耗尽。
-
资源昂贵且无需多实例:线程池创建时需要启动多个线程(创建线程是内核级操作,开销很大),多次创建会严重浪费资源;而且多个线程池实例处理任务,除了浪费资源外,没有任何额外好处。类似的还有数据库连接池------创建一个数据库连接要经过TCP三次握手、权限验证等步骤,耗时耗力,全局一个连接池统一管理连接才是最优解。
-
需要全局统一状态管理:比如需要统计整个程序的任务处理总数、成功/失败数,或者监控线程池的运行状态(活跃线程数、休眠线程数),单例模式能保证这些状态的唯一性和一致性。要是多个实例各存各的状态,统计出来的数据就是错的------比如A实例统计处理了100个任务,B实例统计处理了200个任务,实际总任务数是300,但你根本没法快速汇总。
典型例子:Web服务器的请求处理、后台定时任务调度系统、大数据批量处理程序、日志收集系统等,几乎都采用"单例模式+线程池"的组合。这不是什么"高级技巧",而是工业级开发中的"标配",掌握它能让你的代码更符合工程化规范。
二、单例模式核心解析:从"为什么"到"怎么实现"
要实现单例线程池,必须先掌握单例模式的基础。这部分内容是核心,哪怕你之前了解过单例,也建议认真看------我们会结合线程池的场景,讲透容易踩坑的细节,比如"为什么静态函数才能作为单例访问入口""为什么懒汉式必须加双重检查锁定",这些都是新手最容易栽跟头的地方。
2.1 单例模式的3个核心特点(必须满足)
一个合格的单例类,必须满足以下3个条件,缺一不可,否则就不是"真正的单例":
-
实例唯一:不管你在代码里多少次尝试创建这个类的对象,最终拿到的都是同一个。比如日志类,你绝对不能让它同时存在10个实例------不然多个实例同时往一个日志文件写内容,线程A的日志还没写完,线程B的日志就插进来,最后日志文件只会乱成一锅粥;线程池也是同理,多个实例会导致线程数量爆炸,完全违背"统一调度"的初衷。
-
全局访问 :不用把对象当成参数在各个模块间到处传递。比如线程池里的Log类,要是不用单例,就得在Thread类、ThreadPool类、Task类里都传一遍Log对象,代码耦合度极高;而用了单例之后,任何地方想写日志,直接调
Log::GetInstance()->WriteLog(...)就行,不用管对象是怎么创建的、在哪创建的。更贴心的是,我们在日志类头文件里会直接定义全局变量logger并封装成宏,用户连获取实例的步骤都省了,直接调用宏就能用。 -
自行实例化 :类自己控制实例的创建,不让外部随便
new。毕竟都叫"单例"了,怎么能允许外部随便创建新对象呢?所以核心操作是把构造函数设为私有(private),这样外部就没法通过new 类名()的方式创建实例,实例的创建权就完全掌握在类自己手里。
这三个特点是单例模式的"铁律",少一个都不行。比如只满足"实例唯一"但没有"全局访问入口",那这个单例在多模块场景下根本用不了;只满足"全局访问"但允许外部创建多个实例,那和普通的全局变量就没区别了。
2.2 单例模式到底有啥用?核心价值:省资源+保一致
单例模式之所以被广泛使用,核心价值就两个:节省资源 和保证状态一致。结合我们熟悉的场景,你一下子就能懂:
2.2.1 场景1:日志类(线程池里的Log.hpp)
这是单例最经典的用法,没有之一!
为啥要用单例?核心原因有两个:一是"写日志不能乱",二是"初始化一次就够了"。日志需要往同一个文件/终端写内容,如果创建多个Log实例,就可能出现"多个实例同时写日志"的情况------比如线程A的日志还没写完(只写了一半),线程B的日志就插进来,最后日志文件里的内容会变成"线程A:开始处理线程B:接收请求任务A:处理完成",完全没法读;而且日志类初始化一次就够了(比如打开日志文件、设置日志级别为INFO),多次初始化纯属浪费资源------打开文件是IO操作,频繁打开关闭不仅耗时间,还可能导致文件句柄泄漏。
用了单例之后呢?不管是线程池、任务类、线程类,要写日志直接调Log::GetInstance()->WriteLog(...),拿到的都是同一个日志实例,写日志不会乱;而且不用到处传Log对象,代码简洁了很多。更贴心的是,我们在日志类头文件里会直接定义全局变量logger并封装成宏(比如LOG_INFO("xxx")),用户连获取实例的步骤都省了,直接调用宏就能写日志,体验拉满。
2.2.2 场景2:配置文件类(比如线程池要读"线程数、队列大小")
假设你写个Config类,负责加载config.ini里的配置(比如默认线程数、日志输出路径、数据库地址),这个类用单例再合适不过了。
为啥要用单例?一是"加载配置很耗资源",二是"配置要一致"。加载配置文件是IO操作,挺耗资源的------读文件要从硬盘读到内存,解析ini格式还要消耗CPU;要是创建多个Config实例,每个实例都加载一次配置,既浪费时间又浪费内存。更严重的是,万一多个实例加载的配置不一致(比如有人改了config.ini里的线程数,一个实例加载了旧的5个线程,一个加载了新的10个线程),程序就乱了------线程池可能启动了5个线程,而业务模块以为有10个线程,导致任务堆积。
用了单例之后呢?程序启动时加载一次配置,后续不管哪个模块(线程池、任务类、数据库模块)要拿配置,直接Config::GetInstance()->GetThreadNum(),拿到的都是同一个配置,一致又高效。就算配置文件被修改了,我们也只需在单例类里加一个"重新加载配置"的方法,调用一次就能让所有模块用上新配置,不用逐个通知。
2.2.3 场景3:线程池(我们今天的主角)
我们之前写的线程池,其实也特别适合单例!
为啥要用单例?一是"创建线程池很耗资源",二是"任务调度要统一"。线程池创建的时候会启动一堆线程,创建线程是内核级操作------内核要为线程分配栈空间、维护线程控制块(TCB),开销很大;要是不小心创建了多个线程池(比如A模块创建一个、B模块再创建一个),就会出现"线程数量爆炸"------本来只需要5个线程,结果两个线程池搞出10个,浪费CPU和内存。更严重的是,多个线程池处理任务,还可能出现任务重复执行、资源竞争的问题------比如同一个用户请求被两个线程池分别处理一次,导致数据错误。
用了单例之后呢?整个程序只有一个线程池实例,所有模块提交的任务都进同一个队列,线程统一调度,既省资源又不会乱。比如Web服务器的所有请求,不管是来自哪个端口、哪个模块,都提交到同一个线程池处理,线程利用率最高,也不会出现任务分散的问题。
2.2.4 其他常见场景(理解就好)
除了上面三个场景,还有两个场景也常用单例:
-
数据库连接池:创建数据库连接要经过TCP三次握手、权限验证等步骤,耗时耗力,全局一个连接池统一管理连接,避免频繁创建销毁连接,提高数据库访问效率。
-
缓存类:全局缓存数据要一致,不能多个缓存实例各存各的------比如用户登录状态缓存,要是A实例存了用户已登录,B实例存了用户未登录,用户就会出现"时而登录时而未登录"的奇怪问题。
这些场景的本质都是:资源昂贵(创建/销毁费时间) 或状态需要全局一致。只要符合这两个条件,就可以考虑用单例模式。
2.3 单例模式的关键实现要点:堵死外部,掌控内部
要实现单例,核心思路就一句话:堵死外部创建实例的所有路径,自己控制唯一实例的创建和访问。具体来说,就是两个关键步骤,少一步都不行:
2.3.1 步骤1:堵死外部创建实例的路径
外部要创建一个类的实例,常见的方式有3种:① 通过构造函数new 类名();② 通过拷贝构造函数类名 obj2(obj1);③ 通过赋值运算符obj2 = obj1。要堵死这些路径,就要做两件事:
-
把构造函数设为私有(private) :这样外部就没法通过
new 类名()的方式创建实例------比如我们的线程池类,构造函数ThreadPool(int threadnum=gdefaultthreadnum)是private的,外部写ThreadPool<Task> pool(5)会直接编译报错,从根源上禁止外部创建实例。 -
把拷贝构造函数和赋值运算符禁用 :就算构造函数私有了,外部还可能通过拷贝或赋值的方式创建新实例(比如用已有的实例拷贝一个)。所以我们要明确禁用这两个函数,用C++11的
delete关键字最直接------比如线程池类里的ThreadPool(const ThreadPool<T>&)=delete;和ThreadPool<T>& operator=(const ThreadPool<T>&)=delete;,这样外部尝试拷贝或赋值时,编译器会直接报错。
这里要多说一句:为什么要禁用拷贝和赋值?因为线程池是"独立资源容器",里面包含工作线程、任务队列、互斥锁这些资源,拷贝一个线程池实例毫无意义------难道要拷贝一堆线程出来?而且拷贝过程中还会出现资源竞争(比如两个实例共用一个任务队列),导致程序崩溃。所以禁用拷贝和赋值是单例模式的"标配操作"。
2.3.2 步骤2:内部控制唯一实例的创建和访问
堵死外部创建路径后,实例的创建权就回到了类自己手里。要让外部能访问到这个唯一实例,需要两个核心成员:
-
一个静态的私有实例 :静态成员属于"类级别的成员",不属于任何对象,整个程序运行期间只有一个副本------这正好符合"实例唯一"的要求。比如线程池类里的
static ThreadPool<T>* inc;,这是一个静态指针,初始值为nullptr,用来指向唯一的线程池实例。 -
一个静态的公有访问方法 :静态方法也属于"类级别的方法",不用创建对象就能调用(外部正是因为不能创建对象,才需要这种方法)。这个方法的作用是:创建唯一的实例(如果还没创建的话),并返回这个实例的指针。比如线程池类里的
static ThreadPool<T>* GetInc();,外部通过ThreadPool<Task>::GetInc()就能获取到唯一的线程池实例。
这两个成员是单例模式的"核心骨架",缺一不可。静态实例负责"存储唯一实例",静态访问方法负责"提供全局访问入口",两者配合就能实现单例的核心功能。
2.4 单例模式的两种实现方式:饿汉式vs懒汉式(重点区分)
根据"实例创建时机"的不同,单例模式主要分为两种实现方式:饿汉式和懒汉式。两者的核心区别就是"什么时候创建实例",我们结合线程池的场景详细讲解,新手一定要分清!
2.4.1 饿汉式:程序启动就创建,简单但可能浪费资源
饿汉式的核心特点:程序一启动(进程初始化阶段),就创建唯一的实例,不管后续有没有用到。比如我们要实现一个饿汉式的日志单例:
cpp
#include "Log.hpp"
namespace LogModlue {
class Log {
private:
// 饿汉式:静态实例,程序启动就创建(全局唯一)
static Log _instance;
// 构造函数私有,堵死外部创建路径
Log() {
// 初始化日志:打开文件、设置级别
OpenLogFile();
SetLogLevel(INFO);
}
// 禁用拷贝和赋值
Log(const Log&)=delete;
Log& operator=(const Log&)=delete;
public:
// 静态访问方法:直接返回已创建的实例
static Log* GetInstance() {
return &_instance;
}
// 写日志方法(示例)
void WriteLog(const std::string& msg) {
// 写日志逻辑
}
};
// 类外初始化静态实例:程序启动时执行
Log Log::_instance;
}
饿汉式的优点很明显:
-
简单易懂:不用考虑线程安全问题------程序启动时只有主线程在执行,创建实例的过程不会有多个线程竞争,天然线程安全。
-
访问速度快:实例已经提前创建好了,外部调用
GetInstance()时直接返回指针,没有任何判断和锁操作,效率很高。
但缺点也很突出:可能浪费资源。如果这个单例实例后续没被用到(比如程序启动后从来没写过日志),那么它占用的内存就白浪费了------虽然单个实例占用的内存不多,但如果有多个这样的饿汉式单例,累积起来也是一笔不小的开销。比如一个工具类程序,里面有10个饿汉式单例,但用户只用到了2个,剩下的8个都是"闲置资源"。
2.4.2 懒汉式:第一次用到才创建,省资源但要处理线程安全
懒汉式的核心特点:程序启动时不创建实例,第一次用到的时候才创建。这就像一个"懒汉",不到万不得已不干活。我们今天的主角------单例线程池,用的就是懒汉式实现。
懒汉式的核心逻辑:用一个静态指针(初始为nullptr)指向实例,第一次调用GetInstance()时,判断指针是否为nullptr,如果是,就new一个实例;如果不是,就直接返回指针。比如线程池类里的static ThreadPool<T>* inc = nullptr;,就是典型的懒汉式写法。
懒汉式的优点很致命:节省资源。只有在真正用到的时候才创建实例,没用到就不占内存,这在资源敏感的场景(比如嵌入式设备、服务器)中非常重要。比如服务器程序里的"大数据处理线程池",可能只有每天凌晨才会用到,用懒汉式的话,白天就不会占用内存和线程资源。
但缺点也很明显:需要处理线程安全问题 。如果多个线程同时调用GetInstance(),而且此时实例还没创建(inc == nullptr),就可能出现"多个线程同时new实例"的情况,导致创建多个实例,违背单例的"唯一性"要求。这也是新手最容易踩坑的地方,我们后面会重点拆解如何解决这个问题。
2.4.3 饿汉式vs懒汉式:核心区别一句话总结
很多新手分不清两者的区别,其实一句话就能概括:
饿汉式 = 程序启动就
new对象(提前占坑,不用判断); 懒汉式 = 第一次用到才new对象(延迟占坑,要判断指针是否为nullptr)。
更通俗的解释:
-
饿汉式:"我不管你用不用,我先创建好,等你来拿"------优点是简单安全,缺点是可能浪费资源。
-
懒汉式:"你不用我就不创建,你第一次用我再创建"------优点是节省资源,缺点是需要处理线程安全。
在实际开发中,我们更常用懒汉式------因为系统级程序(比如服务器、框架)都很在意资源利用率,能省则省。而且线程安全问题也有成熟的解决方案(双重检查锁定),实现起来并不复杂。我们今天的单例线程池,就是基于懒汉式实现的,后面会重点解析线程安全的处理逻辑。
重点提醒:饿汉式和懒汉式最核心的区别,不是"用指针还是用对象",而是"实例创建的时机"。新手容易误以为"饿汉式用对象,懒汉式用指针"------这是错误的!饿汉式也可以用指针(程序启动时new),懒汉式也可以用对象(但需要特殊处理),只是我们常用"饿汉式用对象,懒汉式用指针"的写法,因为这样最直观、最容易实现。
懒汉式完整代码:
cpp
#include "Mutex.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Log.hpp"
#include <queue>
//接下来这个文件就是要讲一种很常用的模式:单例模式------singleton
/*
单例模式其实特好理解,核心就一句话:一个类在整个程序运行期间,只能创建出唯一一个实例对象,
而且还得提供一个 "全局都能拿到这个实例" (想到什么了,静态!!!)的访问入口 ------ 不用你到处传对象,哪里要用哪里直接调,省事又唯一。
结合我们之前写的线程池、日志类的场景,我来给大家分析分析
一、单例模式的核心特点(必须满足这 3 点)
实例唯一:不管你在代码里多少次尝试创建这个类的对象,最终拿到的都是同一个
(比如日志类,你不可能让它同时存在 10 个实例,不然写日志会乱成一团);
全局访问:不用把对象当成参数到处传递
比如线程池里的 Log,要是不用单例,就得在 Thread、ThreadPool 里都传一遍,
而用了单例之后,我们就会发现,线程池中的所有日志类对象其实都是我们在日志头文件中创建的全局变量logger
直接通过类的静态方法就能拿到实例,即类变量的指针或者实体(比如 Log::GetInstance());
那么我们在日志类头文件中是直接定义全局变量,并且封装为宏,提供给用户使用
所以用户不必去获取到类的实例,可以直接调用宏进行使用
自行实例化:类自己控制实例的创建,不让外部随便 new(所以构造函数得是私有的,外部没法直接创建对象)
毕竟都是单例了,那么肯定就只有一个,怎么可能让外部再去创建新的类对象,也就是实例呢?
二、单例模式到底有啥用
它的核心价值就俩:省资源 + 保一致,具体看这几个熟悉的场景:
1. 日志类(线程池里的 Log.hpp)
这是单例最经典的用法!
为啥要用单例?:日志需要往同一个文件 / 终端写内容,如果创建多个 Log 实例,
可能会出现 "多个实例同时写日志" 的情况 ------
比如线程 A 的日志还没写完,线程 B 的日志插进来,最后日志文件乱成一锅粥;
而且日志类初始化一次就够了(比如打开日志文件、设置日志级别),多次初始化纯属浪费资源。
用了单例之后:不管是线程池、任务类、线程类,要写日志直接调 Log::GetInstance()->WriteLog(...),
拿到的都是同一个日志实例,写日志不会乱,还不用到处传 Log 对象。
2. 配置文件类(比如线程池要读 "线程数、队列大小" 的配置)
假设你写个 Config 类,负责加载 config.ini 里的配置(比如默认线程数、日志输出路径):
为啥要用单例?:配置文件加载一次就够了(加载文件是 IO 操作,挺耗资源的),要是创建多个 Config 实例,
每个实例都加载一次配置,既浪费时间又浪费内存;
而且万一多个实例加载的配置不一致(比如有人改了配置文件,一个实例加载了旧的,一个加载了新的),程序就乱了。
用了单例之后:程序启动时加载一次配置,后续不管哪个模块(线程池、任务类)要拿配置,
直接 Config::GetInstance()->GetThreadNum(),拿到的都是同一个配置,一致又高效。
3. 线程池(我们之前写的 ThreadPool)
我们之前写的线程池,其实也特别适合单例!
为啥要用单例?:线程池创建的时候会启动一堆线程,要是不小心创建了多个线程池
(比如 A 模块创建一个、B 模块再创建一个),
就会出现 "线程数量爆炸"------ 本来只需要 5 个线程,结果两个线程池搞出 10 个,浪费 CPU 和内存;
而且多个线程池处理任务,还可能出现任务重复执行、资源竞争的问题。
用了单例之后:整个程序只有一个线程池实例,所有模块提交的任务都进同一个队列,线程统一调度,既省资源又不会乱。
4. 其他场景(理解就好)
比如数据库连接池(创建连接很耗资源,全局一个连接池统一管理连接)、缓存类(全局缓存数据要一致,不能多个缓存实例各存各的)------
本质都是 "资源昂贵(创建 / 销毁费时间)" 或 "状态需要全局一致" 的场景
三、单例模式的关键实现要点
要实现单例,核心就是 "堵死外部创建实例的路,自己控制唯一实例",关键两步:
把构造函数、拷贝构造、赋值运算符都设为私有(private 或者 delete):
比如我们之前线程池里写的 ThreadPool(const ThreadPool&)=delete,就是防止外部随便拷贝创建新对象;
类内部搞一个 "静态的私有实例"(比如 static Log* _instance),
再提供一个 "静态的公有方法"(比如 static Log* GetInstance()),
通过这个方法返回唯一的实例。
这个是关键的关键哦~~~~~
常见的两种实现方式(不用深钻代码,知道区别就行):
饿汉式:程序一启动就创建实例(比如 static Log _instance;),简单、线程安全,
但如果实例没被用到,就浪费了点内存;
懒汉式:第一次用到的时候才创建实例(比如 if (_instance == nullptr) _instance = new Log();),
其实意思就是说,我们是先声明了实例,那么一般是指针
但是我们并没有说在声明的时候就去创建了实例,即向内存申请空间用于实例了
省内存,但要处理线程安全(比如加锁)
关于多线程下线程安全的,我会在下面的代码中进行解析
那么注意:接下来重点来了
饿汉式和懒汉式最核心最明显的区别是什么????????????
其实就是饿汉式是直接就创建出类对象,也就是类变量,那么这个就是相当于直接拿一块空间进行使用了!!!
而懒汉式是什么???
懒汉式就是,诶,我是要创建出类对象,但是我不知道你什么时候要用啊???
那么我干嘛着急去拿空间使用呢???
所以,我创建的是一个指针,一个类对象的指针,那么我只是创建指针,并且刚开始为nullptr
那么这就代表了是有这么一个类对象了,但是他没占空间啊!!!!!!
(其实是占一丢丢,指针本身也是有空间的)(因为它是nullptr的)
(指针本身占 4/8 字节,但其指向的对象实体初始为 nullptr,不占堆空间)
而等到你要用了,OK,我判断一下我之前就已经创建的类对象指针是不是为nullptr
是的话,那么我再new给你申请空间,因为你确实是要用了
而不是的话,那我就直接返回就完了,毕竟该指针已经有实体了,干嘛还要new!!!
"饿汉直接创建类对象占空间"
"懒汉用 nullptr 指针延迟 new"
简单说:饿汉式=程序启动就new对象(不用判断);懒汉式=用的时候才new(要判断指针是否为nullptr)
所以,上面的就是我们理解单例模式,理解饿汉式和懒汉式的关键中的关键!!!
那么我们一般都是使用懒汉式,因为系统里也是这么用的哈哈哈
四、总结:什么时候该用单例?
记住 3 个场景,碰到了就可以考虑:
你需要一个 "全局唯一" 的对象(比如日志、配置、线程池);
这个对象创建 / 销毁很耗资源(比如数据库连接池、大配置文件加载);
整个程序需要共享这个对象的状态(比如缓存数据、配置参数),不能出现多个版本。
反过来,要是一个类的对象可以有多个(比如之前写的 Task 任务类,每个任务都是一个对象),
或者对象状态不需要全局一致,就千万别用单例 ------ 用了反而麻烦~
*/
//懒汉式单例模式的线程池
// 线程池核心:生产者-消费者模型,用户扔任务到队列,工作线程抢着执行
// 关键:队空且池运行→线程休眠;队空且池停止→线程退出(必须跑完所有任务)
namespace SingletonThreadPoolModlue
{
using namespace MutexModlue;
using namespace CondModlue;
using namespace ThreadModlue;
using namespace LogModlue;
#define gdefaultthreadnum 5// 默认线程数5个
// 类模板:兼容各种类型的任务(只要是可调用对象就行)
template <typename T>
class ThreadPool
{
private:
// 唤醒所有休眠线程,停线程池时用,防止线程卡着不醒
void WakeUpAllThread()
{
_cond.CondBroadcast();
LOG(INFO)<<"所有线程被唤醒";
}
// 唤醒一个休眠线程,丢完任务后用,保证有线程处理新任务
void WakeUpOneThread()
{
_cond.CondSignal();
LOG(INFO)<<"随机一个线程被唤醒";
}
//把构造函数丢进private里,不允许外界直接创建类变量
//符合单例模式的要求
// 构造:初始化参数,创建工作线程(线程执行逻辑绑Handler)
ThreadPool(int threadnum=gdefaultthreadnum)
:_threadnum(threadnum)
,_sleepthreadnum(0)
,_isrunning(false)
{
Use_Monitor_Log();// 日志直接打在显示器上
for(int i=0;i<_threadnum;++i)
{
_threads.emplace_back([this](){ Handler(); });
}
}
// 禁止拷贝赋值:线程池是独立资源,不能复制
ThreadPool(const ThreadPool<T>&)=delete;
ThreadPool<T>& operator=(const ThreadPool<T>&)=delete;
public:
static ThreadPool<T>* GetInc()//外界获取到我们封装好的,规定好的单例指针,不要忘记得是静态的
{
//上锁!!!!!!
//然后就是多线程的线程安全了
//那么想想看,要是是多线程呢?即有多个生产者线程呢?
//那么会不会出现多个线程都去获取到单例指针呢???
//而且我们获取到单例指针的函数是要先判断单例指针是否为空
//不为空的话就得new一下,那么要是多线程都访问的话
//不会出现多个线程都new吗?????
//肯定是会的,所以,怎么办???????
//上锁,只允许一个线程去new(为nullptr),一个线程去获取到单例指针(不为nullptr)
//所以,也得创建一个静态的锁
//直接使用RAII风格的锁
// LockGuard mutexguard(_singletonmutex);
// LOG(DEBUG) << "获取单例....";
// //判断单例指针是否为nullptr,是的话,我们就给它new一下
// //毕竟我们是使用懒汉式的!!!
// if(inc==nullptr)
// {
// LOG(DEBUG) << "首次使用单例, 创建!!!";
// inc = new ThreadPool<T>();//new会自动转换指针类型
// //那么仅仅只是new申请空间就够了吗???
// //不够,我们new了之后,只是代表说,有这么一个线程池指针了
// //但是线程池里面的线程还没被启动啊!!!!!
// //虽然可以外界自己调用单例指针的启动线程函数
// //不过我们也可以直接在线程获取到指针的时候就去启动线程池里的线程
// //这样子就不用外界每次获取到单例指针之后再去调用启动线程池里的线程的函数
// //毕竟想想,我们的单例,其实就是直接就让外界获取到一个可以运行的线程池了
// //所以,直接在new之后就去启动线程池就完事了
// inc->StartThreadPool();
// return inc;
// }
// //而要是单例指针不为空,那么就是代表单例线程池已经有了
// //直接返回单例指针就行了
// return inc;
//那么上面的就足够好吗????
//答案是:不够的!!!
//想想看这个场景,多线程,
//是不是就得多个线程都先去争夺锁,争夺锁了之后,再去判断单例指针是不是为空
//为空就new,不为空就返回单例指针,但是我们要明确,单例指针只需要被new一次就够了
//一旦有一个线程new加启动了一次之后,
//那么后续的线程还需要去进行判断单例指针是不是为空,为空就new吗???
//不用,直接返回单例指针就完事了
//不难的话还得先抢锁,抢锁了之后才能去为空就new,不为空就返回单例指针
//要知道,争夺锁本质上就是一个很消耗时间的操作
//所以,我们要在上锁之前,就先进行一次单例指针是否为空的操作
//当单例指针不为空了,那么所有线程直接返回单例指针即可,没必要再去抢夺锁去判断然后巴拉巴拉的
//为空了,然后在锁里面再进行一次判断单例指针是否为空的操作,为空了,再去上锁,再去new,再去启动
//那么有人可能会好奇,诶,为什么外面有一层if(inc==nullptr)了
//里面还得再有if(inc==nullptr),不是进入了外面一层的if(inc==nullptr)之后
//就是代表单例指针为nullptr吗,那不就是代表要new加start了嘛
//可是,想想这个问题:首先在刚开始单例指针就是为空的时候
//多个线程都通过了外面一层的if(inc==nullptr)判断
//然后都去争夺锁
//然后有一个线程捷足先登了,OK,那么这个线程就会去new加start
//可是呢,不要忘记了,这只是一个线程,锁哪里还有好几个线程呢
//它们难道就不进入锁之后的代码吗???它们都已经排队了,怎么可能直接就让它们滚蛋了呢???
//肯定不能,所以,即使你已经有一个线程new加start了,但是其他进入外层if(inc==nullptr)的线程
//也得去进行new+start,为什么???因为你没有在外层if(inc==nullptr)里面再加一层if(inc==nullptr)
//那么其余的线程就得去再new+start
//那么这不就是纯扯淡吗!!!!!
//正确来说,应该是有一次线程new+start了,那么后面的其余线程(其他进入外层if(inc==nullptr)的线程)
//就得去再判断一下是不是单例指针为空,不为空的话,就直接返回单例指针就完事了
//所以这就是为什么要有两层if(inc==nullptr)!!!!!!!!!!!
//这就是双重if提高效率
if(inc==nullptr)
{
LockGuard mutexguard(_singletonmutex);
LOG(DEBUG) << "获取单例....";
//判断单例指针是否为nullptr,是的话,我们就给它new一下
//毕竟我们是使用懒汉式的!!!
if(inc==nullptr)
{
LOG(DEBUG) << "首次使用单例, 创建!!!";
inc = new ThreadPool<T>();//new会自动转换指针类型
//那么仅仅只是new申请空间就够了吗???
//不够,我们new了之后,只是代表说,有这么一个线程池指针了
//但是线程池里面的线程还没被启动啊!!!!!
//虽然可以外界自己调用单例指针的启动线程函数
//不过我们也可以直接在线程获取到指针的时候就去启动线程池里的线程
//这样子就不用外界每次获取到单例指针之后再去调用启动线程池里的线程的函数
//毕竟想想,我们的单例,其实就是直接就让外界获取到一个可以运行的线程池了
//所以,直接在new之后就去启动线程池就完事了
inc->StartThreadPool();
return inc;
}
return inc;//具体原因上面解释过了
}
return inc;
}
// 析构:没要手动清的资源,空着就行
~ThreadPool() {}
// 启动线程池:改运行状态为运行,启动所有工作线程
bool StartThreadPool()
{
if(_isrunning==true) return true;
_isrunning=true;
for(auto& thread:_threads)
{
thread.Create_Start_Thread();
LOG(INFO)<<"start new thread success:"<<thread.ThreadName();
}
return true;
}
// 停止线程池:改运行状态为停止,唤醒所有线程(避免线程睡死)
bool StopThreadPool()
{
if(_isrunning==false) return true;
_isrunning=false;
WakeUpAllThread();
return true;
}
// 提交任务:线程池运行中才接任务,加锁保护队列,丢完任务唤醒一个线程
void EntryThreadPool(const T& data)
{
if(_isrunning==false) return ;
LockGuard _lockguard(_mutex);
{
_threadpoolblockqueue.push(data);
WakeUpOneThread();
}
}
// 工作线程核心逻辑:死循环拿任务执行
void Handler()
{
char name[128];
pthread_getname_np(pthread_self(), name, sizeof(name));
while(true)
{
T data;
LockGuard _lockguard(_mutex);
{
// 队空且池还在运行:线程休眠(别空转耗资源)
while(_threadpoolblockqueue.empty()&&_isrunning==true)
{
++_sleepthreadnum;
_cond.CondWait(_mutex);
--_sleepthreadnum;
}
// 队空且池停止:线程退出(确保所有任务跑完)
if(_threadpoolblockqueue.empty()&&_isrunning==false)
{
LOG(INFO) << name << " 退出了, 线程池退出&&任务队列为空";
break;
}
// 拿任务:临界区操作,必须加锁
data=_threadpoolblockqueue.front();
_threadpoolblockqueue.pop();
}
data();// 执行任务,放锁外避免卡其他线程
}
}
// 等待所有工作线程退出,回收线程资源
void ThreadPoolJoin()
{
for(auto& thread:_threads)
{
thread.JoinThread();
LOG(INFO)<<"Join thread success:"<<thread.ThreadName();
}
}
private:
int _threadnum=gdefaultthreadnum;// 工作线程总数
std::string _threadname;// 线程池名称(暂未使用)
std::vector<Thread> _threads;// 存所有工作线程的数组
int _sleepthreadnum;// 休眠线程计数
Mutex _mutex;// 互斥锁:保护队列和状态变量,防止抢资源
Cond _cond;// 条件变量:控制线程休眠/唤醒
std::queue<T> _threadpoolblockqueue;// 任务队列:存待执行任务
bool _isrunning;// 线程池运行状态标记
//那么前面说了,要实现单例模式,那么就得有一个全局的,静态的类对象
//又由于我们实现的是懒汉模式,所以,我们就是先创建类对象指针
static ThreadPool<T>* inc;
//然后就是多线程的线程安全了
//那么想想看,要是是多线程呢?即有多个生产者线程呢?
//那么会不会出现多个线程都去获取到单例指针呢???
//而且我们获取到单例指针的函数是要先判断单例指针是否为空
//不为空的话就得new一下,那么要是多线程都访问的话
//不会出现多个线程都new吗?????
//肯定是会的,所以,怎么办???????
//上锁,只允许一个线程去new(为nullptr),一个线程去获取到单例指针(不为nullptr)
//所以,也得创建一个静态的锁
//诶,为什么锁也要创建一个静态的呢,就不能创建类内的锁吗??????
//原因很简单,因为我们类内的获取单例指针的函数,它得是静态函数!!!!!!!!!!!!!!!!
//不错,就是静态函数!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//为什么,为什么,为什么啊啊啊啊啊!!!!!!!!!!
//有人说,啊我要疯了,怎么函数也要静态呢????
//没事,想想这个问题:
//如果把获取到单例指针的函数设置为类内函数,会怎么样呢???????
//答案是,你线程想调用这个函数,不好意思,你得先有该类变量
//然后你才能调用我类的成员函数啊!!!!!!!!!!!!!!!!!!!!!!!
//那么问题又来了,我们是单例模式啊,允许外界创建类变量吗?????????????
//不允许,那线程就说,啊啊啊我要疯了,我得获取到单例指针才行
//可是要想获取到这个单例指针,我还得先有你这个类变量,可是你又不允许我去创建类变量
//你只允许我们使用你已经创建好的单例指针,那你要我怎么办!!!!!!!!!!!!!!!!!!
//所以,怎么才能让线程不用创建类变量就能调用类内的成员函数呢???
//答案是:设置为静态成员函数,那么这个问题就被完美解决了
//然后就是回到上面的那个问题:为什么要创建静态锁?????
//很简单,因为类内的静态成员函数,不允许访问类内的private的成员变量
//所以,你就得用静态锁!!!!!!
//那么,我们一下子就明白了!!!!!!!!!
static Mutex _singletonmutex;
};
//要注意,类的静态成员变量是不能在类中进行初始化的,得在类外进行初始化
//还得指明类域
//那么由于我们的线程池类是模版类,再加上要指明类域
//所以就得创建一个模版了,然后再去指明类域,而后再初始化
template <typename T>
ThreadPool<T>* ThreadPool<T>::inc=nullptr;//刚开始该类对象指针就是nullptr
//类型在前面,然后后面指明类域~~~
//同样的,静态锁的初始化也得在类外
//和上面的单例指针的初始化一样
template <typename T>
Mutex ThreadPool<T>::_singletonmutex;
}
三、核心解析:懒汉式单例线程池的代码深挖(逐函数拆解)
前面铺垫了这么多,终于到了核心部分!我们结合你提供的代码,逐行解析懒汉式单例线程池的实现细节,重点关注单例相关的核心函数(比如GetInc()),同时详细分析每个函数的参数、返回值和内部逻辑------这部分是面试高频考点,一定要吃透!
先明确一下代码结构:我们的单例线程池位于SingletonThreadPoolModlue命名空间下,是一个模板类ThreadPool<T>(模板参数T代表任务类型,必须是可调用对象,比如函数指针、lambda表达式、重载了()运算符的类对象)。核心设计是"懒汉式单例+生产者-消费者模型":用户(生产者)提交任务到队列,工作线程(消费者)从队列中获取任务执行。
3.1 先看"单例骨架":核心成员变量解析
要实现单例,线程池类必须包含几个关键的静态成员和私有成员。我们先把这些"骨架"成员单独拎出来解析,帮你快速建立认知:
cpp
template <typename T>
class ThreadPool
{
private:
// 1. 单例核心:静态私有实例指针(懒汉式)
static ThreadPool<T>* inc;
// 2. 线程安全核心:静态私有互斥锁(保护实例创建过程)
static Mutex _singletonmutex;
// 3. 私有构造函数:堵死外部创建实例的路径
ThreadPool(int threadnum=gdefaultthreadnum);
// 4. 禁用拷贝和赋值:避免外部通过拷贝创建新实例
ThreadPool(const ThreadPool<T>&)=delete;
ThreadPool<T>& operator=(const ThreadPool<T>&)=delete;
// 线程池通用成员(非单例独有关,但需了解)
int _threadnum; // 工作线程总数
std::vector<Thread> _threads; // 存储所有工作线程的容器
int _sleepthreadnum; // 休眠线程数量(用于监控)
Mutex _mutex; // 保护任务队列的互斥锁
Cond _cond; // 控制线程休眠/唤醒的条件变量
std::queue<T> _threadpoolblockqueue; // 任务队列(存储待执行任务)
bool _isrunning; // 线程池运行状态标记(true=运行,false=停止)
public:
// 5. 单例核心:静态公有访问方法(全局唯一入口)
static ThreadPool<T>* GetInc();
// 线程池通用方法(后面会解析)
bool StartThreadPool(); // 启动线程池
bool StopThreadPool(); // 停止线程池
void EntryThreadPool(const T& data); // 提交任务
void Handler(); // 工作线程执行逻辑
void ThreadPoolJoin(); // 等待所有线程退出
~ThreadPool() {} // 析构函数(空实现,后面解释)
};
// 6. 类外初始化静态成员变量(必须!)
template <typename T>
ThreadPool<T>* ThreadPool<T>::inc=nullptr;
template <typename T>
Mutex ThreadPool<T>::_singletonmutex;
3.1.1 单例核心:静态实例指针 inc
-
类型 :
static ThreadPool<T>*(静态模板类指针) -
访问权限:private(私有,只能在类内部访问)
-
初始化位置 :类外初始化,初始值为
nullptr -
核心作用:存储唯一的线程池实例指针,是懒汉式单例的核心。因为是静态成员,所以整个程序运行期间只有一个副本,无论创建多少个"名义上的线程池对象"(实际上外部根本创建不了),都共享这一个指针。
-
关键细节 :为什么用指针而不是对象?因为懒汉式需要"延迟创建实例"------指针可以初始化为
nullptr,表示"实例还没创建";直到第一次调用GetInc()时,才通过new ThreadPool<T>()创建实例并赋值给指针。如果用对象(比如static ThreadPool<T> inc;),程序启动时就会创建实例,这就变成饿汉式了。
3.1.2 线程安全核心:静态互斥锁 _singletonmutex
-
类型 :
static Mutex(静态互斥锁,这里的Mutex是自定义的RAII风格锁,封装了pthread_mutex_t) -
访问权限:private(私有)
-
初始化位置:类外初始化(调用Mutex的默认构造函数,初始化底层的pthread_mutex_t)
-
核心作用 :保护单例实例的创建过程,避免多线程下"重复创建实例"。后面会详细讲解,这里先记住:
GetInc()静态锁是为了配合静态访问方法使用的 ------因为GetInc()是静态方法,只能访问静态成员变量,不能访问非静态的_mutex(_mutex是保护任务队列的,和单例创建无关)。 -
为什么必须是静态的? :非静态锁属于"对象级别的成员",只有创建了对象之后才会存在;而
GetInc()的作用是"创建对象",在创建对象之前,非静态锁还不存在,根本没法用来加锁。所以必须用静态锁------静态锁属于类,程序启动时就会初始化,不管有没有创建对象,都能用来加锁。
3.1.3 其他关键成员:构造函数、拷贝构造、赋值运算符
这三个成员是"堵死外部创建实例路径"的关键,前面已经讲过核心思想,这里结合线程池的场景再补充细节:
-
私有构造函数 :参数是
int threadnum=gdefaultthreadnum(默认值是5,由宏gdefaultthreadnum定义)。作用是初始化线程池的基础资源(比如工作线程容器、休眠线程数、运行状态标记),并创建工作线程(但不启动,启动操作在StartThreadPool()中)。因为是private,外部无法通过new ThreadPool<T>(5)创建实例,只能在类内部(比如GetInc()中)调用。 -
禁用拷贝构造和赋值运算符 :用
delete关键字明确禁用。线程池是"资源密集型"类,包含工作线程、任务队列等资源,拷贝或赋值这些资源会导致资源竞争和状态混乱(比如两个线程池实例共用一个任务队列),所以必须禁用。
3.2 单例"入口":静态访问方法 GetInc() 深度解析(重中之重)
GetInc()是单例模式的"门面",是外部获取线程池实例的唯一入口,其实现直接决定了单例的线程安全性和效率。我们先看完整代码,再逐行拆解:
cpp
static ThreadPool<T>* GetInc()//外界获取到我们封装好的,规定好的单例指针,不要忘记得是静态的
{
// 第一层检查:避免不必要的锁竞争(提高效率)
if(inc==nullptr)
{
// RAII风格锁:作用域结束自动解锁,避免死锁
LockGuard mutexguard(_singletonmutex);
LOG(DEBUG) << "获取单例....";
// 第二层检查:确保只创建一个实例(线程安全核心)
if(inc==nullptr)
{
LOG(DEBUG) << "首次使用单例, 创建!!!";
// 创建线程池实例(调用私有构造函数)
inc = new ThreadPool<T>();
// 创建实例后,直接启动线程池(避免外部额外调用Start方法)
inc->StartThreadPool();
return inc;
}
// 第一层检查通过,但第二层检查发现实例已存在,直接返回
return inc;
}
// 实例已存在,直接返回(无需加锁,提高效率)
return inc;
}
3.2.1 函数基本信息
-
函数类型 :静态成员函数(
static) -
返回值 :
ThreadPool<T>*(线程池实例的指针)------外部通过这个指针操作线程池(比如提交任务、停止线程池)。 -
参数:无参数------因为是全局访问入口,不需要外部传递任何参数,直接调用即可。
-
核心作用:1. 确保整个程序只有一个线程池实例;2. 为外部提供全局访问入口;3. 首次调用时自动创建并启动线程池,简化外部使用流程。
3.2.2 核心设计:双重检查锁定(DCLP)
这是整个函数的灵魂,也是解决"懒汉式线程安全"的经典方案------双重检查锁定(Double-Checked Locking Pattern,简称DCLP)。为什么需要"双重检查"?我们分场景拆解:
场景1:没有双重检查(只有一层if+锁)
如果代码写成这样(注释掉第一层if):
cpp
static ThreadPool<T>* GetInc()
{
// 没有第一层检查,每次调用都要加锁
LockGuard mutexguard(_singletonmutex);
if(inc==nullptr)
{
inc = new ThreadPool<T>();
inc->StartThreadPool();
}
return inc;
}
问题:效率极低 。单例实例只需要创建一次,创建完成后,后续所有调用GetInc()的线程都不需要再进入if逻辑,直接返回实例即可。但上面的代码每次调用都要加锁------加锁/解锁是有开销的(内核态和用户态切换),在高并发场景下,大量线程竞争这把锁会严重影响性能。
场景2:只有一层检查(没有第二层if)
如果代码写成这样(注释掉锁内部的if):
cpp
static ThreadPool<T>* GetInc()
{
if(inc==nullptr) // 只有第一层检查
{
LockGuard mutexguard(_singletonmutex);
// 没有第二层检查,多个线程可能进入这里
inc = new ThreadPool<T>();
inc->StartThreadPool();
}
return inc;
}
问题:线程不安全,可能创建多个实例。我们模拟多线程场景:
-
线程A和线程B同时调用
GetInc(),此时inc == nullptr,两者都通过第一层检查。 -
线程A先抢到锁,进入临界区,创建实例并赋值给
inc,然后解锁。 -
线程B抢到锁,进入临界区------此时
inc已经不是nullptr了,但因为没有第二层检查,线程B会再次执行new ThreadPool<T>(),创建第二个实例,违背单例的"唯一性"要求。
场景3:双重检查锁定(正确方案)
我们的代码正是采用这种方案,两层if(inc==nullptr)配合锁,完美解决了"效率"和"线程安全"的问题:
-
第一层检查(锁外) :作用是"避免不必要的锁竞争"。如果实例已经创建(
inc != nullptr),直接返回实例,不需要加锁------这是提高效率的关键,99%的调用场景都会走这条分支,避免了锁的开销。 -
第二层检查(锁内) :作用是"确保只创建一个实例"。只有当第一层检查通过(
inc == nullptr),说明可能需要创建实例,此时加锁,进入临界区后再检查一次inc是否为nullptr------这是为了防止多个线程同时通过第一层检查,导致重复创建实例。
简单总结:双重检查锁定 = 效率(第一层检查避免锁竞争) + 线程安全(第二层检查避免重复创建)。这是懒汉式单例的标准实现方案,面试时一定要能讲清其原理。
3.2.3 其他关键细节拆解
-
RAII风格锁 LockGuard :代码中用
LockGuard mutexguard(_singletonmutex);加锁,这是RAII(资源获取即初始化)设计模式的应用------LockGuard的构造函数调用pthread_mutex_lock加锁,析构函数调用pthread_mutex_unlock解锁。这样做的好处是"自动解锁",即使临界区内部抛出异常(虽然我们的代码里没有异常,但工业级代码必须考虑),析构函数也会被调用,避免死锁。 -
创建实例后直接启动线程池 :代码中
inc = new ThreadPool<T>();创建实例后,立即调用inc->StartThreadPool();启动线程池。这样做是为了"简化外部使用"------外部只需要调用GetInc()就能获取到一个"已启动、可使用"的线程池,不需要额外调用StartThreadPool(),降低了使用门槛。 -
日志打印的作用 :
LOG(DEBUG) << "获取单例...."和LOG(DEBUG) << "首次使用单例, 创建!!!"是调试用的日志,能帮助我们观察单例的创建过程------比如首次调用时会打印"首次使用单例, 创建!!!",后续调用只会打印"获取单例...."(如果进入锁的话),能直观验证单例是否唯一。
3.3 私有构造函数:线程池的"初始化入口"解析
构造函数是private的,仅能在GetInc()中通过new ThreadPool<T>()调用,其核心职责是"初始化线程池基础资源",为后续启动线程、处理任务打基础,而非直接启动线程(启动逻辑单独放在StartThreadPool()中,符合"单一职责原则")。我们结合代码逐行拆解细节:
cpp
ThreadPool(int threadnum=gdefaultthreadnum)
:_threadnum(threadnum)
,_sleepthreadnum(0)
,_isrunning(false)
{
Use_Monitor_Log();// 日志直接打在显示器上
for(int i=0;i<_threadnum;++i)
{
_threads.emplace_back([this](){ Handler(); });
}
}
3.3.1 成员初始化列表:奠定初始状态
构造函数的初始化列表明确了三个核心成员的初始值,每个值的设置都有其设计考量,缺一不可:
-
_threadnum(threadnum):将传入的线程数(默认值为gdefaultthreadnum=5)赋值给成员变量,确定线程池的工作线程总数。这里支持自定义线程数,满足不同场景需求------比如轻量任务场景用5个线程,大数据处理场景可传入20个线程。 -
_sleepthreadnum(0):初始化休眠线程数为0。休眠线程数是线程池的"监控指标",记录当前处于等待任务状态的线程数量,初始时线程未启动,自然没有休眠线程。 -
_isrunning(false):将线程池运行状态标记为"未运行"。这是关键设计------构造函数仅创建工作线程对象(未启动),避免"创建实例即启动线程"的耦合逻辑;只有调用StartThreadPool()时,才会将_isrunning设为true,唤醒线程开始工作。这种分离设计让线程池的生命周期更可控(比如可先创建实例,后续按需启动)。
3.3.2 日志配置:Use_Monitor_Log()的作用
Use_Monitor_Log()是自定义的日志工具函数,作用是"将线程池的运行日志直接输出到显示器(控制台)",而非写入文件。这样设计的核心目的是"方便调试"------线程池的初始化、任务提交、线程启停等关键操作的日志的实时打印在控制台,开发时能快速定位问题(比如线程是否创建成功、任务是否执行)。在生产环境中,可将其替换为"写入日志文件"的函数(比如前文提到的Log类的WriteLog方法),兼顾调试和生产需求。
3.3.3 核心操作:工作线程的创建(emplace_back+lambda)
for循环是构造函数的核心,用于创建指定数量的工作线程,并存入_threads容器(std::vector<Thread>)。这里有两个高频面试考点,必须讲透:
-
为什么用
emplace_back而非push_back? emplace_back是C++11新增的容器方法,能直接在容器内存中构造对象,避免对象的拷贝/移动开销;而push_back需要先创建Thread对象,再将其拷贝/移动到容器中。线程(Thread)是"不可拷贝、不可移动"的对象(C++11中Thread的拷贝构造和赋值运算符被delete),若用push_back会直接编译报错;而emplace_back直接在vector中构造Thread对象,完美规避此问题。 -
lambda表达式
[this](){ Handler(); }的含义? - 捕获列表[this]:捕获当前ThreadPool实例的指针,因为Handler()是类的非静态成员函数,必须通过实例(this指针)调用(静态成员函数无需实例,但Handler需要访问线程池的成员变量如_taskqueue、_isrunning,因此设计为非静态)。 - 函数体Handler():工作线程的核心执行逻辑(后面会详细解析),每个工作线程创建后,最终会执行Handler()函数,循环获取并处理任务。 简单来说:这行代码的作用是"创建一个工作线程,该线程启动后会执行当前线程池实例的Handler()方法"。
注意:此时创建的Thread对象仅完成"线程对象的构造",并未真正启动线程(C++ Thread对象的构造不会自动启动线程,需调用thread::start()或通过特定构造方式启动,具体取决于自定义Thread类的实现)。线程的启动逻辑统一放在StartThreadPool()中,这是"初始化与启动分离"的设计思想。
OK大家,那么单例模式下的线程池代码也就上面这些会和我们之前的线程池点不同,其他的都一样,所以这里就不进行多余解析了。
结语:以单例之 "独",筑线程之 "稳"------ 技术之路,行则将至
亲爱的读者朋友们,当你读到这里时,想必已经跟着我走完了 "懒汉式单例线程池" 的完整学习之旅。从普通线程池的痛点剖析,到单例模式的核心原理,再到逐行拆解代码中的每一个设计细节,我们用近万字的篇幅,把这个工业级开发中的 "标配技术" 拆解得明明白白。此刻,或许你心中既有 "原来如此" 的通透,也有 "动手实践" 的跃跃欲试 ------ 而这,正是技术学习最美好的状态。
回望整个学习过程,我们其实一直在围绕两个核心问题展开:"为什么要这么设计" 和 "这么设计能解决什么问题"。单例模式不是 "花里胡哨的设计技巧",而是为了解决 "资源浪费""状态不一致""调度混乱" 等实际痛点而生;线程池也不是 "多线程的简单堆砌",而是通过 "生产者 - 消费者模型" 实现线程资源的高效复用。当两者结合,单例的 "唯一性" 保证了线程池的全局统一调度,线程池的 "并发能力" 则让单例模式的价值得以落地 ------ 这便是技术组合的魅力:1+1 远大于 2。
在代码解析的过程中,我们反复琢磨了很多 "看似微小却至关重要" 的细节:为什么单例的访问方法必须是静态的?因为外部无法创建实例,只能通过类级别的方法获取入口;为什么懒汉式必须用双重检查锁定?因为要在 "线程安全" 和 "效率" 之间找到平衡;为什么条件变量等待要用 while 而非 if?因为要抵御系统的 "虚假唤醒";为什么静态成员必须在类外初始化?因为静态成员属于类本身,而非某个实例 ------ 这些细节,恰恰是区分 "会用" 和 "精通" 的关键。
我完全能理解,作为新手,你可能曾在 "双重检查锁定" 的两层 if 判断中困惑,也曾在 "静态锁和普通锁的区别" 里纠结,甚至在 "线程池优雅停止" 的逻辑中迷茫。其实,这正是技术学习的常态 ------ 没有谁能一蹴而就,所有的通透都源于 "反复琢磨" 和 "动手验证"。我至今还记得自己第一次写单例线程池时,因为忘记类外初始化静态锁而导致链接错误,因为误用 if 替代 while 而出现线程卡死,因为没唤醒休眠线程而导致任务无法执行...... 这些踩过的坑,如今都成了我讲解时最想提醒你的 "避坑指南"。
技术学习的本质,就是 "在解决问题中积累经验"。单例线程池的知识点,看似零散(单例模式、多线程同步、模板编程、RAII 设计),但串联起来就是一套完整的 "工程化思维":如何设计一个高可用、高并发、易维护的组件?如何在满足功能的同时,兼顾性能和安全性?如何让代码既符合规范,又能应对实际场景的复杂需求?这些思维能力,远比记住一段代码更重要 ------ 它会帮你在未来遇到 "数据库连接池""全局缓存" 等类似场景时,能够举一反三,快速找到解决方案。
或许有读者会问:"掌握了单例线程池,接下来还能往哪里深入?" 技术的边界永远在延伸,这个组件还有很多可以优化和扩展的方向:比如给任务队列增加优先级(用priority_queue替代queue),让重要任务先执行;比如支持动态调整线程数(根据任务队列长度自动新增或销毁线程),应对流量波动;比如增加任务执行结果回调(通过std::future和std::promise),让调用者能获取任务执行状态;比如加入线程池监控接口(统计任务执行总数、平均耗时、活跃线程数),方便线上问题排查;甚至可以结合配置文件单例,让线程池的参数(默认线程数、队列大小)支持动态加载 ------ 这些扩展,都会让你的代码更具工程价值,也能让你在面试中更有竞争力。
但请记住,技术学习永远是 "循序渐进" 的过程。不必急于求成,先把基础的单例线程池吃透,亲手写一遍代码,跑通每一个流程:提交任务、线程执行、停止线程池,观察日志中的每一条输出,验证单例是否唯一、线程是否安全、任务是否不丢失。当你能独立解决 "多线程并发创建单例""线程池优雅退出" 这些问题时,你对多线程和设计模式的理解,已经超越了很多初级开发者。
在后端开发的道路上,我们会遇到无数类似的 "核心组件"------ 从日志类、配置类,到数据库连接池、缓存系统,它们的设计理念都离不开 "解决实际问题" 这一核心。单例线程池只是一个起点,它教会我们的 "全局思维""并发思维""工程化思维",会成为你未来攻克更复杂技术的基石。或许现在的你,还会在某些细节上犹豫,但请相信:每一次对代码的琢磨,每一次对问题的拆解,每一次成功的运行,都是在为你的技术能力添砖加瓦。
我常常对身边的开发者说:"技术的深度,源于对细节的敬畏。" 单例模式中的 "禁用拷贝构造""静态成员初始化",线程池中的 "锁的粒度控制""条件变量唤醒策略",这些看似不起眼的细节,恰恰是工业级代码和 "玩具代码" 的区别。希望你在未来的开发中,既能 "仰望星空"(把握整体架构),也能 "脚踏实地"(打磨每一个细节)------ 这样写出的代码,才经得起生产环境的考验。
最后,感谢你愿意花时间读完这篇长文。技术学习从来不是一条孤独的路,每一个坚持探索的人,都在彼此陪伴、共同成长。或许你现在还有疑问,或许你在实践中会遇到新的坑,或许你有更好的优化方案 ------ 这些都没关系,技术的魅力就在于 "不断迭代、持续精进"。
请勇敢地打开编译器,把今天学到的知识付诸实践吧!试着用单例线程池搭建一个简单的任务调度系统,或者把它集成到你的项目中,感受它在实际场景中的作用。当你看到自己写的线程池平稳运行,高效处理每一个任务时,你会感受到技术带来的成就感 ------ 这种成就感,会成为你继续前行的最大动力。
技术之路,道阻且长,但行则将至;行而不辍,未来可期。愿你在 C++ 后端开发的道路上,既能深耕细作,也能仰望星空,不断解锁新的技术技能,成为自己心中想成为的 "技术高手"。我们下次再见!