ARTICLE DETAIL

资讯详情

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

C++多线程编程从基础到实战:std::thread、锁与线程池详解

C++多线程编程从基础到实战:std::thread、锁与线程池详解 C里写线程说简单也简单说难也难。简单到std::thread一句话就能拉起一个线程难到线上服务偶发卡顿、数据莫名其妙多了一位查了三天才发现是多线程并发写同一个变量惹的祸。这篇文章就是给刚把C基础语法过完、想系统了解一下线程的同学准备的。我会从进程和线程的区别讲起把创建线程、传参、加锁、条件变量、线程池这些东西逐个拆开配合可以直接跑的小例子讲清楚每个操作背后的为什么也把那些不踩一遍不会长记性的坑提前指给你看。不管你是正在准备面试还是项目里第一次遇到并发需求这篇都值得你花上十几分钟从头看一遍。1. 先想清楚C程序为什么需要线程1.1 进程与线程到底谁是谁很多初学者对进程和线程的概念是模糊的上来就写std::thread结果越写越懵。先花两分钟把底层的账算清楚。进程是操作系统分配资源的基本单位它拥有独立的地址空间、文件描述符表、信号处理器等。线程是CPU调度的基本单位一个进程内部可以包含多个线程这些线程共享同一个进程的地址空间、堆内存和全局变量。打个比方进程像一家公司每个线程像是公司的员工。公司有自己的办公场地独立地址空间员工们在同一栋楼里办公共享内存可以随便用公共打印机和茶水间共享资源但如果有两个人同时抢一台打印机还不排队那就会出事。最直观的区别表现在开销上对比项进程线程地址空间相互独立共享进程地址空间创建开销较高需要分配独立资源较低复用进程资源通信方式需要IPC管道、共享内存等直接读写共享内存即可切换成本较高涉及地址空间切换较低但也不是零成本故障隔离一个进程崩溃不影响其他进程一个线程出问题可能拖垮整个进程我平时跟新人聊天时最常强调的一点是线程虽然切换成本比进程低但绝不是没有成本。系统底层需要保存和恢复寄存器状态、维护调度队列一次上下文切换通常需要几百纳秒到几微秒不等的开销。如果任务本身执行只需要一微秒你非拆成两个线程去跑调度开销比任务本身还贵性能反而会变差。1.2 C11之前写线程的痛现在学C线程说实话是件挺幸福的事。C11之前标准库完全没有线程的概念想做并发只能依赖系统API。在Linux上用POSIX线程库写pthread_create那一长串参数还要手动处理线程属性、返回值回收在Windows上用CreateThread又是一套完全不同的接口。平台差异大、写法啰嗦、容易出错而且代码根本没有可移植性。C11正式将多线程支持纳入了标准库提供了std::thread、std::mutex、std::condition_variable、std::atomic以及std::async、std::future等一系列设施。这才让“一份代码多处编译跑”成为现实。到了C14、C17又陆续补充了共享锁、并行算法等能力到C20甚至有了std::jthread这样能自动join的线程类。不过目前生产环境里用的最多的还是C11/14这一套这也是我下面要讲的重点。1.3 线程能带来什么要付出什么代价线程的核心价值有三个一是提升响应性UI线程不被阻塞后台任务能干自己的活二是提升吞吐量多核CPU上多个线程真正并行执行计算任务三是让异步操作变得自然比如同时发起多个网络请求再统一等待结果。但收益背后是代价。最直接的代价就是复杂性数据竞争、死锁、线程安全、调试困难。并发程序的问题往往不是稳定复现的而是某种特定时序下才暴露令人头疼。所以在你动手写多线程之前先问自己一句这里真的需要多线程吗如果单线程能解决就不要为了“显得高级”而强行并发。先保证正确再谈性能这句话对于刚接触并发编程的人尤其重要。2. 线程基础操作创建、等待与传参2.1 用std::thread拉起第一个线程std::thread的使用方式非常直观构造时传入一个可调用对象线程就随之启动并执行。#include iostream #include thread void hello() { std::cout Hello from thread, id std::this_thread::get_id() std::endl; } int main() { std::thread t(hello); t.join(); return 0; }这里的std::thread t(hello)创建了一个线程并让它从函数hello开始执行。t.join()则等待子线程执行完毕再继续主线程的后续代码。std::this_thread::get_id()返回当前线程的标识打印出来可以看到和主线程的id不同。有一点很容易忽略线程一旦创建并不等于构造完就暂停在原地等你指挥它可能立刻就开始执行了。两个线程的执行顺序是操作系统调度器决定的你在代码里看到的“先创建后执行”只是表象实际完全可能相反。所以任何假设“这条线程一定先跑到某行”的写法都是隐患。2.2 join与detach想清楚再选join和detach是线程对象敲定的两个最终归宿。join是阻塞等待线程执行完后才会从join()返回适合需要子线程结果或要确保子线程退出后才能安全释放资源的场景。detach则是把线程与当前std::thread对象分离被分离的线程会成为“后台线程”独立运行不再有对象能直接管理它。新手最容易踩的坑是创建线程后既不join也不detach直接让std::thread对象析构。这种情况下如果线程还处于joinable状态程序会直接调用std::terminate终止整个进程。这不是编译报错是运行时的致命打击而且毫无商量余地。我见过不少同学把这当成抽象威胁直到自己的程序莫名崩溃才回去补join。还有一点要特别提醒detach并不是一劳永逸。主线程退出意味着进程退出进程退出时所有线程都会结束。所以不要让“detach的后台线程能一直跑”这种错觉支配设计后台任务必须在进程生命周期内完成或者有明确的生命周期管理机制。2.3 给线程传参引用、指针与所有权转移std::thread构造时传给线程函数的参数默认是按值拷贝的。这样设计有它的道理线程函数在自己独立的上下文中运行参数拷贝出一份副本外部对象的生死不会影响线程内部安全性有保障。但这也带来一个经典困惑我想通过引用修改一个外部变量怎么传不进去#include thread #include iostream void change(int x) { x 100; } int main() { int value 0; // 直接写 std::thread t(change, value) 是不行的编译报错 std::thread t(change, std::ref(value)); t.join(); std::cout value std::endl; // 100 return 0; }关键就在于std::ref(value)它把value包装成引用包装器线程内部才能把它解包成真正的引用。如果不加std::refchange收到的是一份拷贝外部value永远不会改变而你甚至不会收到任何报错只是结果不正确。这种“安静的错误”比编译错误更坑人。对于std::unique_ptr这类只允许移动的对象要用std::move把所有权转移进线程。移动之后原线程里的对象已经空了不能再使用。最后一个原则性的提醒如果你在子线程中使用了外部对象的引用或指针一定要确保该对象在线程运行期间仍然存活。最常见的问题是detach线程中引用局部变量如下面这种std::thread t; { int local 42; t std::thread([] { /* 不能访问 local */ }); }这里若访问local就是使用悬垂引用轻则得到垃圾值重则直接段错误。解决思路是不要在新线程中引用栈上局部变量要么用std::ref传递堆变量要么直接按值捕获。3. 线程同步数据竞争与锁3.1 数据竞争为什么可怕先看个简单例子两个线程各自对同一个int执行一万次。你想当然地觉得最后结果应该是两万实际跑起来经常会得到一万九千多、一万九千五百多这样的数字。问题出在不是原子操作。它在底层可以拆成“读取内存到寄存器、寄存器加1、写回内存”三步。设初始值为0线程A读了0还没写回线程B也读了0两个线程各自加1写回结果还是1而不是2。这种多个线程同时访问同一块共享数据且至少有一个是写操作的行为在C标准里被定义为数据竞争属于未定义行为UB。我刚开始接触并发时也曾想不就是结果偶尔少几个吗大不了重试一次。真正深入学习后才发现未定义行为远比“结果不对”严重编译器在-O2优化下可能把包含数据竞争的代码重写成让你完全无法理解的样子甚至崩溃、死循环、逻辑错乱都会出现。所以正确的做法不是碰运气而是从源头消除数据竞争。3.2 mutex与lock_guard最简单可靠的锁互斥锁是最基础的同步工具用它保证同一时刻只有一个线程进入临界区。#include iostream #include thread #include mutex #include vector std::mutex mtx; int counter 0; void worker() { for (int i 0; i 100000; i) { std::lock_guardstd::mutex lock(mtx); counter; } } int main() { std::thread t1(worker); std::thread t2(worker); t1.join(); t2.join(); std::cout counter std::endl; // 200000 return 0; }代码里用的是std::lock_guardstd::mutex这是一个RAII资源获取即初始化封装构造时自动加锁作用域结束时自动解锁。即使临界区里抛出异常锁也会被正常释放。与之相比手动lock()和unlock()很容易因为提前return或异常导致忘记解锁最终卡死其他线程。我自己的习惯是能用lock_guard就绝不用裸lock这是最基本的第一道防线。锁带来的代价是性能。两个线程抢同一把锁意味着同一时刻只有一个线程能进入临界区其他线程只能阻塞等待。锁的粒度越大并发度越低。所以实际开发中要尽量缩小临界区的范围只把读共享变量、写共享变量的地方锁起来不要把无关计算也包进锁里。3.3 条件变量让线程学会等待锁能解决互斥但解决不了“线程需要等待某个条件成立再继续”的问题。经典场景是生产者-消费者消费者线程不能空等它得知道“队列里什么时候有数据”。如果让消费者循环检查队列CPU会被白白耗光这叫忙等待。条件变量就是为此而生的。#include iostream #include thread #include mutex #include condition_variable #include queue std::mutex mtx; std::condition_variable cv; std::queueint tasks; void producer() { for (int i 0; i 5; i) { { std::lock_guardstd::mutex lock(mtx); tasks.push(i); std::cout produce i std::endl; } cv.notify_one(); // 唤醒一个等待线程 std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } void consumer() { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return !tasks.empty(); }); int val tasks.front(); tasks.pop(); lock.unlock(); std::cout consume val std::endl; if (val 4) break; } } int main() { std::thread p(producer); std::thread c(consumer); p.join(); c.join(); return 0; }这段代码有两个细节值得深挖。第一cv.wait必须配合std::unique_lock而不是std::lock_guard。原因是wait内部需要临时解锁让出锁的所有权等待被唤醒后再重新加锁而unique_lock支持这种灵活的加锁和解锁操作。第二wait的第二个参数是个谓词它其实是防止“伪唤醒”的保险。操作系统可能因为信号等原因把线程唤醒但此时条件并没有真正满足。如果只用if检查条件伪唤醒会直接让线程拿到空队列数据用while循环或谓词就不会出问题。C标准库的cv.wait(lock, predicate)内部就是一个while循环这也是我一直推荐大家用这个重载形式的原因。3.4 死锁最隐蔽的敌人两个线程各自先拿一把锁再尝试去拿对方的锁谁都不肯放手于是双双卡死。这就是死锁的经典形态。我见过不止一个线上案例现象是服务某个接口偶发完全无响应日志中断在某一个步骤最后用gdb挂上去才看到两个线程相互等待。死锁的产生需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。程序员能直接控制的是“循环等待”这一环。最务实的对策是多个线程需要多把锁时始终按相同的顺序加锁。比如所有线程都先锁A再锁B就不会出现一个人拿了B等人家的A这种僵局。C标准还提供了std::lock它能在一次调用中同时锁住多个互斥量内部保证不产生死锁std::lock(mtx1, mtx2); std::lock_guardstd::mutex lock1(mtx1, std::adopt_lock); std::lock_guardstd::mutex lock2(mtx2, std::adopt_lock);这段代码的要点是先用std::lock加锁再用std::adopt_lock告诉lock_guard“锁已经上好了你只管接管并负责析构时释放”。这样既享受RAII的便利又在加锁阶段规避了死锁风险。4. 原子变量、async与线程池4.1 std::atomic解决计数器问题如果只是针对一个整数做累加、一个布尔值做标志杀鸡用牛刀地加mutex会有点浪费。标准库提供了std::atomic原子类型它基于CPU提供的原子指令实现通常没有锁。#include atomic #include thread #include iostream std::atomicint counter{0}; void worker() { for (int i 0; i 100000; i) { counter.fetch_add(1, std::memory_order_relaxed); } } int main() { std::thread t1(worker); std::thread t2(worker); t1.join(); t2.join(); std::cout counter.load() std::endl; // 200000 return 0; }fetch_add是原子的读-改-写操作两个线程并发执行也不会出现之前那种丢失更新的问题。代码里的memory_order_relaxed是内存序选项表示只要求原子性、不要求其他内存操作的重排限制。对于单纯的计数器场景这是性能最优的选择。建议不要用volatile解决同步问题。volatile只告诉编译器“这个变量可能被外部修改不要优化缓存它”它在C中并不能保证原子性更不能阻止指令重排。把volatile当线程同步工具是嵌入式开发背景转过来的同学们爱踩的坑强烈提醒一句标准答案是std::atomic不是volatile。4.2 std::async与std::future现代C更优雅的方案手动创建std::thread管理生命周期、再用共享变量传递结果有点原始。很多时候我们只是想“在另一个线程执行一个任务然后拿到它的返回值”标准库为此准备了std::async和std::future。#include iostream #include future int calc(int a, int b) { return a b; } int main() { std::futureint f std::async(std::launch::async, calc, 10, 20); std::cout f.get() std::endl; // 30 return 0; }std::async启动一个异步任务并返回一个std::future对象future.get()会阻塞等待任务执行完成并取出返回值。与手写thread相比这种写法的优势是不需要join不用为返回值设计共享变量异常也能通过future传递回调用方。一个值得注意的细节std::async的策略参数如果不写标准库允许实现自行决定是异步执行还是延迟到get()时同步执行。为了确保真正并发运行建议显式传入std::launch::async。4.3 线程池从手写线程到按需复用如果每次来一个任务就新建一个线程任务执行完再销毁线程在高频短任务场景下会非常浪费。频繁创建线程涉及系统调用、内核对象分配、栈空间分配等开销几百上千个任务就能明显感受到性能下降。线程池的核心理念是提前创建一批线程反复复用任务来了直接投递进队列由池中线程消费执行。一个最简线程池包含三个要素固定数量的工作线程、一个任务队列、一把用于保护队列的互斥锁加一个条件变量。流程是外部往任务队列里塞任务notify_one唤醒一个工作线程工作线程从队列取任务执行队列为空则wait等待。任务队列的阻塞队列选型是设计里很关键的一环无界队列实现简单不会因为任务过多而阻塞提交方但任务堆积太多时会耗尽内存有界队列限定了任务上限超过上限后要用“丢弃、等待或由提交者自己执行”等策略牺牲部分吞吐量来换取稳定性。C标准库本身没有直接提供线程池生产环境里可以用boost.asio、TBB这类久经考验的库理解上面的原理有助于你正确配置它们。5. 常见问题与排查技巧实录5.1 症状与对策速查表多线程程序出了问题第一步是看症状第二步按症状去找原因避免漫无目的地试错。这里我整理了一份速查表症状常见原因排查思路程序一跑就崩溃线程对象析构时仍joinable、悬垂引用检查是否漏了join/detach审查线程内引用的对象生命周期程序卡死不动死锁、条件变量丢唤醒、锁忘记释放gdb挂上执行thread apply all bt看每个线程栈数据结果不正确数据竞争、忘记加锁用ThreadSanitizer复现审查共享变量访问点性能不升反降锁竞争激烈、临界区过大、线程创建销毁频繁分析锁等待时间缩小临界区考虑线程池偶发段错误栈空间不足、悬垂指针用AddressSanitizer跑一遍检查深层递归5.2 两个线程读写同一个大数组怎么设计“两个线程分别读写一个大数组”这问题看起来简单实际藏了很多设计选择。假设线程A在写数组线程B想等A写完后读取结果做汇总。如果全程加一把大锁线程B会一直被阻塞A辛辛苦苦写的数据B完全帮不上忙并发等于白开。更合理的思路有两种。第一种是分区无锁如果A和B各自处理的区域不重叠比如A写数组前半段B写数组后半段那根本不需要锁最后汇总是两个互不干扰的结果相加。这种方式在数组可分割时是性能最优的。第二种是发布-订阅A写完整个数组后用一个std::atomicbool或条件变量把“数据已就绪”的消息发布出去B收到信号后再开始读。这是生产者-消费者模型在大数据场景下的应用。现实项目中我遇到最多的问题反而是第三种A和B确实需要同时访问同一个数组的不同区域但编译器或CPU的缓存让这种“看似安全”的访问出现性能问题。比如两个人分别改数组的相邻元素实际上可能落在同一个缓存行上导致缓存行反复在两个CPU核心之间颠簸这叫伪共享False Sharing性能会莫名下降。解决手段是让不同线程操作的变量按缓存行大小对齐通常通过alignas(64)这属于比较进阶的优化话题初学者知道有这回事就够用。5.3 新手最容易踩的5个坑整理几个我几乎每次带人都说一遍的坑detach后子线程还在跑局部变量已销毁访问就是悬垂引用。创建了std::thread既没join也没detach析构时程序直接终止。传引用给线程函数忘了std::ref以为改了实际没改。条件变量用if检查条件遇到伪唤醒直接取到空数据。手动lock/unlock提前return忘记解锁线程卡死在等待锁上。这些坑的共同点在于编译阶段都不报错甚至能正常跑几十次直到某次调度时机不对才暴露。正是这种“偶发性”让多线程调试显得格外痛苦。5.4 用Sanitizer和gdb快速定位问题如果代码里怀疑有数据竞争不要靠眼睛盯着代码干瞪眼直接用工具。ThreadSanitizerTSan是专门检测数据竞争的利器编译时加上-fsanitizethread -g运行时会精确报告是哪两个线程、哪两个位置访问了同一块内存。我建议所有并发代码在开发阶段都开一次TSan跑测试用例它能抓出绝大多数数据竞争问题成本极低收益非常高。如果程序死锁先把进程挂上gdb执行thread apply all bt能看到所有线程的调用栈。把各个线程的栈结合看如果线程A停在锁B的获取上线程B停在锁A的获取上死锁基本就实锤了。再看代码里的加锁顺序调整成一致即可。顺便提一句核心转储文件也可以用相同方法离线分析很多线上问题就是这么定位出来的。写在最后的一些体会C线程这块内容光看不练很容易有“我懂了”的错觉真正动手写时才发现全是坑。我个人特别推荐一个学习路径先老老实实把std::thread、join/detach、mutex、condition_variable这些基础代码亲手敲一遍然后把示例代码故意写错几次比如故意漏掉join、故意让两个线程争抢共享变量亲眼看看程序崩溃或结果错误的样子。有过这种“亲眼见证灾难”的经历你对并发危险点的记忆会比看任何教程都牢固。我自己早期的经验是每写一处并发代码都先问问“这个共享变量有几个人在写有几个人在读他们之间的先后关系是什么”回答完这几个问题再去写能省掉后续大量调试时间。多线程调试的产出比很低与其在线上被问题逼着查不如在设计阶段多想一层。C线程这条路入门不难入门之后才知道水深但值得认真趟一遍。
返回列表