线程池 + 单例模式
为什么线程池要做单例
线程池是管理一堆工作线程的资源,一个进程只需要一个线程池实例 。 如果到处
new ThreadPool,会创建多组线程,抢占 CPU 资源,线程爆炸、资源浪费。 所以工程上经常把线程池做成饿汉 / 懒汉单例,全局唯一,整个程序共用这一组工作线程。
本质就是让某一个类对象在内存中只存在一份
1.将磁盘加载到内存,创建对象
int val=100;
2.进程在运行期间,创建对象
int *val=(int *)malloc(sizeof(int));
本质就是让某一个类对象在内存中只存在一份(在运行或者加载期间)
那么如何做到呢???
有懒汉模式和饿汉模式
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭. 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.
首先将特定的类的构造函数,拷贝构造,赋值语句全部时private,甚至拷贝构造,赋值语句
饿汉方式实现单例模式--加载的时候形成对象
cpp
template <typename T>
class Singleton {
static T data;
public:
static T* GetInstance() {
return &data;
}
};
可以优化一下启动速度
懒汉方式实现单例模式--在使用的时候才进程创建
cpp
template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance() {
if (inst == NULL) {
inst = new T();
}
return inst;
}
};
接下来我们来修改一下我们线程池的代码
懒汉模式
赋值和拷贝构造都要禁止

那么我们应该要怎么形成类对象呢??
将上面构造变成私有,让外部调用函数来获取


- 构造私有,外部无法直接构造对象。
- 删除拷贝、赋值,防止复制出第二个实例。
- 懒加载:第一次调用
Instance()才创建线程池,不用就不创建。 - new 完直接
Start(),拿到实例就已经启动好线程,main 不需要手动调用Start()。

在main函数这里就不能用之前的方法
ThreadPool<Task>:模板类 ,指定线程池处理的任务类型是Task::Instance():静态成员函数,属于类,不是属于某个对象。
单例模式:静态函数不需要对象,直接
类名::函数名()调用,返回ThreadPool<Task>*(指针)
->:因为Instance()返回的是对象指针 ,访问成员函数要用箭头->,不能用.Enqueue(t):线程池的入队接口,把任务放进任务队列。
因为单例模式下:
- 构造函数被 private 私有化 ,外部不能直接
ThreadPool<Task> pool;创建局部对象。 - 整个程序只能有唯一一个线程池对象 ,所有地方都必须通过
Instance()拿到那唯一的实例指针。
重点:
Instance()是静态函数,没有对象也可以调用 ,它内部 new 出唯一对象,返回对象的地址(指针)。拿到指针之后,用->调用普通成员Enqueue、Stop、Wait。

现在完成了最简单的方法来实现,但是这里依然存在着很多的问题
问题:只有第一次初始化的时候并发会出事 ,
_instance不为 nullptr 之后,直接 return,完全安全。
- 线程 A:判断
_instance == nullptr→ true,进入 if,时间片切走 - 线程 B:同样判断
_instance == nullptr→ true,也进入 if - 线程 B 执行
new ThreadPool<T>(),_instance被赋值第一个对象地址 - 切回线程 A,继续执行
new ThreadPool<T>(),又 new 第二个对象 ,覆盖_instance
结果:
- 内存泄漏,第一个 new 出来的对象丢失;
- 出现两个线程池实例,系统里多组工作线程,业务完全错乱。
后续再调用 Instance,
_instance已经不为空,直接 return,不会再触发 new,所以后续调用没问题。
所以现在还需要优化加锁


因为我们在处理完一次之后就不用在加锁也不会多创建对象了
接下来我们就可以采用if判断语句来进行优化

线程安全与可重入函数
很多人容易把线程安全 和可重入搞混,二者有关系,但不完全等价。
1、什么是线程安全
多个线程同时跑同一个函数、访问同一份共享数据,程序不会乱、不会出现奇怪结果,就叫线程安全。
- 如果函数里面只用局部变量(栈上,每个线程自己一份),基本不会出问题。
- 如果操作全局变量 /static 静态变量,又不加锁保护,大概率线程不安全。
线程安全解决的是:多线程并发访问共享资源会不会搞坏数据。
2、什么是可重入函数
一个函数还没跑完,又被再次调用进来,这种现象就叫重入。 重入之后结果依旧正确,就是可重入函数;重入之后逻辑错乱,就是不可重入。
重入分两种场景:
- 多线程导致重入:线程 A 函数跑一半,线程 B 又进入同一个函数
- 信号导致重入:函数正在执行,来了信号,信号处理函数再次进入该函数
重点:信号重入是单线程内部发生的,跟多线程没关系,这是考试高频坑。
3、不可重入的典型情况
只要踩下面任意一条,函数就不可重入:
- 内部使用全局、static 静态变量;
- 调用
malloc / free / new,堆管理依靠全局链表; - 调用标准 IO 库
printf、fopen等,内部用全局缓冲区; - 返回静态变量的指针。
通俗理解:函数内部有 "公共的、大家共用的缓冲区 / 变量",一旦重入就会把数据搅乱。
- 可重入:函数中途被打断再进来,结果不能错(重点:信号、递归、并发闯入)
- 线程安全:多个线程一起跑,共享数据不能被破坏(重点:多线程并发)

锁相关概念:死锁
1 什么是死锁
通俗理解:多个线程互相拿着对方想要的锁,谁也不肯放手,大家全部卡住,谁都没法继续往下跑,程序僵住不动,这就是死锁。
举生活例子:
- 线程 A:拿到锁 1,还想要锁 2;
- 线程 B:拿到锁 2,还想要锁 1。
2 死锁四个必要条件
- 互斥条件 锁本身就是互斥资源,同一时间只能一个线程持有锁;别人拿不到只能等待。
- 请求与保持(占有且等待) 线程已经拿着一把锁,不释放手里的锁,又去申请新的锁。
A 拿着 m1,不去释放,又要申请 m2。
- 不可剥夺 锁不能被别人强行抢走。只能由持有锁的线程自己主动 unlock 释放,别的线程不能把锁抢过来。
- 环路等待 形成等待闭环:A 等 B 的锁,B 又在等 A 的锁,形成循环等待链条。
记住:四个条件全部成立才发生死锁;破坏任意一个,死锁就不会出现。
3 如何避免死锁,对应破坏四个条件
- 破坏请求与保持:一次性申请所有锁 线程要用到多把锁时,一次性把需要的全部锁拿到手,不拿着一部分再去申请另一部分;拿不全就一把都不拿。
- 破坏环路等待:统一锁的申请顺序(最常用) 所有线程拿锁必须严格按照相同顺序获取锁。
不管线程 A 还是线程 B,都必须先拿 m1,再拿 m2。就不会出现 A 拿 m1、B 拿 m2 的闭环。工程中最实用。
- 破坏不可剥夺:拿不到锁就主动释放已经持有的锁 如果申请新锁失败,就把手上已经拿到的锁全部释放,过一会儿再重试。调用try lock ,pthread_mutex 本身不支持,需要自己业务控制。
- 破坏互斥条件:尽量不用锁 能不用共享资源就不用,使用局部变量、无锁数据结构。很多场景做不到,现实中很少用。
小总结
死锁 = 互斥 + 请求保持 + 不可剥夺 + 环路等待,四者缺一不可。 写代码最容易落地的手段:统一加锁顺序,减少锁嵌套。
STL 容器与智能指针的线程安全
1 STL 容器是不是线程安全?
结论:STL 容器默认不是线程安全,需要我们自己加锁保护。
为什么标准库不帮我们加锁?
STL 追求极致性能。加锁会带来不小的性能开销。 不同业务场景锁的策略也不一样: 比如 unordered_map 哈希表,可以锁整个表,也可以只锁单个桶。标准库不知道我们的业务,没法选最合适的锁策略。 所以 C++ 标准把锁的责任交给使用者自己。
STL 容器的安全边界
- 多个线程,只读不写(只调用 size、find、at 等查询接口),不做插入、删除、修改,这种情况是安全的。
- 只要有一个线程在做写操作(insert、push_back、erase、clear),不管其他线程是读还是写,都不安全,会发生数据竞争。 会出现:程序崩溃、死循环、脏数据、迭代器失效
2 智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题. 对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时 候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.