ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++多线程实战:从std::thread到线程池的并发优化指南

C++多线程实战:从std::thread到线程池的并发优化指南 做C开发这十来年多线程一直是我最不敢马虎、也最有话想说的一块。前段时间帮朋友排查一个线上服务偶发崩溃进程退出时莫名其妙报了std::terminate看栈信息是某个线程还在跑但它要用的上下文对象已经被提前释放了。再往下一查代码里用std::thread创建线程后直接调了detach()线程还没跑完局部变量先没了。这个场景在C项目里太典型了尤其是一些写了几年、对并发只是“大概懂”的代码里往往是埋雷的重灾区。这篇文不打算讲学院派理论而是按现代CC11到C20的实际用法把线程创建、同步、任务派发和性能排查这条线完整串一遍。适合谁看如果你刚接触多线程想用std::thread、async、线程池把这些能力写进项目这篇能帮你少走不少弯路。如果你已经写过一段时间并发代码建议重点看第三节的线程池和第四节的排查实录都是我在真实项目里踩过坑、验证过的心得。1. 线程创建与生命周期管理先把 std::thread 用明白再谈并发1.1 std::thread 基本用法从创建到 join 再到 detach现代C里最基础的线程创建方式就是std::threadC11引入用法很直白把可调用对象和参数扔进构造函数线程就开始跑了。#include thread #include iostream void worker(int id) { std::cout thread id is running\n; } int main() { std::thread t(worker, 1); t.join(); return 0; }构造std::thread时第一个参数可以是普通函数、lambda表达式、函数对象甚至是成员函数指针。传参的细节有个经典坑std::thread构造函数使用完美转发但传给线程函数的参数默认是按值拷贝的。如果你想传引用必须显式用std::ref包装void modify(std::string s) { s modified; } int main() { std::string name hello; // 如果不包 std::ref线程里操作的是 name 的拷贝改完 name 不变 std::thread t(modify, std::ref(name)); t.join(); std::cout name std::endl; // hello modified }很多新手在这里栽过跟头以为传了引用结果线程内部改的是副本外部对象毫无变化。如果你在modify里意外看到了“修改成功”的假象多数时候是因为函数参数本身就是按值传递的只是恰好副本和原对象内容一样产生了错觉。创建线程之后必须在某个时刻对线程对象调用join()或detach()。join()是阻塞等待线程结束detach()则是把线程“放养”让它在后台自己跑完线程对象和底层线程的关联被切断。如果在std::thread对象销毁时它仍然关联着一个可执行线程程序会直接调用std::terminate整个进程崩溃。这一点比大多数人想象的要严肃得多线上很多诡异崩溃就是这么来的。1.2 为什么我几乎不直接裸 detach先说结论在实际项目里裸用detach()要非常克制。原因有几个。第一detach()之后你无法再获得线程的结果线程抛出的异常也无法被捕获一旦线程函数内部出了问题你连知道都不知道。第二被detach()的线程生命周期完全失控它可能在你某个局部变量销毁之后还在访问那个变量这比内存泄漏还难查。第三调试困难gdb里一把线程栈打出来你根本分不清这个流浪线程是谁创建的、要干什么。之前我给朋友排查的那个崩溃就是典型的detach野指针问题线程函数捕获了外部对象的指针主线程提前退出对象析构线程才执行到一半访问了已经失效的内存程序在退出阶段抛异常。这种问题在开发环境极难复现因为线程调度的时间点每次都不同只有到高并发或特定负载下才会偶然触发。如果你确实需要一个后台任务且不关心它的完成时机C20里有一个更好的替代方案std::jthread。它会自动在析构时请求停止并join()支持std::stop_token实现优雅退出#include thread #include iostream #include chrono int main() { std::jthread t([](std::stop_token st) { while (!st.stop_requested()) { std::cout loop working...\n; std::this_thread::sleep_for(std::chrono::milliseconds(200)); } }); std::this_thread::sleep_for(std::chrono::seconds(1)); t.request_stop(); // 请求停止析构时自动 join }这个设计让线程生命周期和对象生命周期绑定在一起不会出现“忘了join导致崩溃”或者“强行detach导致野指针”的尴尬局面。即使你的项目还在用C14/17也完全可以自行封装一个类似的小工具类来做RAII式线程管理思路是一样的析构函数里统一join()。1.3 线程数量不是越多越快很多刚接触多线程的人以为线程数开得越多并行度越高性能就越好。这个想法在IO密集任务里可能沾点边但在CPU密集任务里线程数超过物理核心数只会增加上下文切换的开销性能反而下降。一个常见经验值是CPU密集任务线程数设为std::thread::hardware_concurrency()通常等于物理核心数或逻辑核心数IO密集任务可以适当多一些。实际项目里最靠谱的方式不是拍脑袋定数字而是做压测先从硬件并发数开始逐步增加线程数观察吞吐量和响应时间的变化找到拐点。线程创建本身是有成本的。每个线程都有一块独立的栈空间默认几MB的虚拟内存内核也要维护线程控制块。如果一个任务只需要几十毫秒就能完成那么创建线程的开销可能比任务本身还大。所以“线程池”概念在多线程编程中几乎是必备的这个我放在后面第三节详细讲。2. 线程同步与数据竞争从互斥锁到原子变量2.1 数据竞争是万恶之源多线程编程里最常见的bug就是数据竞争data race两个或多个线程同时访问同一块内存其中至少一个是写操作而且没有同步机制。数据竞争的行为是未定义的程序可能产生随机错误结果甚至崩溃。看一个经典的例子#include thread #include iostream int counter 0; const int N 1000000; void bad_increment() { for (int i 0; i N; i) { counter; } } int main() { std::thread t1(bad_increment); std::thread t2(bad_increment); t1.join(); t2.join(); std::cout counter std::endl; // 期望 2000000但通常小于这个值 }这段代码跑出来的结果不是2000000而是千奇百怪的比2000000小的数字。原因是counter并不是一步操作它分成了“读取counter的值”、“对值加1”、“把新值写回内存”三步。两个线程可能同时读到同一个旧值各自加1再分别写回结果就丢了一次递增。用一个生活里的类比你和同事各持一份账本老板发话“每个人把工资总数加一百”你们俩同时从旧余额出发各自在自己的本子上写了个“100”最后账面上只多了一百而不是两百。数据竞争就是这么一回事。解决数据竞争的方法就是给共享数据的访问加锁让同一时刻只有一个线程能进入临界区。2.2 mutex 家族lock_guard、unique_lock、scoped_lockC11引入的std::mutex是最基础的互斥锁但它裸用容易忘记解锁一旦中间有异常抛出锁就永远不释放了。所以现代C里更推荐使用RAII锁。#include mutex std::mutex m; int counter 0; void safe_increment() { for (int i 0; i N; i) { std::lock_guardstd::mutex lock(m); // 构造时加锁析构时解锁 counter; } }std::lock_guard是最简单的RAII锁构造时上锁析构时解锁中途不能手动解锁也不能移动。适合临界区不长、不需要中途解锁的场景。std::unique_lock更灵活一些它支持手动lock()和unlock()支持移动还能作为条件变量的等待锁。代价是性能上比lock_guard略差一点点但对于绝大多数应用来说可以忽略。std::scoped_lock是C17新增的它可以接受多个互斥量并在内部用统一的算法一次性锁住它们避免因为加锁顺序不同导致死锁std::mutex m1, m2; void safe_ops() { std::scoped_lock lock(m1, m2); // 同时持有两个锁不会因顺序问题死锁 }我在实际开发里给团队定的规矩是默认优先用std::scoped_lock或std::lock_guard需要条件变量配合时换std::unique_lock。什么时候用哪个锁核心判断标准就是“需不需要中途解锁”。下面这个表格可以帮你快速选型锁类型适用场景特性std::lock_guard简单临界区全程持锁RAII不可中途解锁开销最小std::unique_lock条件变量、需要手动解锁/重锁RAII可移动可手动lock/unlockstd::scoped_lock需要同时锁多个互斥量C17变参模板避免锁顺序死锁2.3 条件变量别用轮询用通知很多场景下一个线程需要等待某个条件成立才能继续工作。比如任务队列空了消费线程应该等着生产者往队列里放了数据消费线程要立刻被唤醒。新手最容易写出的方案是死循环 sleep轮询每隔几十毫秒检查一次队列。这种写法有两个明显问题一是浪费CPU二是实时性差任务来了不能立刻响应。条件变量就是用来解决“等待-唤醒”问题的标准机制。典型的生产者消费者模型#include condition_variable #include mutex #include queue #include thread std::mutex m; std::condition_variable cv; std::queueint tasks; void producer() { for (int i 0; i 100; i) { { std::lock_guardstd::mutex lock(m); tasks.push(i); } cv.notify_one(); // 唤醒一个等待中的消费者 } } void consumer() { while (true) { std::unique_lockstd::mutex lock(m); // wait 会先检查谓词不满足则释放锁并阻塞被唤醒后重新拿锁再检查 cv.wait(lock, [] { return !tasks.empty(); }); int v tasks.front(); tasks.pop(); lock.unlock(); // 在锁外处理 v避免长期持锁 process(v); } }这里有两个关键点必须理解。第一为什么wait需要std::unique_lock而不是std::lock_guard因为wait在阻塞期间必须释放互斥锁让别的线程能进入临界区写入数据被唤醒后又要重新获取锁。这个“解锁-等待-加锁”的流程只有unique_lock能做到lock_guard不支持中途解锁所以条件变量必须配unique_lock。第二为什么要用带谓词的wait因为存在“伪唤醒”spurious wakeup——即使没有notify线程也可能被系统唤醒。如果只写cv.wait(lock)线程可能醒来时队列还是空的取出空队列的元素就出问题了。带谓词的写法相当于让wait循环检查条件只有条件真正成立才返回伪唤醒会被自动过滤掉。notify_one()唤醒一个等待线程notify_all()唤醒所有等待线程。如果只有一个消费者用notify_one就够了如果有多个消费者且一次生产了多个任务可以考虑notify_all但要注意惊群效应带来的锁竞争。2.4 原子量什么时候可以不用锁对于简单的计数器、标志位这类操作std::mutex有点“杀鸡用牛刀”了。C11提供了std::atomic原子类型对它的操作是原子的不需要加锁。#include atomic std::atomicint counter{0}; void safe_increment() { for (int i 0; i N; i) { counter.fetch_add(1, std::memory_order_relaxed); } }为什么用atomic比用mutex快因为原子操作会直接编译成CPU提供的原子指令比如x86上的lock xadd不需要操作系统调度和用户态内核态切换。而互斥锁在竞争激烈时可能会让线程进入睡眠再被唤醒这个开销是纳秒和微秒级的差距。注意我上面用了std::memory_order_relaxed。内存序是原子操作里很进阶的内容简单来说它告诉编译器“这个原子操作只需要保证原子性不需要对其它内存操作进行顺序约束”性能最好。对于计数器这类“只在乎最终值正确”的场景relaxed足够了。如果你不确定该用哪种内存序就用默认的std::memory_order_seq_cst它是全序的最安全但性能略低。原子量不是万能的。如果你需要让多个共享变量保持一致的“状态切换”比如账户A扣钱和账户B加钱必须作为一个整体对其它线程可见那么原子量做不到必须用锁把整个临界区包起来。原子量只保证单个操作的原子性不保证多个原子操作组成的复合操作整体原子性这个边界一定要记牢。3. 任务派发与性能优化从 future 到线程池3.1 想让线程返回结果用 future 和 async直接使用std::thread有个麻烦事线程函数既没有返回值也无法把异常传回主线程。你没法知道线程计算的结果是什么更没法优雅地处理线程内部的异常。std::async和std::future解决了这个问题。std::async启动一个异步任务返回一个std::future对象通过future.get()可以拿到任务返回值如果任务内部抛了异常get()会在主线程里重新抛出可以被捕获#include future #include iostream #include stdexcept int compute(int x) { if (x 0) throw std::runtime_error(negative input); return x * x; } int main() { std::futureint f std::async(std::launch::async, compute, 10); try { std::cout result f.get() std::endl; } catch (const std::exception e) { std::cerr e.what() std::endl; } }这里有一个非常实用的坑要提醒如果调用std::async时不显式指定std::launch::async实现可能采用std::launch::deferred策略任务不会在后台线程运行而是等到future.get()被调用时才在调用线程里同步执行。这就失去了并行的意义。所以当你明确需要并行执行时务必写成std::async(std::launch::async, ...)。std::promise是另一种包装方式你可以在一个线程里通过promise.set_value()设置结果在另一个线程里通过对应的future.get()取结果。它适合在“任务运行中你想在任意时刻手动传递结果或异常”的场景可以实现更灵活的生产者-消费者模式。3.2 自己实现一个线程池其实没多难线程池是并发编程中的基础设施。为什么要用线程池因为线程的创建和销毁是有成本开销的。在Linux上pthread_create会分配栈空间、设置调度参数、完成内核线程的创建一次创建可能要几十微秒甚至更多。如果一个业务每来一个请求就创建一个线程高并发下系统会疲于创建销毁线程吞吐量反而上不去。线程池的核心思路是启动时就创建固定数量的线程让它们共同从一个任务队列里取任务执行队列为空时线程阻塞等待来了新任务就唤醒它们执行。这样线程只创建一次后续全程复用缩短了频繁创建销毁的开销。我写过一个很精简的线程池足够应付大多数业务了#include condition_variable #include functional #include mutex #include queue #include thread #include vector class ThreadPool { public: explicit ThreadPool(size_t threads std::thread::hardware_concurrency()) : stop_(false) { for (size_t i 0; i threads; i) { workers_.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(queue_mutex_); cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ tasks_.empty()) { return; } task std::move(tasks_.front()); tasks_.pop(); } task(); } }); } } template typename F void enqueue(F f) { { std::lock_guardstd::mutex lock(queue_mutex_); tasks_.emplace(std::forwardF(f)); } cv_.notify_one(); } ~ThreadPool() { { std::lock_guardstd::mutex lock(queue_mutex_); stop_ true; } cv_.notify_all(); for (std::thread worker : workers_) { worker.join(); } } private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex queue_mutex_; std::condition_variable cv_; bool stop_; };使用方式ThreadPool pool(4); for (int i 0; i 20; i) { pool.enqueue([i] { std::cout task i executed\n; }); }这个实现里有一个非常关键的细节藏在析构函数的判断里if (stop_ tasks_.empty()) return;。这个条件的含义是当线程池被销毁时已经提交但还没执行的任务会继续被执行完剩下的新任务不再接受。如果你把条件写成if (stop_) return;那么析构时所有排队中的任务都会被直接丢弃这在某些业务里是不能接受的。再补充一点这个简单的线程池enqueue不支持返回值。如果需要提交一个带返回值的任务可以改成让enqueue接收std::packaged_task并把对应的future返回给调用方。思路并不复杂只是代码会长一截。3.3 并行标准库sort 也能开多线程如果你只是想“让一段算法跑得更快”不需要自己手写线程池。C17提供了并行算法Parallel Algorithms配合std::execution策略你可以一句话把单线程的std::sort变成多线程版本#include algorithm #include execution #include vector std::vectorint v /* ... */; std::sort(std::execution::par, v.begin(), v.end());std::execution::par表示算法可以并行执行标准库实现会自己切分数据、分配线程。好处非常明显你不必关心线程数量、任务切分、负载均衡所有细节都封装在了标准库实现里。需要提醒的是编译器兼容性。目前MSVC的并行算法支持比较完善GCC则需要安装并链接TBBThreading Building Blocks且不同版本的GCC对不同并行算法支持的完善程度不一样。使用前一定要查一下你用的编译器版本对应的文档否则可能出现编译通过但链接失败或者某些算法实际没有并行化的情况。另外并行算法的谓词必须线程安全不能修改共享状态。3.4 伪共享真正影响多线程性能的隐匿杀手有时候你明明给两个线程各分配了一块互不相干的数据为什么性能还是很差这可能是“伪共享”False Sharing在捣鬼。CPU是按缓存行cache line读取内存的常见的缓存行大小是64字节。如果两个变量被放在了同一个缓存行里两个线程分别修改这两个看似独立的变量会导致两个核心上的缓存行不断互相失效每次修改都要把整个缓存行重新同步到内存性能下降得非常厉害。举个例子struct Data { int a; // 线程1修改 int b; // 线程2修改 }; Data d;a和b很可能落在同一个64字节缓存行里。线程1不断写a线程2不断写b它们没有真正共享同一变量但缓存行是共享的两个核之间来回同步缓存行效率极低。解决办法是让这两个变量分属不同的缓存行可以使用alignas(64)对齐或手动填充字节struct alignas(64) Data { int a; int b; };我做过的某次优化里两个线程各写一个int加上alignas(64)之后性能提升了一倍多。这个坑特别隐蔽因为代码逻辑上完全正确也不会出现数据竞争纯粹是CPU缓存层不配合。做性能压测时如果发现多线程无法线性扩展优先检查一下是不是存在伪共享。4. 实战排查死锁、数据竞争与性能瓶颈的定位方法4.1 死锁怎么发生、怎么定位死锁是并发编程里最著名的坑。四个必要条件互斥、持有并等待、不可剥夺、循环等待。在实际工程里最常见的死锁原因就是加锁顺序不一致。看这段典型代码std::mutex m1, m2; void thread_a() { std::lock_guardstd::mutex lock1(m1); std::lock_guardstd::mutex lock2(m2); // do something } void thread_b() { std::lock_guardstd::mutex lock2(m2); std::lock_guardstd::mutex lock1(m1); // do something }线程a先拿m1再拿m2线程b先拿m2再拿m1。如果两个线程同时执行就会发生经典的“各持一锁互等对方”的死锁。解决办法非常简单粗暴让所有地方都以同一个全局顺序加锁或者用std::scoped_lock一次锁住多个互斥量。排查死锁最直接的工具是gdb。程序卡住时用gdb附加到进程gdb -p pid然后在gdb内部执行(gdb) thread apply all bt这个命令会把所有线程的调用栈打印出来。死锁时通常能看到两个线程都停在__lll_lock_wait或者类似的锁等待函数上顺着栈往下看就能找到等待的锁是在哪里被哪个线程占用的。配合p查看变量地址基本能快速定位。4.2 数据竞争不好复现让工具替你做数据竞争的可怕之处在于它大多数时候“看起来没问题”只在特定调度时机、特定负载下才跑出错误结果。我遇到过发布到生产环境后才偶发输出错乱的情况本地跑一万次都复现不了最后用工具抓到真凶两个线程访问同一个全局变量一个写一个读完全没有同步。现代编译器提供了非常好的工具——ThreadSanitizerTSan。编译时加上开关g -fsanitizethread -g -O1 main.cpp -o main运行程序后如果发生数据竞争TSan会打印出具体是在哪个文件哪一行发生的以及两个线程的调用栈。它能直接告诉你“这两行访问之间有竞争”省去大量猜测和复现的时间。TSan非常适合在开发阶段和CI里跑。把它加进回归测试的编译选项里跑几轮测试大多数数据竞争都能被提前揪出来。另一个类似的工具是valgrind --toolhelgrind也能检测数据竞争和死锁但运行速度慢很多适合小规模复现。顺带提一个很容易误导人的现象std::cout多线程输出乱序。这是因为标准输出流内部是有缓冲和全局状态的多个线程同时输出会互相穿插甚至出现乱码但这并不代表业务逻辑有数据竞争。排查时要学会把“日志输出乱序”和“业务数据竞争”区分开否则容易在错误的方向上浪费大量时间。4.3 锁粒度一锁到底只会更慢很多新手写并发代码时为了图省事容易把一段很重的计算、IO操作也放进锁里导致其他线程全部排队等待性能不升反降。典型的反面教材std::mutex m; void process() { std::lock_guardstd::mutex lock(m); auto result heavy_compute(); // 很耗时的计算根本不需要持锁 write_to_db(result); // 网络或磁盘IO更不应该持锁 }这个process函数里真正需要保护的只有result这个共享变量但锁却把整个计算和IO都包住了。其他线程想要进入临界区只能傻等着几十毫秒甚至更久。正确的做法是std::mutex m; int latest_result; void process() { auto result heavy_compute(); // 先在锁外做耗时操作 { std::lock_guardstd::mutex lock(m); latest_result result; // 只在写共享变量时持锁 } write_to_db(result); // IO 放在锁外 }如果是任务队列场景还有一个经典技巧把整个队列里的任务swap到局部变量然后释放锁再逐个处理。这样其他线程可以继续往队列里提交任务不用等当前线程把任务跑完。我见过一个真实项目某个接口加了锁之后吞吐量不升反降排查后发现持锁期间做了一次网络请求锁被占用了几十毫秒其他线程全在等待。把网络请求移到锁外之后吞吐量立刻恢复了正常。锁的粒度越细并发度越高这是多线程优化的第一原则。聊到最后我想说说自己的体会。多线程编程里最忌讳的不是不会API而是想当然。项目里很多问题本质上都是线程模型不清晰、随手加锁、裸detach埋下的雷。我现在的习惯是写代码之前先在一张纸上把线程间共享的东西列清楚谁生产、谁消费、谁拥有数据、谁负责回收。线程数、任务切分、同步点这些如果在脑子里都能画成图代码里基本不会出大问题。工具层面我建议多花点时间把ThreadSanitizer和gdb的线程栈排查练熟关键时刻真的能救命。
返回列表