简介:这是一份面向C++后端与Windows平台开发者的线程池实现源码,针对移动设备与管理中心高并发请求场景下频繁创建、销毁线程导致系统开销过大的问题,给出了一套可复用的线程池解决方案。资源包共41个文件,以16个.h头文件与15个.cpp源文件为核心,涵盖线程封装、互斥锁、工作线程、线程管理与异常处理等模块,另含dsp、dsw等VC工程文件及ncb、opt等编译辅助文件,整体约54KB,结构紧凑便于直接嵌入现有工程。内容包含ThreadPoolDLL与MyThreadPool两套示例工程,并配有TestClass与processmain等测试入口,方便读者理解线程调度、任务队列与同步机制的具体落地方式。目前已有337人学习下载,适合希望掌握并发编程、优化服务端资源调度的中高级开发者参考借鉴。
1. 线程池不是万能药:为什么你的 VC 程序还在反复创建线程
很多做 Windows C++ 的同行,第一次接触「VC 实现的线程池」这个题目,往往是因为线上出了事:某个服务在并发请求上来之后,CPU 没跑满,内存却先炸了,日志里全是CreateThread失败或者句柄泄漏。回头一看代码,每个任务来一发_beginthreadex,任务结束线程退出,短时间几千个线程反复创建销毁,内核对象分配和栈内存(默认 1MB 保留)直接把进程拖垮。线程池要解决的就是这个:把线程的创建和销毁从「每任务一次」变成「进程生命周期内有限次」,任务排队复用固定数量的工作线程。
这个方向适合两类人:一是写 Windows 服务端、采集端、工控上位机的 C++ 开发者,需要稳定可控的并发;二是正在准备把裸线程代码重构成线程池、但不确定参数怎么设、退出怎么收尾的人。下面按「先立住模型,再动手复现,最后讲坑」的顺序推。核心词会反复落在 VC 线程池、工作线程、任务队列、条件变量这些具体对象上,不空谈。
2. 线程池的骨架:任务队列、工作线程与同步原语怎么选
2.1 为什么是「队列 + 固定线程」而不是「来一个起一个」
线程池的本质是一个生产者-消费者模型。生产者是提交任务的业务线程,消费者是池子里预先创建好的工作线程,中间靠一个任务队列解耦。固定线程数的好处是资源可预测:线程栈、内核对象、TLS 槽位都是有限且提前分配的,不会因为突发流量把系统资源吃光。代价是任务会排队,所以队列容量和拒绝策略必须想清楚。
在 VC 环境下,同步原语的选择直接决定实现复杂度。常见做法有三种组合:
| 方案 | 同步原语 | 适用场景 | 注意点 |
|---|---|---|---|
| A | CRITICAL_SECTION+ 信号量 | 任务量中等,追求简单 | 信号量计数要和队列长度严格一致 |
| B | CRITICAL_SECTION+ 条件变量 | 需要精确唤醒、支持退出 | 条件变量在 Windows 上要用CONDITION_VARIABLE |
| C | 无锁队列 + 事件 | 超高吞吐、任务极短 | 实现难度大,ABA 问题要处理 |
我一般会选方案 B。CONDITION_VARIABLE配合CRITICAL_SECTION能干净地表达「队列空则等待、有任务则唤醒、退出则广播」三种状态,比信号量少一层计数维护,出错概率低。方案 A 的坑在于信号量ReleaseSemaphore和入队不是原子的,一旦顺序写反,工作线程可能先等到信号量却发现队列是空的。
2.2 用_beginthreadex而不是CreateThread
这是 VC 线程池实现里最容易被忽略的一条。CreateThread是 Win32 API,它不知道 C 运行时的存在。如果你的线程函数里用了malloc、strtok、errno这类 CRT 函数,用CreateThread创建的线程不会初始化 CRT 的每线程数据,轻则内存泄漏,重则直接崩。正确做法是用_beginthreadex,它内部会调用CreateThread并额外初始化 CRT 的线程局部存储。
// 工作线程入口,必须用 unsigned __stdcall 签名配合 _beginthreadex unsigned __stdcall WorkerProc(void* arg) { ThreadPool* pool = static_cast<ThreadPool*>(arg); pool->WorkerLoop(); // 真正的循环逻辑在类内部 return 0; } // 创建线程时保存句柄,退出时要 WaitForSingleObject + CloseHandle HANDLE h = (HANDLE)_beginthreadex( nullptr, // 安全属性,默认 0, // 栈大小,0 表示用默认 1MB WorkerProc, // 线程函数 this, // 传 this 指针 0, // 创建标志,0 表示立即运行 &threadId); // 输出线程 ID参数说明:第二个参数栈大小填 0 走默认值,如果任务里有深递归可以显式给大一点,但别超过 8MB,否则线程一多虚拟地址空间吃紧。第五个参数如果传CREATE_SUSPENDED,线程创建后挂起,需要手动ResumeThread,线程池初始化时一般不用,直接跑起来等任务即可。_beginthreadex返回的是uintptr_t,要强转成HANDLE才能用于等待和关闭。
2.3 任务怎么封装:std::function还是自定义基类
任务对象的封装决定了线程池的通用性。两种主流做法:
- 用
std::function<void()>加 lambda,提交时Push([=]{ ... }),写起来最舒服,但每次提交可能有堆分配,任务极短时开销占比高。 - 定义一个
ITask基类,带Run()虚函数,提交指针。零额外分配,但每个任务要写一个类,啰嗦。
VC 环境如果用的是 VS2015 及以上,std::function可用且稳定,我倾向用它,代码可读性优先。如果任务频率是每秒几十万次这种量级,再考虑基类方案或者对象池复用任务包装。下面是最小任务队列的定义:
#include <functional> #include <deque> #include <Windows.h> class TaskQueue { public: void Push(std::function<void()> task) { EnterCriticalSection(&cs_); queue_.push_back(std::move(task)); LeaveCriticalSection(&cs_); WakeConditionVariable(&cv_); // 唤醒一个等待的工作线程 } // 返回 false 表示池已关闭且队列已空,工作线程应退出 bool Pop(std::function<void()>& task) { EnterCriticalSection(&cs_); while (queue_.empty() && !closed_) { SleepConditionVariableCS(&cv_, &cs_, INFINITE); } if (queue_.empty() && closed_) { LeaveCriticalSection(&cs_); return false; } task = std::move(queue_.front()); queue_.pop_front(); LeaveCriticalSection(&cs_); return true; } void Close() { EnterCriticalSection(&cs_); closed_ = true; LeaveCriticalSection(&cs_); WakeAllConditionVariable(&cv_); // 广播唤醒所有线程去检查退出条件 } private: std::deque<std::function<void()>> queue_; CRITICAL_SECTION cs_; CONDITION_VARIABLE cv_; bool closed_ = false; };逻辑说明:Push在锁内改队列,锁外唤醒,避免唤醒后线程立刻抢锁又阻塞。Pop用while而不是if包住等待,这是条件变量的铁律——被唤醒不代表条件一定成立,可能被其他线程抢先消费了任务。Close用WakeAllConditionVariable而不是WakeConditionVariable,因为关闭时要让所有工作线程都醒来走退出分支,只唤醒一个是叫不醒全部人的。
3. 把线程池跑起来:初始化、提交、退出的完整实现
3.1 线程池类的成员与初始化顺序
一个能用的 VC 线程池类,成员大致分三块:线程句柄数组、任务队列、状态标志。初始化顺序有讲究,CRITICAL_SECTION必须先InitializeCriticalSection再使用,线程句柄数组要在启动线程前分配好。
class ThreadPool { public: explicit ThreadPool(size_t threadCount) : stop_(false) { InitializeCriticalSection(&cs_); InitializeConditionVariable(&cv_); for (size_t i = 0; i < threadCount; ++i) { HANDLE h = (HANDLE)_beginthreadex(nullptr, 0, WorkerProc, this, 0, nullptr); if (h) threads_.push_back(h); } } ~ThreadPool() { Shutdown(); } void Submit(std::function<void()> task) { EnterCriticalSection(&cs_); if (stop_) { // 已关闭则拒绝新任务 LeaveCriticalSection(&cs_); return; } tasks_.push_back(std::move(task)); LeaveCriticalSection(&cs_); WakeConditionVariable(&cv_); } void Shutdown() { EnterCriticalSection(&cs_); if (stop_) { LeaveCriticalSection(&cs_); return; } stop_ = true; LeaveCriticalSection(&cs_); WakeAllConditionVariable(&cv_); for (HANDLE h : threads_) { WaitForSingleObject(h, INFINITE); // 等工作线程真正退出 CloseHandle(h); // 关闭句柄,避免句柄泄漏 } threads_.clear(); DeleteCriticalSection(&cs_); } private: static unsigned __stdcall WorkerProc(void* arg) { static_cast<ThreadPool*>(arg)->WorkerLoop(); return 0; } void WorkerLoop() { for (;;) { std::function<void()> task; EnterCriticalSection(&cs_); while (tasks_.empty() && !stop_) { SleepConditionVariableCS(&cv_, &cs_, INFINITE); } if (tasks_.empty() && stop_) { // 关闭且无剩余任务,退出 LeaveCriticalSection(&cs_); return; } task = std::move(tasks_.front()); tasks_.pop_front(); LeaveCriticalSection(&cs_); task(); // 锁外执行任务 } } std::vector<HANDLE> threads_; std::deque<std::function<void()>> tasks_; CRITICAL_SECTION cs_; CONDITION_VARIABLE cv_; bool stop_; };参数与逻辑说明:构造函数里的threadCount是工作线程数,后面会专门讲怎么定。Submit在锁内检查stop_,防止关闭后还有任务进来导致队列永远不被消费。Shutdown先置标志再广播,然后逐个WaitForSingleObject,这一步保证所有线程把手里任务跑完才真正退出,是「优雅关闭」的关键。WorkerLoop里任务在锁外执行,否则一个长任务会把整个队列卡死,其他线程拿不到任务,这是新手最常见的翻车点。
3.2 提交任务与验证:写一个能跑的最小 main
光有类不够,得有个能编译运行的验证程序。下面这段在 VS 里建一个空控制台工程,把上面代码拼进去就能跑。
#include <cstdio> #include <atomic> std::atomic<int> g_done{0}; int main() { ThreadPool pool(4); // 4 个工作线程 for (int i = 0; i < 100; ++i) { pool.Submit([i]() { // 模拟任务:打印任务号,累加完成计数 printf("task %d on thread %lu\n", i, GetCurrentThreadId()); g_done.fetch_add(1); }); } // 等所有任务跑完(实际项目里用条件变量或事件通知,这里简单轮询) while (g_done.load() < 100) { Sleep(10); } printf("all done, count=%d\n", g_done.load()); pool.Shutdown(); // 优雅退出 return 0; }逻辑说明:Submit传的是 lambda,捕获i的副本,避免悬空引用。g_done用std::atomic保证多线程累加不丢。轮询等待只是演示,生产代码应该在最后一个任务里发事件或者用std::promise。跑起来你会看到 100 个任务被 4 个线程瓜分,线程 ID 只有 4 个不同值,这就验证了复用生效。
3.3 线程数到底设多少:别拍脑袋填 CPU 核数
线程数不是越多越好。工作线程分两种负载类型:
- CPU 密集型:线程数设为逻辑核数或核数+1,多了只会增加上下文切换开销。用
GetSystemInfo拿dwNumberOfProcessors。 - IO 密集型:线程大部分时间在等网络或磁盘,线程数可以到核数的 2 到 4 倍,甚至更高,具体看阻塞比例。
SYSTEM_INFO si; GetSystemInfo(&si); size_t cpuCount = si.dwNumberOfProcessors; size_t ioThreads = cpuCount * 2; // IO 密集场景的经验起点我一般会做成可配置,启动时从配置文件读,压测时再调。别写死,因为同一份代码在开发机和服务器上最优值不一样。另外线程数一旦确定,运行期不要动态增删,VC 下动态调整线程数的收益远小于复杂度,除非你有明确的监控数据支撑。
4. 避坑与排查:线程池上线后最容易翻车的五个点
4.1 任务在锁内执行导致队列假死
现象:提交几十个任务后,只有第一个在跑,其余全部卡住,CPU 占用极低。原因:WorkerLoop里拿到任务后没有释放CRITICAL_SECTION就直接task(),长任务持锁期间其他线程无法EnterCriticalSection取任务。解决:把LeaveCriticalSection提到task()之前,任务执行永远在锁外。这个坑我见过不止一次,代码 review 时重点看锁的作用域。
4.2 关闭时丢任务或死等
现象:调用Shutdown后,队列里剩余任务没执行,或者程序卡在WaitForSingleObject不返回。原因有两种:一是stop_置位后工作线程直接退出,没检查队列是否还有任务;二是Shutdown里先WaitForSingleObject再广播,线程还在睡。解决:退出条件必须是「队列空且 stop_ 为真」,且Shutdown先置标志、再WakeAllConditionVariable、最后等待。顺序错了就是血泪经验。
4.3 任务里抛异常导致线程静默死亡
现象:某个任务抛了 C++ 异常,之后线程数越来越少,最终池子不干活了。原因:异常从task()抛出后穿过WorkerLoop,没有被捕获,线程函数直接返回,线程结束。解决:在task()外面包try/catch(...),记录日志后继续循环。线程池的线程是宝贵资源,不能因为单个任务异常就减员。
try { task(); } catch (const std::exception& e) { // 记录 e.what(),线程继续存活 } catch (...) { // 兜底,防止未知异常逃逸 }4.4 提交任务时捕获了悬空引用
现象:任务执行时读到随机值或崩溃,且崩溃位置飘忽。原因:lambda 用[&]捕获了局部变量,而任务在局部变量销毁后才执行。解决:提交任务时明确捕获方式,短生命周期数据用值捕获[=]或拷贝,长生命周期对象用shared_ptr捕获保证存活。这是异步编程的通病,线程池只是把它放大了。
4.5 句柄泄漏:_beginthreadex的返回值没关
现象:反复创建销毁线程池对象,进程句柄数持续上涨,最终CreateThread失败。原因:_beginthreadex返回的HANDLE是内核对象句柄,线程结束后句柄不会自动关闭,必须CloseHandle。解决:在Shutdown里WaitForSingleObject之后逐个CloseHandle,再清空数组。用 RAII 包一层更稳妥,但手动管理时千万别漏。
5. 进阶技巧:让线程池支持优先级与批量等待
基础版跑通之后,实际项目往往还会提两个需求:任务分优先级、能等一批任务全部完成。这两个都能在现有骨架上加,不用推翻重来。
优先级队列的做法是把std::deque换成std::priority_queue,任务包装里带一个int priority,比较函数按优先级排序。注意std::function本身不可比较,所以要包一层结构体:
struct TaskItem { int priority; std::function<void()> fn; bool operator<(const TaskItem& other) const { return priority < other.priority; // 大顶堆,数值大的先出 } }; std::priority_queue<TaskItem> pq_;参数说明:priority数值越大越先执行,和常见直觉一致。坑在于priority_queue的top()返回 const 引用,取任务时要么拷贝要么用const_cast配合pop,我一般直接拷贝std::function,开销可接受。另外优先级队列在任务量巨大时插入是 O(log n),比 deque 的 O(1) 慢,如果任务都是同优先级,别用优先级队列,纯属自找开销。
批量等待用std::atomic<int>加条件变量实现一个简易计数器:提交 N 个任务时把计数设为 N,每个任务结束时fetch_sub(1),减到 0 时WakeAllConditionVariable,等待方在条件变量上睡到计数归零。这比轮询Sleep优雅,也比std::future轻量。验证方法很简单:提交 1000 个任务,主线程等批量完成,打印耗时和完成计数,跑十遍看计数是否稳定为 1000,有偏差就说明计数逻辑有竞态。
最后一个习惯:线程池的每个参数——线程数、队列上限、拒绝策略、关闭超时——都写进配置文件并在启动日志里打出来。线上出问题时,第一件事就是看这份日志,比翻代码猜参数快得多。线程池这东西,写对一次不难,难的是半年后还能说清当时为什么设了 8 个线程。希望帮到你。
本文还有配套的精品资源,点击获取