【Linux篇】高并发编程终极指南:线程池优化、单例模式陷阱与死锁避坑实战
深入理解线程池设计与应用:高效并发编程的秘密
- 一. 线程池
- 1.1 什么是线程池
- 1.2 线程池的优点
- 1.3 线程池的应用场景
- 二. 线程池设计
- 三. 单例模式
- 3.1 什么是单例模式
- 3.2 单例模式特点
- 3.3 实现单例模式方法
- 3.3.1 饿汉实现⽅式
- 3.3.2 懒汉实现⽅式
- 四. 线程安全和重入问题
- 4.1 线程安全
- 4.1.1 什么是线程安全?
- 4.2 重入
- 4.2.1 什么是重入?
- 五. 死锁
- 5.1 基本概念
- 5.2 死锁必要条件
- 5.3 如何避免死锁
- 6. STL,智能指针与线程安全的关系
- 6.1 STL中的容器是否是线程安全的?
- 6.2 智能指针是否是线程安全的?
- 七. 最后
一. 线程池
1.1 什么是线程池
线程池(Thread Pool)是一种多线程管理技术,用于提高程序中多线程的执行效率和资源利用率。
具体来说,线程池在程序启动时预先创建一定数量的线程,这些线程处于空闲等待状态。当有任务到来时,线程池从空闲线程中分配一个线程来执行任务,执行完后线程不会被销毁,而是继续回到线程池中等待下一次任务。这样避免了频繁创建和销毁线程带来的性能开销。
可能大家又会有这样的疑问:线程池为啥提高效率,他提前创建一定数量的线程池操作系统要维护它这不是要花费一定的成本吗?
-
避免频繁创建和销毁线程的开销:创建和销毁线程是一个比较耗时且资源消耗大的操作,包括分配栈空间、初始化线程上下文、内核调度等。应用线程池后,线程可以被重复利用,免去了频繁创建和销毁的成本。
-
控制并发线程数量,避免资源过载:线程池固定线程数量,防止大量线程同时运行导致CPU调度压力加大、内存耗尽、上下文切换频繁,从而反而降低性能。
-
减少线程切换开销:线程池里线程数量有限,可以减少操作系统频繁进行线程切换的次数(上下文切换是昂贵的),从而提升CPU利用率和执行效率。
-
快速响应新任务:因为线程已经准备好,任务到达时可以马上被线程执行,无需等待创建线程,提升系统响应速度。
总结:线程池的维护成本是固定且有限的,而节省的线程创建销毁开销及调度管理开销通常远大于维护成本,最终整体提升了系统的性能和效率。
1.2 线程池的优点
线程池的主要优点包括:
-
降低线程创建和销毁的系统开销
-
控制最大并发线程数,防止资源过度消耗
-
提高任务执行的响应速度
-
便于管理和调优多线程环境
1.3 线程池的应用场景
- 高并发服务器
例如Web服务器、数据库服务器、文件服务器等,需要同时处理大量客户端请求,通过线程池复用线程,减少线程创建销毁开销,提高响应速度和吞吐量。
- 异步任务处理
后台任务处理、日志写入、消息队列消费等场景,线程池可以异步执行任务,提高主线程的响应性能,避免阻塞。
- 定时任务调度
定时执行周期性任务时,使用线程池管理执行线程,保证资源利用率和任务调度的稳定性。
二. 线程池设计
想一想在设计线程池之前,我们需要什么变量。
- vector:线程池固定数量线程集合
- _num:线程个数
- queue:任务队列,存放提交的任务
- _cond:条件变量,用于线程等待和唤醒
- _mutex:互斥锁,保护任务队列和状态的同步访问
- _isrunning:线程池运行状态标志,false时停止线程
- _sleepernum:当前处于等待状态(休眠)的线程数量,用于判断是否要唤醒休眠的线程去处理任务
通过上述思考可以得到如下的伪代码:
template <typename T>class ThreadPool{private:std::vector<Thread> _threads; // 插入的lamada表达式会构建Thread类对象int _num; // 线程池中的线程个数std::queue<T> _taskq;Cond _cond;Mutex _mutex;bool _isrunning;int _sleepernum;};
接下来需要创建线程池对象,创建一定数量的线程,并将该线程需要的函数传给指定的线程。而构造函数就可以完成该功能,为了支持泛型编程,我们设计成模版。伪代码如下:
static const int gnum = 5;
template <typename T>
class ThreadPool
{
private:
ThreadPool(int num = gnum): _num(num),_isrunning(false),//线程还未启动_sleepernum(0)//线程休眠个数{for (int i = 0; i < num; i++){_threads.emplace_back([this](){HandlerTask();});}}
};
该构造函数在创建thread对象的时候,还会将HandlerTask()函数赋值至自己的成员变量_func中,完成回调功能。
清理资源,做任何事有始有终。析构函数完成该功能。伪代码如下:
template <typename T>
class ThreadPool
{
public:~ThreadPool(){}
};
往任务队列入任务,如何入任务,已经启动的线程才给他入任务,没有启动的线程给它入任务干嘛,他又不做事,为了保持原子性防止一个任务被多个线程执行,咱们直接加锁,如果线程都在休眠,需要手动唤醒一个线程去处理任务,通过上述思考,得到的伪代码如下:
template <typename T>
class ThreadPool
{
public:bool Equeue(const T &in){if (_isrunning){LockGuard lockguard(_mutex);_taskq.push(in);if (_threads.size() == _sleepernum)WakeUpOne();return true;}return false;}
};
如何启动线程池,将所有线程对象调用pthread_create(),创建线程,建立虚拟地址空间的映射,启动线程前,需要将_isrunning的状态修改为true,因为默认是false,这会影响回调函数处理任务的逻辑,伪代码如下:
template <typename T>
class ThreadPool
{
public:void Start(){if (_isrunning)return; // 线程已启动直接返回即可_isrunning = true; // 不可省略,会导致任务不会被处理for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "start new thread success:" << thread.Name();}}
};
停止及等待线程池,伪代码如下,直接调用接口就行,特别需要注意,在停止所有线程前,需将_isrunning的状态设置为false,方便回调函数将该线程从while跳出,唤醒所有休眠的线程,直接同样的逻辑,伪代码如下:
void Stop(){if (!_isrunning)return;_isrunning = false;// 唤醒所有休眠的线程WakeUpAllThread();}void Join(){for (auto &thread : _threads){thread.Join();}}
唤醒一个和所有休眠线程,直接调用接口即可,伪代码如下:
void WakeUpAllThread(){LockGuard lockguard(_mutex);if (_sleepernum > 0)_cond.Broadcast();LOG(LogLevel::INFO) << "唤醒所有的休眠线程";}void WakeUpOne(){_cond.Signal();LOG(LogLevel::INFO) << "唤醒一个的休眠线程";}
线程池中的线程执行HandlerTask()函数,伪代码如下:
template <typename T>
class ThreadPool
{
public:void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);while (_taskq.empty() && _isrunning){_sleepernum++;_cond.Wait(_mutex);_sleepernum--;}// 内部线程被唤醒if (!_isrunning && _taskq.empty()){LOG(LogLevel::INFO) << name << " 退出了,线程池退出&&任务队列为空";break;}// 一定有任务t = _taskq.front();_taskq.pop();}t(); // 任务已经是私有的,不需要加锁}}
};
- 问题1:while循环条件的必要性???
当线程正在运行且任务队列为空,就需要在条件变量下进行阻塞等待。 - 问题2:如何退出循环???如何理解
当任务队列为空且线程的状态为退出的时候,即可退出循环。 - 问题3:理一下处理任务逻辑
当前线程正在运行,同时任务队列不为空时,才让线程去处理任务。 - 问题4:锁的必要性???
为了保持原子性,防止同一任务被多个线程处理。
三. 单例模式
3.1 什么是单例模式
单例模式(Singleton Pattern)是一种设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。单例模式主要解决的是保证在整个应用程序中,某个类只能有一个对象实例,并且该实例可以被全局访问。
3.2 单例模式特点
-
唯一性:确保类只有一个实例,并且提供一个全局访问点。
-
懒加载:单例实例的创建是延迟的,即在第一次使用时才创建实例。
-
全局访问:通过一个静态方法可以访问该单例实例。
3.3 实现单例模式方法
只允许存在一个类对象实例,所以要将类的构造函数私有化,将构造函数和赋值运算符禁用,外部就无法创建该类的对象了。如:
namespace A
{class B{private:B(std::string name):_name(name) {}std::string _name;};
}
我们现在尝试创建对象,如下图看看有什么问题?
从图中可以看出类的外部不允许创建该类的对象。类的内部是可以创建对象的,只需要创建一个指向该类对象的静态指针或者静态对象,在初始化即可,因为外部无法访问该指针,可以提供一个静态的方法获取单例对象的句柄。下面展示两种方法实现该原理:
3.3.1 饿汉实现⽅式
template <typename T>
class EagerSingleton {
private:static T instance; // 静态实例(直接初始化)// 私有化构造函数/析构函数EagerSingleton() = default;~EagerSingleton() = default;public:// 删除拷贝构造和赋值运算符EagerSingleton(const EagerSingleton&) = delete;EagerSingleton& operator=(const EagerSingleton&) = delete;static T& GetInstance() {return instance; // 直接返回已存在的实例}
};// 静态成员变量初始化(需在头文件外或模板特化中定义)
template <typename T>
T EagerSingleton<T>::instance;
当创建该类对象时,直接创建静态实例,不管它需不需要使用,这就是饿汉模式,可以看出该设计模式浪费空间延迟服务启动,所以需要改进,这就出现了懒汉模式。
3.3.2 懒汉实现⽅式
**#include <iostream>
#include <mutex>template <typename T>
class LazySingleton {
private:static T* instance; // 静态指针(不直接创建对象)static std::mutex mtx; // 互斥锁(线程安全)// 私有化构造函数/析构函数LazySingleton() = default;~LazySingleton() = default;public:// 删除拷贝构造和赋值运算符LazySingleton(const LazySingleton&) = delete;LazySingleton& operator=(const LazySingleton&) = delete;static T* GetInstance() {// 双重检查锁定(Double-Checked Locking)if (!instance) {std::lock_guard<std::mutex> lock(mtx);if (!instance) {instance = new T();// 注册析构函数(防止内存泄漏)static std::atexit([] {delete instance;instance = nullptr;});}}return instance;}
};// 静态成员变量初始化(需在头文件外或模板特化中定义)
template <typename T>
T* LazySingleton<T>::instance = nullptr;template <typename T>
std::mutex LazySingleton<T>::mtx;
当需要该类对象才创建对象,可以看出当真正需要时,才创建对象,核心思想就是延时加载,有点类似于动态库的加载,也是不全部加载,当真正需要某些方法时才绑定关联关系。注意:静态成员变量需要在类外部进行初始化。
四. 线程安全和重入问题
4.1 线程安全
4.1.1 什么是线程安全?
线程安全(Thread Safety)是指多个线程同时访问某个代码片段时,程序能够正常运行而不发生异常或错误的现象。换句话说,线程安全是指程序在多线程环境下,即使有多个线程并发执行,仍能保持数据的一致性和正确性。
4.2 重入
4.2.1 什么是重入?
重入(Reentrancy)是指一个方法或代码块在执行过程中可以被同一个线程再次调用,而不会导致冲突或不一致的情况。换句话说,重入是指当一个线程在执行一个方法时,如果该方法在执行过程中再次被相同线程调用,程序能正确处理这种情况,而不会引起死锁、资源冲突或数据错误。
结论:
- 函数是可重⼊的,那就是线程安全的
- 线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还
未释放则会产⽣死锁,因此是不可重⼊的。
五. 死锁
5.1 基本概念
申请一把锁是原子的,但申请两把锁就不是了,因临界资源需要多个锁才能进入,而不同的线程拥有不同的锁,导致两个或多个线程都在等待其它线程释放锁,造成死等待,进而造成死锁。
举个例子:一家棒棒糖超市老板有且仅有一个棒棒糖售价为1元,李四有0.5元,法外狂徒张三也有0.5元,因为棒棒糖为1元,所以两人都不可以买,李四在等张三把他的0.5元给我,法外狂徒张三也在等李四把他的0.5元给我,两人互不相让这就造成任何一个人都不能拿到棒棒糖,进而导致死锁。
如图所示:
5.2 死锁必要条件
- 互斥条件(Mutual Exclusion)互斥条件指的是资源每次只能被一个线程占用。也就是说,如果一个线程正在使用某个资源,其他线程不能使用该资源,直到该线程释放资源。
- 请求和保持条件(Hold and Wait)请求和保持条件是指一个线程至少持有一个资源,并且正在等待其他线程持有的资源。例如:线程A已经持有资源1,并且正在请求资源2;同时线程B已经持有资源2,并请求资源1。这时,两个线程都会处于等待状态,导致死锁。
- 不剥夺条件(No Preemption)不剥夺条件是指一旦资源被线程占有,其他线程无法强行剥夺该资源,只能由持有该资源的线程自行释放。例如:线程A已经获取了资源1并且正在运行,线程B请求资源1时,它必须等待线程A释放该资源,无法强制中断或剥夺资源。
- 循环等待条件(Circular Wait)
循环等待条件指的是,线程集合中存在一个线程等待其他线程持有的资源,并且这种等待关系形成一个闭环(即循环依赖)。例如:线程A等待资源B,线程B等待资源C,线程C等待资源A,形成一个循环。此时,线程们互相等待,无法继续执行,导致死锁。
5.3 如何避免死锁
直接使上述四个条件任意一个条件不成立即可避免死锁。
6. STL,智能指针与线程安全的关系
6.1 STL中的容器是否是线程安全的?
不是,因为STL容器是将性能挖掘到极致,一旦加锁保证线程安全,会对性能造成巨大影响,因此STL默认不是安全的,如果要保证安全,需要调用者自行保证线程安全。
6.2 智能指针是否是线程安全的?
- 对于unique_ptr,由于只是在当前的代码块范围生效,因此不涉及线程安全问题。
- 对于shared_ptr,多个对象需要共用一个引用计数变量,所以存在线程安全问题。标准库基于原子(CAS)方式保证shared_ptr能够高效引用原子计数,来解决该问题。
七. 最后
本文介绍了线程池、单例模式、线程安全、死锁及STL与智能指针的线程安全性。线程池通过复用线程提升性能,适用于高并发和异步任务场景。单例模式确保类唯一实例,提供全局访问,分饿汉和懒汉两种实现。线程安全指多线程下数据一致性,重入是同一线程多次调用不冲突。死锁由互斥、请求保持、不剥夺、循环等待导致,需破坏任一条件避免。STL容器非线程安全,需自行加锁;智能指针中unique_ptr无此问题,shared_ptr通过原子操作保证引用计数安全。关于Linux系统部分的知识就已经全部更新完毕,下一步进入Linux网络部分,踏入新征程。