简介:面向使用VC开发Windows服务端、需要处理高并发连接的C++学习者,这套线程池代码针对管理中心的并发请求场景:每次连接都新建线程,断开后再销毁,开销很明显,因此用线程复用替代频繁创建与回收,并兼顾其他模块的线程调用需求。压缩包共41个文件、约54KB,主体是16个.h头文件与15个.cpp源文件,另有dsp、dsw等VC工程与配置文件,可直接打开工程编译对照。目录分为DLL封装与自建线程池两套工程,涵盖线程对象封装、互斥量与同步控制、工作线程调度、线程管理、线程池核心队列及自定义异常处理等模块,另附测试类与调试输出;层次清晰,便于按模块逐层阅读。已有339人学习,适合通过调试源码体会任务排队、线程复用与异常保底处理的具体做法,也可作为项目中替代频繁创建线程方案的参考。
1. 从一次卡顿说起:C++ 线程池要解决的不是“能不能并发”
帮一个做图像采集的朋友看代码,他在每帧回调里直接std::thread起一个线程去算,跑十分钟,任务管理器里挂了两百多个线程,界面开始一卡一卡。问题不在算法,在于线程被当成了消耗品。C++ 线程池要解决的就是这件事:把线程的创建和任务的产生解耦,线程活多久跟来多少任务无关。
这套 VC 实现的线程池用的是 C++11 标准库那套std::thread+mutex+condition_variable,零第三方依赖,能直接塞进 MSVC 工程里编译。适合两类人:写过std::thread但被join/detach折磨过的,以及手里有个 MFC 或 Qt 老项目、想补一层并发能力又不想引入大型框架的。
2. 工程骨架与线程模型:VC 下这套线程池由哪几个零件组成
项目不大,但分层是清晰的:任务队列只负责存和取,线程池负责调度和生命周期,任务本身对线程池一无所知。这种切法的好处是后面想换队列(比如换成有界队列或者优先级队列)时,线程池那部分一行都不用动。先说骨架。
2.1 目录结构与编译单元划分
目录结构大致是这个样子:
threadpool/ ├─ include/ │ ├─ TaskQueue.h // 任务队列,纯头文件模板 │ └─ ThreadPool.h // 线程池对外接口 ├─ src/ │ ├─ ThreadPool.cpp // 构造、worker 循环、shutdown │ └─ main.cpp // 手写的冒烟测试 ├─ tests/ │ └─ test_basic.cpp // 提交/回收/异常三类用例 └─ CMakeLists.txt // 同时兼容 cl.exe 与 VS 生成器TaskQueue.h做成纯头文件是有意为之,它只依赖<queue>、<mutex>、<condition_variable>、<functional>四个头,谁 include 谁就能用,不会拖出一堆链接依赖。ThreadPool.h只暴露submit、shutdown、workerCount三个接口,worker 循环放在.cpp里,外面看不到实现细节,后面想换调度算法也不用改调用方。
VC 工程里有个细节容易被忽略:.cpp的编译选项必须统一。如果线程池静态库用/MT(静态链接 CRT)编,而主程序用/MD(动态链接 CRT),链接期会报_ITERATOR_DEBUG_LEVEL不匹配或者RuntimeLibrary冲突。血泪经验是,跟着主工程走,主工程是/MDd就全部/MDd,别在一个解决方案里混着来。
2.2 三个角色的职责边界
把类拆开之前先看一张表,职责划不清的时候回头对一眼:
| 角色 | 负责 | 不负责 |
|---|---|---|
TaskQueue | 存任务、阻塞等待、关闭信号 | 不创建线程、不执行任务 |
ThreadPool | 起线程、收任务、优雅停机 | 不关心任务内容、不吞异常之外的逻辑 |
| worker 循环 | 取任务、执行、兜异常 | 不修改队列结构、不做调度决策 |
对外头文件长这样:
// include/ThreadPool.h #pragma once #include <vector> #include <thread> #include <atomic> #include <future> #include <mutex> #include "TaskQueue.h" class ThreadPool { public: // n 为 0 时退化为单线程,防止 hardware_concurrency() 返回 0 的机器上起空池 explicit ThreadPool(size_t n = std::thread::hardware_concurrency()); ~ThreadPool(); // 提交任意可调用对象,返回 future 供调用方取值或等待 template <typename F, typename... Args> auto submit(F&& f, Args&&... args) -> std::future<decltype(f(args...))>; void shutdown(); // 幂等:重复调用安全 size_t workerCount() const { return m_workers.size(); } private: void workerLoop(size_t id); TaskQueue m_queue; std::vector<std::thread> m_workers; std::atomic<bool> m_stopping{false}; std::once_flag m_shutdownFlag; // 保证 shutdown 只真正执行一次 };submit用了完美转发F&&和变参模板,这样传左值、右值、lambda、成员函数指针都不会多一次拷贝。std::once_flag是为析构和手动shutdown撞车准备的——用户可能先手动停一次,析构里又停一次,没有call_once就会对已经 join 过的 thread 再 join,直接std::terminate。
2.3 线程数量与同步原语的选型
线程数不是越多越好。默认用std::thread::hardware_concurrency(),在 MSVC 上它读的是逻辑核数,超线程机器上会翻倍。我给的建议是按任务性质分:
| 任务类型 | 建议线程数 | 说明 |
|---|---|---|
| 纯 CPU 计算 | 物理核数 | 超线程对纯计算收益有限,多开反而抢缓存 |
| 带 IO 等待 | 核数 × 2 起 | 线程阻塞时让出核,靠数量掩盖等待 |
| 混合型 | 核数 + 1~2 | 留一个位置给偶发的阻塞任务,避免全池停摆 |
同步原语这块,Windows 上其实有CreateEvent/WaitForMultipleObjects这套老 API,但在这个项目里我坚持用标准库的condition_variable。原因很实在:标准库版本在 MSVC 上底层同样走的是 CRT 的线程与同步入口,性能差距在任务粒度稍大的场景里完全看不出来,而可移植性和代码可读性提升是实打实的。真到了每毫秒几十万次唤醒的级别,再考虑无锁队列或者SRWLOCK,那是另一个量级的优化,先把正确性做对。
3. 核心代码拆解:一个任务从提交到执行走了哪几步
这一章按调用链走:调用方submit一个 lambda,任务被包装成packaged_task塞进队列,某个正卡在条件变量上的 worker 被唤醒,取走执行,结果通过future回传给调用方。四步,每一步都有坑。
3.1 任务封装:为什么用packaged_task而不是裸std::function
裸std::function<void()>只能执行,没法把返回值带回来,也没法把异常传出去。packaged_task两者都能做,它的get_future()会给调用方一个句柄。
// ThreadPool.h 里的模板实现(因为要看到完整定义,只能写在头文件) template <typename F, typename... Args> auto ThreadPool::submit(F&& f, Args&&... args) -> std::future<decltype(f(args...))> { using Ret = decltype(f(args...)); // bind 把参数固化进可调用对象,packaged_task 负责把返回值/异常接到 future 上 auto task = std::make_shared<std::packaged_task<Ret()>>( std::bind(std::forward<F>(f), std::forward<Args>(args)...)); std::future<Ret> fut = task->get_future(); // 关键:lambda 以值方式捕获 shared_ptr,队列里存的是副本,函数返回后包装体依然活着 m_queue.push([task]() { (*task)(); }); return fut; }std::bind那行把散装参数压成一个无参可调用对象,因为队列里只能存统一的std::function<void()>。make_shared而不是直接构造packaged_task栈对象,是因为std::function要求可拷贝构造,而packaged_task只能移动——包一层shared_ptr后它变成可拷贝的,代价只是一次引用计数原子操作。
参数上有两个点要留意:一是Ret为void时decltype(f(args...))依然合法,返回std::future<void>,调用方fut.wait()或fut.get()都行;二是千万别在这里用std::ref包参数去省拷贝,除非你确定对象活得比任务久,否则就是悬垂引用的经典现场。
3.2 入队与唤醒:条件变量必须配谓词
队列这层是整个池子里唯一有共享状态的地方,锁的粒度直接决定吞吐。
// include/TaskQueue.h #pragma once #include <queue> #include <mutex> #include <condition_variable> #include <functional> #include <utility> class TaskQueue { public: using Task = std::function<void()>; void push(Task task) { { std::lock_guard<std::mutex> lock(m_mutex); if (m_closed) return; // 已关闭则静默丢弃,避免停机过程中崩溃 m_queue.push(std::move(task)); } m_cv.notify_one(); // 解锁之后再通知,减少唤醒后立刻阻塞的概率 } // 返回 false 表示队列已关闭且取空,worker 收到这个信号才能退出 bool pop(Task& out) { std::unique_lock<std::mutex> lock(m_mutex); m_cv.wait(lock, [this] { return !m_queue.empty() || m_closed; }); if (m_queue.empty()) return false; // 只可能是 closed 分支走到这里 out = std::move(m_queue.front()); m_queue.pop(); return true; } void close() { { std::lock_guard<std::mutex> lock(m_mutex); m_closed = true; } m_cv.notify_all(); // 必须广播,把全部 worker 叫醒 } private: mutable std::mutex m_mutex; std::condition_variable m_cv; std::queue<Task> m_queue; bool m_closed = false; };wait的第二个参数是谓词,这是防“虚假唤醒”的唯一手段。condition_variable在某些平台上会无缘无故返回,没有谓词就得在循环里自己判,而有谓词的话标准库内部会帮你循环。notify_one放在锁外面是我养成的习惯:notify 时被唤醒的线程需要重新拿锁,如果通知方还持着锁,被唤醒者就得再等一轮,白白多一次上下文切换。
close里必须用notify_all,不能图省事用notify_one,否则只有一个 worker 醒来发现队列空了退出,剩下七个永远卡在wait上,进程关不掉。
3.3 工作线程主循环与异常边界
worker 是唯一执行用户代码的地方,也是异常最可能逃逸的地方。
// src/ThreadPool.cpp void ThreadPool::workerLoop(size_t id) { #ifdef _WIN32 // 给线程命名,VS 的“线程”窗口里能直接看到 pool-worker-3 这种标识 SetThreadDescription(GetCurrentThread(), (L"pool-worker-" + std::to_wstring(id)).c_str()); #endif while (true) { TaskQueue::Task task; if (!m_queue.pop(task)) break; // 队列关且取空,正常退出 try { task(); } catch (const std::exception& e) { // 任务异常绝不能逃出 worker:逃出去就是 std::terminate,整个进程没了 std::fprintf(stderr, "[worker %zu] exception: %s\n", id, e.what()); } catch (...) { std::fprintf(stderr, "[worker %zu] unknown exception\n", id); } } }SetThreadDescription是 Windows 10 起才稳的 API,需要<windows.h>和<processthreadsapi.h>。调试多线程时这行代码的价值极高——VS 的“并行堆栈”窗口里显示的线程名直接就是它,比对着十六进制线程 ID 猜要省事得多。
try/catch是必须的,不是什么防御性编程的客套。packaged_task内部会捕获任务本身抛出的异常并转存到 future 里,但如果是构造std::function包装、或者队列内部某些操作抛了(比如内存不足),异常就会一路冒到workerLoop,std::thread的入口函数一旦抛出未捕获异常,标准规定直接调std::terminate。
3.4 启动、停机与析构顺序
停机顺序错了,前面写得再漂亮也是白搭。
// src/ThreadPool.cpp ThreadPool::ThreadPool(size_t n) { if (n == 0) n = 1; m_workers.reserve(n); for (size_t i = 0; i < n; ++i) m_workers.emplace_back(&ThreadPool::workerLoop, this, i); } void ThreadPool::shutdown() { std::call_once(m_shutdownFlag, [this] { m_queue.close(); // 先关队列,worker 才会从 wait 里醒 for (auto& t : m_workers) if (t.joinable()) t.join(); m_workers.clear(); }); } ThreadPool::~ThreadPool() { shutdown(); }顺序是“先关队列,再 join”。反过来的话join会永远阻塞在 worker 的wait上,因为没人通知它。joinable()判断看着多余,实际上clear之前复用对象或者异常路径下确实可能出现不可 join 的 thread。
析构里做 join 意味着~ThreadPool会阻塞到队列里所有任务跑完。这个设计对“优雅退出”是对的,但在 GUI 主线程里析构一个还有长任务的池子,界面会假死。我一般的做法是:主线程持智能指针,退出时先shutdown()再把指针挪到后台线程去释放,或者给每个任务加取消标志(后面 6.2 会说)。
4. 编译与跑通:VC 工程从零到第一个可运行 demo
代码贴完了,得让它真的跑起来。这一章给两个编译路径,手上一台装了 VS 的机器就够。
4.1 用 cl.exe 命令行编第一版
打开“x64 Native Tools Command Prompt”,cd 到工程根目录:
cl /std:c++17 /EHsc /MD /O2 /W4 /Iinclude ^ src\ThreadPool.cpp src\main.cpp ^ /Fe:pool_test.exe参数逐个说明:/std:c++17是必须的,std::invoke相关的重载和变参模板的完善度在 C++14 下都差一点;/EHsc打开标准 C++ 异常模型,漏了它 try/catch 对某些新式异常不一定生效;/MD动态链接 CRT,跟大多数现成工程的默认一致;/W4是 MSVC 里比较严格的告警级别,能把“有符号/无符号比较”这类问题提前揪出来;/Iinclude指定头文件搜索路径,^是 cmd 下的续行符。
跑起来应该看到类似sum=10416 elapsed=812ms的输出。如果报LNK2019 unresolved external symbol,九成是ThreadPool.cpp没加进命令行,检查一下路径斜杠和空格。
4.2 CMake 生成 VS 工程
命令行编译适合快速验证,真要在 VS 里打断点还得靠 CMake 生成.sln:
cmake_minimum_required(VERSION 3.15) project(threadpool CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if (MSVC) add_compile_options(/EHsc /W4 /utf-8) endif() find_package(Threads REQUIRED) add_library(tp STATIC src/ThreadPool.cpp) target_include_directories(tp PUBLIC include) target_link_libraries(tp PUBLIC Threads::Threads) add_executable(pool_test src/main.cpp) target_link_libraries(pool_test PRIVATE tp)/utf-8是 VC 特有的老问题补丁:源码里有中文注释时,默认代码页可能把它解释成乱码进而吃掉换行,加上这个选项就消停了。Threads::Threads在 Windows 上其实不链接额外的库,但在 Linux 上会补-pthread,留着这套写法以后跨平台省事。
生成工程:
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 cmake --build build --config Release-A x64不写的话默认可能是 Win32,32 位下std::thread行为没区别但内存上限会让你在压测时莫名其妙失败。
4.3 一个能看出差别的测试用例
测试要能证明“池子确实有效”,而不是“代码没崩”。
// src/main.cpp #include "ThreadPool.h" #include <iostream> #include <chrono> #include <vector> int main() { ThreadPool pool(4); std::vector<std::future<int>> results; auto t0 = std::chrono::steady_clock::now(); for (int i = 0; i < 32; ++i) { results.emplace_back(pool.submit([i] { std::this_thread::sleep_for(std::chrono::milliseconds(100)); return i * i; // 顺便验证返回值能带回来 })); } long long sum = 0; for (auto& f : results) sum += f.get(); // get() 会阻塞到对应任务完成 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - t0).count(); std::cout << "sum=" << sum << " elapsed=" << ms << "ms\n"; }32 个任务、每个 100ms、4 个线程,理论耗时约 800ms。实测如果接近 3200ms,说明线程池退化成了串行,多半是pop的谓词写错、或者notify_one被漏了。sum应该是 0²+1²+…+31² = 10416,这个数字用来验证任务没被重复执行也没被丢。
f.get()还有个副作用:它会重新抛出任务里未处理的异常。所以正式代码里调用方最好包一层 try/catch,别让它把主线程带崩。
4.4 在 VS 里调试多线程的几个开关
第一次调线程池,最懵的是“断点进去之后其他线程乱跑”。几个设置能救命:
| 操作 | 位置 | 作用 |
|---|---|---|
| 冻结线程 | 调试 → 窗口 → 线程 | 把无关 worker 冻结,单步只走当前线程 |
| 并行堆栈 | 调试 → 窗口 → 并行堆栈 | 一次看所有线程卡在哪一行 |
| 条件断点 | 断点右键 → 条件 | 只在id == 3时命中,避免 8 个 worker 同时断住 |
| 异常设置 | 调试 → 窗口 → 异常设置 | 勾选 C++ 异常,在throw处中断而非catch处 |
条件断点那句写成id == 3就行,MSVC 支持直接写变量名。调试完之后记得把“仅我的代码”关掉,否则标准库内部的wait会被当成黑匣子跳过,你就看不到线程到底卡在哪一层。
5. 避坑与排查:线程池在 Windows 上最容易翻车的五个地方
下面五条,都是我在真项目里踩过或者帮别人排查过的。每条按“现象—原因—解决”走。
5.1 崩溃与卡死类
第一条:程序退出时弹 “abort() has been called”。现象是主函数正常return 0,紧接着进程异常终止,调用栈指向std::terminate。原因通常是ThreadPool对象在main里是栈上局部变量,析构时 join 所有线程,但如果某个任务里又调用了submit想往同一个池子塞任务,而队列已经close,任务在push里被静默丢弃,调用方拿着一个永远不会 ready 的future,在被析构的池子上等,等到的是未定义行为。解决办法有两个:一是任务内部禁止再提交给同一个池(可以让任务里拿到的池指针为空则是“当前池”的语义),二是把push改成返回bool,丢弃时让 future 立刻变成broken_promise异常而不是死等。我一般选后者,出错至少能看见。
第二条:任务里的异常让整个进程消失。现象是某个任务throw std::runtime_error("...")之后进程直接退出,没有栈展开,日志也没了。原因是异常从 worker 的入口函数逃了出去,std::thread规定这种情况直接std::terminate。packaged_task只能兜住“任务体本身”抛出的异常,兜不住包装过程中的异常。解决办法就是 3.3 里那个try/catch(...),一个都不能少,...那个分支也必须写。
第三条:condition_variable偶尔永久阻塞。现象是压测跑几万次里有那么一两次,shutdown卡在join上不返回。原因是wait没用谓词,用if判了一下就往下走,遇到虚假唤醒时取到空队列,front()拿到脏数据或者直接崩;另一种是close用了notify_one,只叫醒一个 worker。解决:wait一律带 lambda 谓词,close一律notify_all,两句话背下来就行,没有例外。
5.2 性能与资源类
第四条:线程池比不用还慢。现象是改造成线程池后整体耗时反而上升,CPU 占用却不高。原因通常是任务粒度太小——每帧只算几十个浮点也往池子里丢,加锁、唤醒、上下文切换的开销远大于计算本身。常见做法是设一个阈值:单个任务预估耗时低于 50 微秒就别进池,当前线程直接算。另一个原因是submit里参数按值捕获了大对象,比如整个图像帧cv::Mat,拷贝一次的代价比计算大得多,这种要用std::shared_ptr或者std::move加引用语义。
第五条:调试版飞快、发布版卡死。现象是 Debug 下一切正常,Release 偶发卡死或数据错乱。原因多半是数据竞争没被m_stopping这类标志掩盖住,Debug 下编译器的内存序和优化级别让错误暴露不出来。解决办法:先在 Release 下开/O2复现,再用 VS 的“并发可视化”或者条件断点定位;m_stopping用std::atomic<bool>而不是裸bool,读写分别用memory_order_acquire/release。这套东西不是玄学,是编译器真会在 Release 下把非原子的bool读缓存进寄存器。
6. 二次开发:把线程池改造成适合自己项目的形态
通用池子只是起点,真落到项目里,八成要加两样东西:优先级和取消能力。
6.1 加优先级:把queue换成priority_queue
std::priority_queue存不了裸的std::function,得包一层带上优先级和序号:
struct Item { int priority; // 数值越大越先执行 uint64_t seq; // 同优先级下保证 FIFO,防止低序号任务被饿死 Task task; }; struct Cmp { bool operator()(const Item& a, const Item& b) const { if (a.priority != b.priority) return a.priority < b.priority; // 大顶堆 return a.seq > b.seq; // seq 小的先出 } }; std::priority_queue<Item, std::vector<Item>, Cmp> m_pq;seq这个字段是必须的,不是可选优化。没有它,同优先级的任务出队顺序由堆内部结构调整决定,实测下来后提交的任务可能先跑,做流水线的时候会莫名其妙乱序。seq用一个std::atomic<uint64_t>在push时自增,跨线程安全。
notify_one依然成立,因为每次push只会让队列多一个元素,叫醒一个 worker 就够。但要注意:高优先级任务到达时,已经有 worker 被低优先级任务占着,这个是抢占不了的,优先级队列只管出队顺序,不管运行中的抢占。想真抢占就得上多池分级,把高低优先级拆成两个池子,简单粗暴但有效。
6.2 加取消与超时:token +wait_for
std::future标准里没有cancel,别去纠结这个,用协作式取消:
class CancellationToken { public: void cancel() { m_flag.store(true, std::memory_order_release); } bool cancelled() const { return m_flag.load(std::memory_order_acquire); } private: std::atomic<bool> m_flag{false}; };任务体在几个关键检查点主动查token.cancelled(),是就提前return。上传下载这类长任务,每个分片处理完查一次,代价可以忽略。内存序用release/acquire是保证cancel()之前写的数据对 worker 可见,别图省事用relaxed。
超时侧用future.wait_for:
auto fut = pool.submit([]{ /* 可能很慢的任务 */ }); if (fut.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { token.cancel(); // 通知任务自己收尾 // 注意:这里不能直接析构 future,会让任务结果无处可去 }wait_for超时只是“我不等了”,任务还在跑,所以必须配token一起用,光靠超时是伪取消。这个坑我翻过一次,以为超时了任务就停了,结果线程池里的 worker 还在吭哧吭哧算,队列越积越长。
6.3 改完怎么验证没改坏
改造完别只看“能跑”,要能证明“改对了”。我的验证顺序固定三步:先跑正确性,再跑压力,最后看退出。
正确性用 4.3 那个测试就行,但要把sum和耗时都打出来对比。优先级改造的验证方式是提交 100 个任务,用std::atomic<int>记下实际执行顺序,检查高优先级的是否真的在前,这个用几行代码就能断言。
压力测试我一般写个循环:8 线程池,提交 10 万个空任务,记录总耗时;再起 200 个并发提交线程,每个提交 500 个任务,看有没有偶发崩溃。这一步最容易暴露锁粒度和notify的问题,跑三轮不出事基本可以认为稳定。
退出验证看两件事:进程退出码是不是 0,退出耗时是否可控。在main返回前打一行时间戳,对比shutdown前后的差值,超过一秒就说明有 worker 卡在某个任务里没出来,结合SetThreadDescription起的线程名去 VS 里抓现场,基本一抓一个准。
从那以后我每次改线程池的同步逻辑,都会强制走一遍“Debug 跑正确性 → Release 跑压力 → 看退出码”这三步,一次都不跳过。并发代码最坑的地方在于它平时看着好好的,只在特定时序下崩给你看,后悔药是没有的。希望这套东西帮到你,少走几个我走过的弯路。
本文还有配套的精品资源,点击获取