线程进阶:线程池、单例模式与线程安全深度解析
前言:
上一篇我们讲解了线程互斥与同步的核心原理,解决了多线程 “抢资源” 和 “按顺序执行” 的问题。但在实际开发中,还会遇到更复杂的场景:如何高效管理大量线程?如何保证全局唯一实例的线程安全?如何区分线程安全与可重入函数?今天我们就聚焦线程池设计、单例模式、线程安全与重入、死锁规避这四大核心,结合实战代码,帮你打通多线程进阶的 “任督二脉”。
一、线程池:高效管理线程的 “线程工厂”
在高并发场景(比如 Web 服务器处理大量请求)中,频繁创建和销毁线程会带来巨大的性能开销(线程创建需要分配栈空间、内核数据结构,销毁需要回收资源)。线程池通过预先创建一批线程,让它们重复执行任务,避免了线程频繁创建销毁的开销,同时还能防止线程过多导致的调度混乱。
1.1 线程池的核心概念与应用场景
1. 核心定义
线程池是一种 “线程复用” 模式:维护一个线程队列,线程空闲时从任务队列中获取任务执行,执行完后不销毁,继续等待下一个任务。
2. 关键组成
- 线程队列:预先创建的线程集合,负责执行任务;
- 任务队列:存放待执行的任务(比如 HTTP 请求、数据计算任务);
- 管理者:负责初始化线程、分配任务、销毁线程池。
3. 应用场景
- 任务数量多且单个任务执行时间短(如 Web 服务器处理请求);
- 对响应速度要求高(避免线程创建耗时影响性能);
- 突发性大量请求(防止瞬间创建过多线程导致内存溢出)。
1.2 线程池设计:固定线程数版本(C++ 实现)
我们设计一个固定线程数的线程池(默认 10 个线程),支持任务入队、线程启动、线程池停止等核心功能,并结合之前封装的日志、锁、条件变量模块,让代码更健壮。
1. 依赖模块引入
首先引入之前封装的工具类(Lock.hpp、Cond.hpp、Log.hpp),确保线程安全和日志打印:
LockModule
:提供互斥量(Mutex)和 RAII 锁守卫(LockGuard);CondModule
:提供条件变量(Cond),用于线程等待任务;LogModule
:提供日志功能,记录线程池初始化、任务执行等信息。
2. 线程池完整代码(ThreadPool.hpp)
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include <pthread.h>
#include "Log.hpp" // 日志模块
#include "Lock.hpp" // 锁模块
#include "Cond.hpp" // 条件变量模块
#include "Thread.hpp" // 线程封装模块(假设已实现线程创建/启动/等待)using namespace LockModule;
using namespace CondModule;
using namespace LogModule;// 默认线程数(可根据CPU核心数调整)
const static int gdefaultthreadnum = 10;template <typename T> // 模板支持任意类型的任务(如函数对象、自定义任务类)
class ThreadPool {
private:// 1. 任务处理函数:线程的核心逻辑(从任务队列取任务执行)void HandlerTask() {// 获取当前线程名称(用于日志区分)std::string name = GetThreadNameFromNptl(); LOG(LogLevel::INFO) << name << " is running..."; // 日志:线程启动while (true) {// 加锁:保护任务队列访问_mutex.Lock();// 等待条件:任务队列为空且线程池未停止// 用while防止伪唤醒(操作系统可能无信号唤醒线程)while (_task_queue.empty() && _isrunning) {_waitnum++; // 等待线程数+1_cond.Wait(_mutex); // 释放锁并等待,唤醒后重新加锁_waitnum--; // 等待线程数-1}// 退出条件:线程池已停止且任务队列为空(处理完所有剩余任务)if (_task_queue.empty() && !_isrunning) {_mutex.Unlock(); // 解锁后退出break;}// 取任务:任务队列非空,取出队首任务T task = _task_queue.front();_task_queue.pop();_mutex.Unlock(); // 解锁:任务执行不需要持有锁,提升并发// 执行任务(任务是线程独占的,无需加锁)LOG(LogLevel::DEBUG) << name << " get a task";task(); // 任务执行(假设T是可调用对象,如std::function<void()>)}}// 禁用拷贝构造和赋值:线程池是全局资源,不允许拷贝ThreadPool(const ThreadPool<T>&) = delete;ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;public:// 2. 构造函数:初始化线程数、等待线程数、线程池运行状态ThreadPool(int threadnum = gdefaultthreadnum) : _threadnum(threadnum), _waitnum(0), _isrunning(false) {LOG(LogLevel::INFO) << "ThreadPool Construct()"; // 日志:线程池创建}// 3. 初始化线程池:创建线程(不启动,等待Start()调用)void InitThreadPool() {for (int i = 0; i < _threadnum; ++i) {// 绑定任务处理函数HandlerTask到线程(std::bind绑定this指针)_threads.emplace_back(std::bind(&ThreadPool::HandlerTask, this));LOG(LogLevel::INFO) << "init thread " << _threads.back().Name() << " done";}}// 4. 启动线程池:启动所有预先创建的线程void Start() {_isrunning = true; // 标记线程池为运行状态for (auto& thread : _threads) {thread.Start(); // 启动线程(调用pthread_create)LOG(LogLevel::INFO) << "start thread " << thread.Name() << " done";}}// 5. 停止线程池:唤醒所有等待线程,标记停止状态void Stop() {_mutex.Lock();_isrunning = false; // 标记线程池停止_cond.NotifyAll(); // 唤醒所有等待任务的线程_mutex.Unlock();LOG(LogLevel::DEBUG) << "线程池退出中...";}// 6. 等待线程池退出:等待所有线程执行完任务后退出void Wait() {for (auto& thread : _threads) {thread.Join(); // 等待线程结束(调用pthread_join)LOG(LogLevel::INFO) << thread.Name() << " 退出...";}}// 7. 任务入队:将任务添加到任务队列,唤醒等待线程bool Enqueue(const T& task) {_mutex.Lock();// 线程池未运行时,拒绝入队if (!_isrunning) {_mutex.Unlock();return false;}// 任务入队_task_queue.push(task);// 有等待线程时,唤醒一个线程处理任务(避免无效唤醒)if (_waitnum > 0) {_cond.Notify();}LOG(LogLevel::DEBUG) << "任务入队列成功";_mutex.Unlock();return true;}// 析构函数:空实现(线程释放通过Wait()处理)~ThreadPool() {}private:int _threadnum; // 线程池中的线程数std::vector<Thread> _threads; // 线程队列(存储预先创建的线程)std::queue<T> _task_queue; // 任务队列(存储待执行任务)Mutex _mutex; // 保护任务队列和线程池状态的互斥量Cond _cond; // 条件变量:用于线程等待任务int _waitnum; // 等待任务的线程数(优化唤醒逻辑)bool _isrunning; // 线程池运行状态(true=运行,false=停止)
};
3. 代码核心逻辑解析
- 任务处理(HandlerTask):
- 线程启动后进入循环,加锁检查任务队列;
- 任务队列为空时,调用
_cond.Wait(_mutex)
释放锁并等待(避免忙等); - 被唤醒后重新加锁,判断线程池是否停止:若停止且任务队列为空,线程退出;否则取出任务执行。
- 线程池启动(Start):
- 标记
_isrunning=true
,启动所有线程(线程开始从任务队列取任务); - 初始化线程时用
std::bind
绑定HandlerTask
和this
指针,确保线程能访问线程池的成员变量。
- 标记
- 任务入队(Enqueue):
- 加锁保护任务队列,防止多线程同时入队导致数据错乱;
- 入队后若有等待线程,唤醒一个线程处理任务(避免所有线程都等待)。
- 线程池停止(Stop):
- 标记
_isrunning=false
,唤醒所有等待线程(让线程检查退出条件); - 调用
Wait()
等待所有线程执行完剩余任务后退出,确保资源正常释放。
- 标记
1.3 线程池测试:执行自定义任务
我们用一个简单的 “下载任务” 测试线程池,任务逻辑是打印 “this is a task”,并通过日志观察线程池运行过程。
测试代码(main.cc)
#include <iostream>
#include <functional>
#include <unistd.h>
#include "ThreadPool.hpp"
#include "Log.hpp"using namespace LogModule;
// 任务类型:函数对象(std::function<void()>)
using task_t = std::function<void()>;// 自定义任务:模拟下载操作
void DownLoadTask() {std::cout << "this is a task" << std::endl;
}int main() {// 启用控制台日志(方便调试)ENABLE_CONSOLE_LOG_STRATEGY();// 1. 创建线程池(默认10个线程)ThreadPool<task_t> pool;// 2. 初始化线程池(创建线程)pool.InitThreadPool();// 3. 启动线程池(线程开始等待任务)pool.Start();// 4. 入队10个任务(每秒入队1个)int task_cnt = 10;while (task_cnt--) {pool.Enqueue(DownLoadTask); // 任务入队sleep(1); // 模拟任务产生间隔}// 5. 停止线程池(唤醒所有线程,标记停止)pool.Stop();// 6. 等待所有线程退出pool.Wait();return 0;
}
编译命令(需支持 C++17)
g++ main.cc -std=c++17 -lpthread -o thread_pool_test
运行日志(部分)
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [62] - ThreadPool Construct()
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread Thread-0 done
[206342] [ThreadPool.hpp] [70] - init thread Thread-1 done
...(初始化10个线程)
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread Thread-0 done
[206342] [ThreadPool.hpp] [79] - start thread Thread-1 done
...(启动10个线程)
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-0 is running...
[206342] [ThreadPool.hpp] [28] - Thread-1 is running...
...(线程等待任务)
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [109] - 任务入队列成功
[206342] [ThreadPool.hpp] [52] - Thread-0 get a task
this is a task
...(任务执行)
[2024-08-04 15:09:39] [DEBUG] [206342] [ThreadPool.hpp] [88] - 线程池退出中...
[206342] [ThreadPool.hpp] [95] - Thread-0 退出...
[206342] [ThreadPool.hpp] [95] - Thread-1 退出...
...(所有线程退出)
1.4 进阶:单例模式的线程池
在实际开发中,线程池通常是全局唯一的(避免创建多个线程池浪费资源),因此需要结合单例模式,确保整个进程中只有一个线程池实例。
单例模式的核心要求
- 全局唯一实例;
- 线程安全(多线程同时获取实例时不会创建多个对象);
- 延迟初始化(用到时才创建,优化程序启动速度)。
单例线程池代码(修改 ThreadPool.hpp)
在原有线程池基础上,添加单例相关逻辑:
template <typename T>
class ThreadPool {
private:// 单例模式:静态成员变量(存储唯一实例)static ThreadPool<T>* _instance;static Mutex _instance_lock; // 保护实例创建的互斥量// 私有构造函数:防止外部创建实例ThreadPool(int threadnum = gdefaultthreadnum) : _threadnum(threadnum), _waitnum(0), _isrunning(false) {LOG(LogLevel::INFO) << "ThreadPool Construct()";}// ...(其他私有方法不变,如HandlerTask)public:// 单例模式:获取唯一实例(线程安全)static ThreadPool<T>* GetInstance() {// 双重检查空指针:第一重避免不必要的加锁(提升性能)if (_instance == nullptr) {LockGuard lockguard(_instance_lock); // 加锁:保护实例创建// 第二重检查:防止多线程同时通过第一重检查后重复创建if (_instance == nullptr) {_instance = new ThreadPool<T>(); // 创建实例_instance->InitThreadPool(); // 初始化线程_instance->Start(); // 启动线程池LOG(LogLevel::DEBUG) << "创建线程池单例";}}LOG(LogLevel::DEBUG) << "获取线程池单例";return _instance;}// ...(其他公有方法不变,如Stop、Enqueue)
};// 静态成员变量初始化(类外初始化)
template <typename T>
ThreadPool<T>* ThreadPool<T>::_instance = nullptr;template <typename T>
Mutex ThreadPool<T>::_instance_lock;
单例线程池使用方式
// 无需手动创建,直接通过GetInstance()获取
ThreadPool<task_t>::GetInstance()->Enqueue(DownLoadTask);
// 停止线程池
ThreadPool<task_t>::GetInstance()->Stop();
// 等待线程池退出
ThreadPool<task_t>::GetInstance()->Wait();
关键设计点
- 双重检查锁(DCLP):
- 第一重
if (_instance == nullptr)
:避免每次获取实例都加锁,提升性能; - 第二重
if (_instance == nullptr)
:防止多线程同时通过第一重检查后,重复创建实例。
- 第一重
- 静态互斥量:保护实例创建过程,确保线程安全;
- 私有构造函数:防止外部通过
new
创建实例,确保全局唯一。
二、线程安全与可重入:多线程编程的 “避坑指南”
在多线程开发中,“线程安全” 和 “可重入” 是两个高频概念,也是最容易踩坑的地方。很多开发者会混淆两者,其实它们既有联系也有明确区别。
2.1 核心概念辨析
1. 线程安全(Thread Safety)
- 定义:多个线程并发访问同一资源时,程序能正确执行,不会出现数据错乱或逻辑错误。
- 核心场景:共享资源的访问(如全局变量、静态变量、文件)。
- 示例:加锁保护的卖票系统是线程安全的;未加锁的卖票系统会卖出负数票,线程不安全。
2. 可重入(Reentrancy)
- 定义:同一个函数被多个执行流(线程或信号处理函数)调用,前一个执行流未完成,后一个执行流再次进入,最终结果仍正确。
- 核心场景:函数被重入时,不会依赖或修改全局 / 静态状态。
- 示例:
strlen
(仅操作输入参数,无全局状态)是可重入函数;rand
(依赖全局种子变量)是不可重入函数。
2.2 线程安全 vs 可重入:联系与区别
维度 | 线程安全(Thread Safety) | 可重入(Reentrancy) |
---|---|---|
关注对象 | 多线程并发访问共享资源的正确性 | 函数被重入时的执行结果正确性 |
依赖条件 | 通常通过加锁保护共享资源实现 | 函数不依赖全局 / 静态变量、不调用不可重入函数 |
联系 | 可重入函数一定是线程安全的(无共享状态,无需加锁) | 线程安全函数不一定是可重入的(加锁可能导致死锁) |
示例 | 加锁的全局变量操作函数 | memcpy (仅操作输入缓冲区) |
关键结论
- 可重入 → 线程安全:可重入函数没有共享状态,多线程调用不会冲突;
- 线程安全 ≠ 可重入:线程安全函数可能通过加锁实现,但如果函数被重入时锁未释放,会导致死锁(如信号处理函数调用加锁的线程安全函数)。
2.3 常见线程不安全 / 不可重入场景
1. 线程不安全的情况
- 不保护共享变量的函数(如未加锁的全局变量自增);
- 函数状态随调用变化(如
rand
,每次调用修改全局种子); - 返回静态变量指针的函数(如
strtok
,返回静态分割结果指针); - 调用线程不安全函数的函数(如 A 函数调用未加锁的 B 函数)。
2. 不可重入的情况
- 调用
malloc/free
(malloc
用全局链表管理堆内存); - 调用标准 I/O 函数(如
printf
,依赖全局缓冲区); - 函数内使用静态 / 全局变量(如
static int count; count++
); - 函数内未释放锁就再次调用自身(递归加锁导致死锁)。
2.4 如何编写线程安全 / 可重入函数
1. 线程安全函数编写原则
- 共享资源必须加锁保护(用互斥量或原子操作);
- 避免使用全局 / 静态变量,若必须使用,需确保原子访问;
- 接口设计为原子操作(如 “判断 + 修改” 需整体加锁)。
2. 可重入函数编写原则
- 不使用全局 / 静态变量,所有数据由调用者提供;
- 不调用
malloc/free
或标准 I/O 函数; - 不返回静态 / 全局数据的指针;
- 若使用锁,确保重入时锁已释放(避免递归加锁)。
三、死锁:多线程编程的 “致命陷阱”
死锁是多线程开发中最危险的问题之一 —— 多个线程互相持有对方需要的资源,永远阻塞等待,导致程序卡死。
为了方便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问
申请⼀把锁是原子的,但是申请两把锁就不⼀定了
造成的结果是
3.1 死锁的四个必要条件
死锁的发生必须同时满足以下四个条件,只要破坏其中一个,就能避免死锁:
-
互斥条件:资源只能被一个线程持有(如锁只能被一个线程获取);
-
请求与保持条件:线程持有已获取的资源,同时请求新资源;
-
不剥夺条件:线程已获取的资源不能被强行剥夺(只能主动释放);
-
循环等待条件:多个线程形成资源等待循环(如 A 等 B 的资源,B 等 A 的资源)。
3.2 如何避免死锁
实际开发中,避免死锁的核心是破坏循环等待条件,常见方法如下:
1. 按固定顺序加锁
多个线程获取多把锁时,按统一的顺序加锁。比如线程 A 和 B 都需要锁 1 和锁 2,约定 “先加锁 1,再加锁 2”,避免循环等待。
2. 资源一次性分配
线程启动时一次性获取所有需要的资源,避免 “持有部分资源再请求其他资源”(破坏请求与保持条件)。
3. 使用超时机制
调用加锁函数时设置超时时间(如pthread_mutex_timedlock
),超时后释放已持有资源,重新尝试(破坏不剥夺条件)。
4. 实战示例:按固定顺序加锁避免死锁
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;mutex mtx1, mtx2; // 两把锁// 线程1:按“锁1→锁2”顺序加锁
void thread1_func() {lock_guard<mutex> lock1(mtx1); // 先加锁1lock_guard<mutex> lock2(mtx2); // 再加锁2cout << "Thread 1: 获取锁1和锁2" << endl;
}// 线程2:同样按“锁1→锁2”顺序加锁(避免循环等待)
void thread2_func() {lock_guard<mutex> lock1(mtx1); // 先加锁1lock_guard<mutex> lock2(mtx2); // 再加锁2cout << "Thread 2: 获取锁1和锁2" << endl;
}int main() {thread t1(thread1_func);thread t2(thread2_func);t1.join();t2.join();return 0;
}
3.3 死锁检测与恢复(了解)
如果无法完全避免死锁,可通过以下机制检测和恢复:
- 死锁检测算法:系统定期检查线程资源等待图,若存在环则判定死锁;
- 银行家算法:分配资源前检查是否会导致死锁,若会则拒绝分配;
- 恢复策略:检测到死锁后,终止一个或多个线程,释放资源(如选择优先级最低的线程)。
四、STL、智能指针与线程安全
在 C++ 开发中,我们经常使用 STL 容器和智能指针,它们的线程安全特性直接影响多线程程序的正确性。
4.1 STL 容器的线程安全
结论:STL 容器默认不线程安全
原因如下:
- 性能优先:STL 的设计目标是极致性能,加锁会带来额外开销(如 vector 的 push_back 每次加锁会降低效率);
- 灵活性:不同场景下锁的粒度不同(如 hash 表可锁整个表或单个桶),STL 无法统一适配;
- 责任转移:STL 将线程安全的责任交给调用者,由调用者根据需求加锁。
如何让 STL 容器线程安全?
在多线程访问 STL 容器时,需手动加锁保护:
#include <vector>
#include <mutex>
using namespace std;vector<int> vec;
mutex vec_mtx;// 线程安全的vector插入
void safe_push_back(int val) {lock_guard<mutex> lock(vec_mtx);vec.push_back(val);
}// 线程安全的vector访问
int safe_at(int idx) {lock_guard<mutex> lock(vec_mtx);return vec.at(idx);
}
4.2 智能指针的线程安全
智能指针的线程安全特性分两种情况:
1. unique_ptr:线程安全
- 原因:
unique_ptr
是独占所有权的智能指针,同一时间只有一个unique_ptr
指向对象,且不允许拷贝(仅支持移动),不存在多线程共享的场景,因此天生线程安全。
2. shared_ptr:部分线程安全
- 线程安全点:
shared_ptr
的引用计数操作是线程安全的(标准库通过原子操作实现,如 CAS),多线程同时拷贝shared_ptr
(增加引用计数)或析构(减少引用计数)不会导致计数错乱; - 线程不安全点:
shared_ptr
指向的对象本身不是线程安全的,多线程访问对象时需额外加锁。
示例:shared_ptr 的线程安全与不安全场景
#include <memory>
#include <thread>
#include <mutex>
using namespace std;// 共享的shared_ptr(指向int)
shared_ptr<int> sp = make_shared<int>(0);
mutex obj_mtx; // 保护int对象的互斥量// 线程1:修改shared_ptr指向的对象(需加锁)
void thread1() {for (int i = 0; i < 1000; ++i) {lock_guard<mutex> lock(obj_mtx); // 保护int对象(*sp)++; // 修改对象,线程不安全,需加锁}
}// 线程2:拷贝shared_ptr(引用计数操作线程安全)
void thread2() {for (int i = 0; i < 1000; ++i) {shared_ptr<int> temp = sp; // 拷贝,引用计数原子增加(线程安全)}
}int main() {thread t1(thread1);thread t2(thread2);t1.join();t2.join();cout << *sp << endl; // 输出1000(正确)return 0;
}
五、总结:多线程进阶核心要点
- 线程池:通过线程复用提升性能,核心是 “线程队列 + 任务队列”,结合单例模式实现全局唯一管理;
- 线程安全与可重入:可重入函数一定线程安全,线程安全函数不一定可重入,编写时避免全局 / 静态状态;
- 死锁规避:破坏死锁的四个必要条件(优先按固定顺序加锁);
- STL 与智能指针:STL 容器默认不线程安全,需手动加锁;
shared_ptr
引用计数线程安全,但指向的对象需额外保护。
掌握这些知识点后,你就能应对大部分多线程开发场景,比如设计高性能的 Web 服务器、并发数据处理系统等。后续我们还会深入讲解更复杂的同步机制(如读写锁、自旋锁),敬请关注!