ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ 多线程并发编程实战:从 std::thread 到手写线程池

C++ 多线程并发编程实战:从 std::thread 到手写线程池 结论多线程的 3 个优点与 3 个场景使用优点为什么值得学优点通俗解释① 榨干多核 CPU现在的 CPU 动辄 8 核 16 核单线程 只让一个员工干活其余全在摸鱼。多线程让所有核同时干活吞吐量成倍提升② 提升响应速度界面程序里如果下载文件时界面卡死用户会砸键盘。把耗时任务丢到后台线程主线程继续响应点击体验立刻提升③ 天然的分工协作模型一个任务拆成读数据→算→写结果三段各用一条线程流水线式推进代码结构比在单线程里用状态机硬憋清晰得多使用场景什么时候该上多线程场景例子计算密集吃 CPU视频转码、图片滤镜、科学计算、游戏物理模拟——按数据分片每个核算一片IO 密集吃等待网络请求、文件读写、数据库访问——等待期间把 CPU 让给其他线程干活实时响应吃交互GUI 程序、服务器并发处理请求——一个请求一条线程谁也别阻塞谁⚠️不是所有程序都该多线程。任务太小如循环 100 次、任务顺序强依赖、单核老机器多线程反而因创建/切换线程的开销更慢。先基准测试 再决定别为了炫技硬上。第 1步准备工作——装好环境别让编译器拦住你1.1 你需要的东西一个 C 编译器Windows 用 MinGW-w64g或 MSVCLinux/macOS 自带 g/clang。一份代码文件新建 demo.cpp把下面的代码粘进去。1.2 关键编译时必须加 -pthread多线程不是 C 语言本身的魔法而是操作系统提供的线程能力被标准库包装了一层。所以链接时需要告诉编译器我要用系统线程库# Linux / macOSg 或 clang g -stdc17 -pthread demo.cpp -o demo # WindowsMinGW-w64 g -stdc17 -pthread demo.cpp -o demo.exe # WindowsMSVC 不需要 -pthread直接编译即可 cl /EHsc demo.cpp⚠️易错点忘了 -pthread 时Linux 上会报类似 undefined reference to pthread_create 的链接错误。这不是代码错了是编译命令少了参数。Windows 的 MSVC 不用加但 MinGW 要加别搞混。1.3 先跑通最小示例#include iostream #include thread // 线程库头文件 void hello() { std::cout 我是子线程ID std::this_thread::get_id() std::endl; } int main() { std::cout 我是主线程ID std::this_thread::get_id() std::endl; std::thread t(hello); // 创建子线程并立刻开始执行 hello t.join(); // 主线程在这里等子线程跑完 std::cout 子线程已结束主线程继续 std::endl; return 0; }编译运行你会看到主线程和子线程各自打印一行 ID。跑通这一步环境就 OK 了后面所有代码都是在这个基础上加料。第 21 步创建线程 std::thread——让程序长出分身2.1 术语首次出现线程Thread是什么类比把程序想象成一家餐厅主线程是大堂经理子线程是后厨师傅。经理下达订单后师傅们各自炒菜互不干扰最后菜一起上桌。线程就是程序里的独立执行流多条执行流可以同时在多个核上真同时单核上则是快速轮流跑不同的代码。2.2 std::thread 的三种启动姿势姿势代码说明传函数std::thread t(worker);线程执行 worker()传函数参数std::thread t(worker, 42, hello);参数原样传给函数传 Lambdastd::thread t([] { /* 干点啥 */ });匿名函数最常用2.3 完整示例带参数的线程#include iostream #include thread #include string // 子线程要执行的函数接收两个参数 void cook(std::string dish, int count) { for (int i 0; i count; i) { std::cout 正在做第 i 1 份 dish std::endl; } } int main() { // 创建线程cook 函数 两个参数参数会被拷贝进线程 std::thread t1(cook, 宫保鸡丁, 3); std::thread t2(cook, 麻婆豆腐, 2); // join()阻塞等待该线程结束类比经理站在出餐口等这道菜做完 t1.join(); t2.join(); std::cout 两道菜都齐了开饭 std::endl; return 0; }2.4 join 与 detach——线程的两种收尾方式方式行为使用场景t.join()主线程阻塞等待该子线程结束需要线程结果、必须保证线程跑完才继续t.detach()线程放飞自我与主线程断绝关系主线程不等它后台任务如日志写入、心跳上报⚠️易错点 1detach() 之后主线程和子线程不再有任何同步关系。如果子线程还在用主线程的局部变量引用或指针主线程先退出了子线程就会访问已销毁的内存——这是悬垂引用程序可能随机崩溃。// ❌ 危险示例detach 后访问主线程栈上变量 int x 42; std::thread t([x] { std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout x; }); t.detach(); // 主线程马上结束x 已销毁子线程读 x 未定义行为正确做法需要共享数据就别 detach用 join 或把数据拷贝进线程。⚠️易错点 2一个 std::thread 对象只能 join 或 detach 一次。既不 join 也不 detach 就析构程序会直接 std::terminate() 崩溃这是标准库的强制要求防止线程没人管。所以要么 join要么 detach二选一。⚠️易错点 3构造线程时参数是拷贝还是引用默认按值拷贝。如果函数参数是 std::string 引用必须用 std::ref() 显式包装void change(std::string s) { s !; } std::string name tom; std::thread t(change, std::ref(name)); // 不写 std::ref 会编译报错 t.join();第 3 步互斥锁 std::mutex——给共享数据装门禁3.1 为什么要锁先看翻车现场多个线程同时读改写同一个变量会发生数据竞争Data Race#include iostream #include thread #include vector int counter 0; // 共享变量 void add() { for (int i 0; i 100000; i) { counter; // 等价于 counter counter 1注意这是读-改-写三步 } } int main() { std::vectorstd::thread threads; for (int i 0; i 4; i) threads.emplace_back(add); for (auto t : threads) t.join(); std::cout counter counter 期望 400000 std::endl; return 0; }跑几遍你会发现结果不是 400000而是随机少一点。为什么类比counter counter 1 像把账本上的数字1。两个会计同时看到账本上写着 100都改成 101写回去——账本最终是 101可明明加了两次应该是 102。这就是读-改-写 三步被两个线程交错执行导致的丢更新。3.2 互斥锁Mutex是什么类比公共厕所的门锁。一个人进去把门锁上其他人只能在门外排队出来开锁下一个人才能进。互斥锁保证同一时刻只有一个线程能进入临界区受保护的代码段。3.3 正确姿势std::lock_guard推荐#include iostream #include thread #include vector #include mutex // 互斥锁头文件 int counter 0; std::mutex mtx; // 全局锁 void add() { for (int i 0; i 100000; i) { std::lock_guardstd::mutex lock(mtx); // 进入时自动上锁 counter; // 临界区同一时刻只有一个人能进 } // 离开作用域时自动解锁析构函数 } int main() { std::vectorstd::thread threads; for (int i 0; i 4; i) threads.emplace_back(add); for (auto t : threads) t.join(); std::cout counter counter 期望 400000 std::endl; return 0; }这次结果一定是 400000。3.4 lock_guard 与 unique_lock 怎么选锁类型特点什么时候用std::lock_guard构造上锁、析构解锁不能手动解锁80% 场景够用就用它std::unique_lock可以手动 lock()/unlock()还能配合条件变量需要中途解锁、或要等条件变量的场景见第 3 步⚠️易错点锁的粒度。把锁写进循环里如上例每次 都上锁解锁性能差把锁放到整个循环外又等于把多线程退化回单线程。正确思路锁只保护真正需要保护的共享数据操作范围越小越好但操作必须是原子性的一组。3.5 补充std::scoped_lockC17防死锁利器同时锁多个互斥量时用 std::lock(a, b) 或 C17 的 std::scoped_lock#include mutex std::mutex m1, m2; void transfer() { std::scoped_lock lock(m1, m2); // 一次性锁住两个内部自动规避死锁顺序问题 // 同时操作两个资源... }第 4 步条件变量 std::condition_variable——线程间的对讲机4.1 没有条件变量时有多痛苦假设消费者线程要等队列里有数据才取如果只用锁只能轮询——不停加锁看队列空不空空就解锁再睡一会儿再来看。空转浪费 CPU而且睡多久根本没法拍脑袋定。4.2 条件变量是什么类比前台叫号系统。顾客消费者取号后坐着等不用一直盯着屏幕厨师生产者做好菜用广播叫号顾客听到自己的号才起身。条件变量让线程睡到被通知而不是反复醒来查既省 CPU 又及时。4.3 完整示例生产者-消费者模型#include iostream #include thread #include mutex #include condition_variable #include queue std::queueint q; // 共享队列消息队列 std::mutex mtx; // 保护队列的锁 std::condition_variable cv; // 条件变量 // 生产者往队列放数据 void producer() { for (int i 0; i 10; i) { { std::lock_guardstd::mutex lock(mtx); // 上锁 q.push(i); // 放入队列 std::cout 生产: i std::endl; } // 解锁 cv.notify_one(); // 通知一个等待的消费者来活了 std::this_thread::sleep_for(std::chrono::milliseconds(30)); } } // 消费者从队列取数据 void consumer() { while (true) { std::unique_lockstd::mutex lock(mtx); // wait 的两个动作① 解锁并睡觉② 被唤醒后重新上锁 // 谓词 []{ return !q.empty(); } 防止假唤醒见下 cv.wait(lock, [] { return !q.empty(); }); int val q.front(); q.pop(); lock.unlock(); // 提前解锁避免持锁做打印 std::cout 消费: val std::endl; if (val 9) break; // 收到最后一个数据就退出 } } int main() { std::thread p(producer); std::thread c(consumer); p.join(); c.join(); return 0; }4.4 三个必须记住的要点要点说明wait 必须配 unique_lockcv.wait() 需要临时解锁让生产者进临界区lock_guard 做不到所以用 unique_lockwait 必须带谓词cv.wait(lock, 谓词) 的谓词是**防假唤醒**的保险丝系统可能莫名唤醒线程谓词会检查条件是否真满足不满足继续睡。永远不要写裸 cv.wait(lock)notify_one vs notify_all一个等待者用 notify_one多个等待者都要醒来用 notify_all⚠️易错点先 notify 再 wait 会丢失通知吗会——如果生产者先 notify 而消费者还没开始 wait通知就落空了。但谓词 wait 不怕这个消费者 wait 时会重新检查谓词队列是否非空队列里有数据就直接拿走不会错过。这就是谓词 wait 的第二重保险。第 5 步std::async / std::future——点外卖式异步任务5.1 使用优点前面几节是自己手动 new 线程、自己传参、自己收结果太繁琐。std::async 是标准库给你的高级封装启动线程、取返回值都极简有异常也能安全传回future.get() 会重新抛出子线程的异常默认策略下库可能复用线程、可能延迟执行由实现优化。5.2 术语首次出现future 是什么类比你在外卖 App 下单App 给你一个配送中的订单页future。你该干嘛干嘛等外卖到了点取餐get()饭就到手。future 就是未来才有的结果的占位符get() 会阻塞等待结果真正产生。5.3 示例异步计算#include iostream #include future // 模拟耗时计算 int heavyCompute(int x) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); return x * x; } int main() { // std::launch::async立刻在新线程执行不写则实现自己选 std::futureint f1 std::async(std::launch::async, heavyCompute, 7); std::futureint f2 std::async(std::launch::async, heavyCompute, 9); // 两个计算并行进行这里才真正等待结果 std::cout 7^2 f1.get() std::endl; std::cout 9^2 f2.get() std::endl; return 0; }⚠️易错点future只能 get() 一次。第二次 get() 会抛 std::future_error。想多线程共享同一个结果用 std::shared_future可拷贝、可多次 get。⚠️易错点std::async 返回的 future 析构时如果任务还没完成析构函数会阻塞等待任务结束相当于隐式 join。所以别把 future 丢在一边不管否则 async 退化成同步执行。第 6 步std::promise——把外卖换成自己亲手递6.1 promise 是什么类比async 是点外卖交给平台自动配送promise 是你亲手写张条子塞进信箱promise对方从信箱取future。promise 和 future 是一对一配对的通信管道promise 负责写入结果future 负责读取结果。6.2 典型场景你需要把结果从手写线程里传出来async 帮你自动做了但手写线程时没有#include iostream #include thread #include future void worker(std::promiseint p, int input) { int result input * 2; p.set_value(result); // 把结果塞进管道 } int main() { std::promiseint pr; // 建管道 std::futureint fu pr.get_future(); // 管道另一头的取件口 // promise 不能拷贝必须 move 进线程 std::thread t(worker, std::move(pr), 21); std::cout 子线程算出的结果 fu.get() std::endl; // 42 t.join(); return 0; }⚠️易错点std::promise不可拷贝拷贝构造被删除传参必须 std::move。这是新手最容易卡住的编译错误。⚠️易错点如果子线程没 set_value 就结束或抛异常没接住fu.get() 会抛 std::future_error。可在子线程用 p.set_exception(std::current_exception()) 把异常传给主线程。第 7 步手写线程池——批量干活的生产线7.1 为什么需要线程池每次 std::thread 创建/销毁都有开销系统调用、栈分配。如果任务是高频短小的比如服务器每秒 10 万个请求现建现拆线程 大量时间花在线程管理而不是干活。类比餐厅高峰期如果每来一桌客人就临时招一个厨师、客人走了就开除——没人这么干。正确做法是固定雇 4 个厨师线程池客人再多也只是排队。7.2 线程池的核心设计部件职责工作线程数组固定 N 条线程启动后就一直活着任务队列存待执行的任务std::functionvoid()互斥锁 条件变量保护队列、让空闲线程睡觉等任务停止标志析构时通知所有线程退出7.3 完整可运行代码约 60 行建议亲手敲一遍#include iostream #include vector #include thread #include queue #include mutex #include condition_variable #include functional #include stdexcept class ThreadPool { public: // 构造函数启动 n 个工作线程 explicit ThreadPool(size_t n) { for (size_t i 0; i n; i) { workers.emplace_back([this] { while (true) { std::functionvoid() task; { // 取任务也要加锁因为任务队列是共享的 std::unique_lockstd::mutex lock(mtx); // 队列为空且没说要停止 - 睡觉等任务 cv.wait(lock, [this] { return stop || !tasks.empty(); }); if (stop tasks.empty()) return; // 要停止且没活了 - 退出线程 task std::move(tasks.front()); // 取出一个任务 tasks.pop(); } task(); // 在锁外执行任务重要不要在持锁时执行耗时任务 } }); } } // 提交任务任意可调用对象函数、lambda、函数对象 template typename F void enqueue(F f) { { std::lock_guardstd::mutex lock(mtx); if (stop) throw std::runtime_error(线程池已停止不能再提交任务); tasks.emplace(std::forwardF(f)); } cv.notify_one(); // 唤醒一个空闲线程去取任务 } // 析构函数通知所有线程退出并等待它们结束 ~ThreadPool() { { std::lock_guardstd::mutex lock(mtx); stop true; // 置停止标志 } cv.notify_all(); // 唤醒所有线程它们会检查 stop 后退出 for (auto w : workers) w.join(); } private: std::vectorstd::thread workers; // 工作线程 std::queuestd::functionvoid() tasks; // 任务队列 std::mutex mtx; // 保护队列的锁 std::condition_variable cv; // 任务到达通知 bool stop false; // 停止标志 }; int main() { ThreadPool pool(4); // 开 4 条工作线程 // 提交 8 个任务4 条线程分两批消化 for (int i 0; i 8; i) { pool.enqueue([i] { std::cout 任务 i 由线程 std::this_thread::get_id() 执行 std::endl; }); } // main 结束时 pool 析构会等待所有任务执行完 return 0; }7.4 想拿到任务的返回值怎么办把 enqueue 升级成返回 future——这是生产级线程池的标准做法std::packaged_task 登场#include future #include utility // 在类里加一个模板方法提交任务并返回 future template typename F, typename... Args auto enqueueWithResult(F f, Args... args) - std::futuredecltype(f(args...)) { using RetType decltype(f(args...)); // packaged_task把任务 它的 future打包在一起 auto task std::make_sharedstd::packaged_taskRetType()( std::bind(std::forwardF(f), std::forwardArgs(args)...) ); std::futureRetType res task-get_future(); enqueue([task] { (*task)(); }); // 入队时执行任务结果自动写入 future return res; }用法std::futureint f pool.enqueueWithResult(heavyCompute, 10); std::cout f.get() std::endl; // 100进阶提示真实的线程池如 Taskflow、folly::ThreadPoolExecutor还会做任务窃取work-stealing、动态扩容、优先级队列、无锁队列等优化。这篇的 60 行版本是地基理解了它再去看开源实现就轻松多了。第 8 步死锁与常见大坑——先学会避坑再写生产代码8.1 死锁Deadlock程序永远卡住的元凶类比两个人面对面过独木桥甲先抬起左脚、乙先抬起右脚谁也不让——都在等对方让路结果谁也无法前进。死锁就是两个线程各自持有一把锁又在等对方手里的锁。死锁四要素缺一不可打破任意一个就解了互斥锁只有一个持有并等待拿着 A 锁等 B 锁不可剥夺锁只能自己释放循环等待你等我、我等你8.2 死锁演示❌ 千万别在生产写这个#include mutex #include thread std::mutex a, b; void f1() { std::lock_guardstd::mutex la(a); std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 故意拖时间增大死锁概率 std::lock_guardstd::mutex lb(b); // f1 先拿 a 再拿 b } void f2() { std::lock_guardstd::mutex lb(b); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guardstd::mutex la(a); // f2 先拿 b 再拿 a —— 顺序反了 }8.3 三种解法按推荐程度排序解法做法优点① 统一加锁顺序最推荐所有线程都先 a 后 b绝不颠倒简单、可读、零开销② 一次性锁多个std::scoped_lock lock(a, b);C17 内置防死锁自动规避顺序问题③ 超时放弃用 std::timed_mutex 的 try_lock_for拿不到就放弃重试处理复杂嵌套场景的兜底8.4 其他高频大坑速览坑症状解法数据竞争结果随机错、偶发崩溃共享数据一律上锁只读数据用 const 共享悬垂引用detach 后访问已销毁变量随机崩溃传给线程的数据要么拷贝、要么用 shared_ptr 保命持锁太久其他线程全部排队性能崩塌锁范围最小化耗时操作移到锁外忘记 join线程对象析构时 std::terminate 崩溃养成创建必配对 join/detach的习惯假唤醒wait 醒来条件其实没满足永远用 cv.wait(lock, 谓词)future 重复 get抛 std::future_error只 get 一次或用 shared_future误用原子操作以为原子能替代锁原子只解决单变量多变量一致性仍需锁原子操作底层原理见另一篇博客8.5 工具推荐用 Sanitizer 抓数据竞争编译时加上 AddressSanitizer / ThreadSanitizer能自动帮你揪出数据竞争和内存错误Linux/macOS 可用Windows 用 MSVC 的 /fsanitize 或直接上 VS 诊断g -stdc17 -pthread -fsanitizethread demo.cpp -o demo ./demo # 有数据竞争时会打印详细报告⚠️注意TSan 会显著拖慢程序只用于调试别带上生产编译。❓ FAQ 速查表问题一句话答案创建线程要 include 什么#include thread编译加 -pthreadMSVC 不用join 和 detach 区别join 主线程等子线程detach 放飞子线程。一个线程对象只能二选一多线程改同一个变量结果不对数据竞争。用 std::mutex std::lock_guard 保护临界区lock_guard 和 unique_lock 怎么选只用锁就 lock_guard要配合条件变量/手动解锁就 unique_lock条件变量 wait 为什么要传谓词防假唤醒被唤醒后先检查条件是否真满足不满足继续睡notify_one 和 notify_all 区别叫醒一个 vs 叫醒全部多个等待者时用 allfuture 能 get 几次一次第二次抛 std::future_error多线程共享结果用 shared_futurepromise 能拷贝吗不能传参必须 std::move什么时候该用线程池任务高频短小如服务器请求处理避免反复创建销毁线程的开销死锁怎么破统一加锁顺序或 std::scoped_lock 一次锁多个或超时放弃detach 后线程还能用主线程局部变量吗不能主线程可能已销毁它导致悬垂引用随机崩溃什么情况下别用多线程任务太小、强顺序依赖、单核机器——多线程开销反而更大总结你的多线程最小行动清单第 1 步装好编译器记住 -pthread第 2 步std::thread 创建线程join/detach 二选一第 3 步共享数据加 std::mutex用 lock_guard 保护第 4 步需要等某个条件时上 condition_variable带谓词 wait第 5~6 步要结果用 async/future 或 promise/future 管道第 7 步高频短小任务上线程池本文 60 行版可直接抄第 8 步记住死锁四要素 FAQ 里的坑用 TSan 帮你查漏。
返回列表