ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++多线程图文详解:从std::thread到无锁编程

C++多线程图文详解:从std::thread到无锁编程 多线程这玩意儿很多C开发者是一边骂一边用。我刚工作那会儿接手一个数据采集模块单线程跑得稳稳的但面对多路数据源和界面刷新卡顿和丢数据同时出现了。当时第一反应是想用“开几个线程”来解决结果踩了一路的坑线程销毁时程序崩溃、共享变量被两个线程反复改、锁没加好直接死锁。后来用C11标准库重写了一遍才算是把这些坑挨个填平。今天的这篇《C多线程图文详解》我打算用一篇文章把它一次讲透从一个最简单的线程开始一直讲到我终于敢在多线程代码里删锁。没有虚的直接上代码、上原理、上排查方法。你会学到std::thread怎么用、锁和条件变量怎么配合、原子变量解决什么问题、被问烂的“ABA问题”到底是个啥还有我在真实项目中踩过的具体坑。适合谁看有C基础、想系统过一遍多线程开发的人以及准备面试想临时抱佛脚的都能在这篇文章里找到价值。1. C多线程到底在解决什么问题1.1 现代CPU上单线程等于浪费先说一个很多人忽略的事实现在你手边任何一台普通电脑CPU基本都是4核、6核甚至8核起步。如果程序只跑一个线程那意味着同一时刻只有一个核心在干活剩下几个核心在那围观。我之前测过一个图像处理的例子单线程处理100张缩略图耗时300毫秒。改成4个线程分片处理后耗时掉到了90毫秒左右。这就是多线程最直接的价值——把任务拆开让多个CPU核心同时干活。典型的适用场景有两类CPU密集型任务。比如图像处理、视频编解码、矩阵运算、编译任务核心瓶颈在CPU算力。这种任务最适合用多线程做数据分片。IO密集型任务。比如网络请求、文件读写、数据库查询大部分时间线程在等IO返回CPU闲着。多线程可以让一个线程等IO的时候其他线程继续跑业务逻辑。多说一句多线程和多进程的区别也是面试常客。简单理解线程是进程里的执行单元同一进程的多个线程共享内存空间通信方便但更容易“打架”进程之间内存隔离安全但是创建代价大、通信要靠IPC。C里日常说的多线程主要指的就是同一进程内创建多个线程。1.2 不是所有场景都适合上多线程很多初学者容易犯的毛病是“只要卡顿就开线程”。多线程不是万能药它本身有成本。第一个成本是线程创建和销毁。std::thread从建立到系统真正创建一个线程涉及内核对象分配、栈空间分配这个开销不小。频繁创建销毁线程性能反而不如单线程。第二个成本是上下文切换。CPU在多个线程之间来回切换要保存当前执行状态、恢复下一个线程状态这个动作有开销。线程开得太多CPU时间全花在切换上了真正的任务反而没时间跑。第三个成本更隐蔽叫缓存问题。每个CPU核心有自己的一级二级缓存线程A在核0上处理数据线程B在核1上处理同一块数据缓存就要不断同步。这也是为什么不是所有并行代码都能线性加速。所以我在项目里的一条经验是先想清楚瓶颈在哪再决定要不要用多线程。计算慢就拆分数据等待久就异步处理都没有的话哪怕多线程代码写得很漂亮实际收益也可能为零甚至为负。2. 先从std::thread建立基本功2.1 创建线程的几种姿势C11从标准库层面提供了std::thread这意味着不用再像早期那样依赖操作系统API写pthread_create或者CreateThread平台的差异被封装掉了代码的通用性一下子好了很多。最基础的用法是传一个函数进去。#include iostream #include thread void worker() { std::cout 子线程开始干活线程ID: std::this_thread::get_id() std::endl; } int main() { std::thread t(worker); t.join(); std::cout 主线程结束 std::endl; return 0; }如果任务是带参数的直接在构造std::thread时传参就行。void print_sum(int a, int b) { std::cout sum a b std::endl; } std::thread t(print_sum, 3, 4); t.join();实际项目中我更喜欢用lambda表达式因为可以把外部变量捕获进来写起来紧凑还方便在函数中间直接开线程。int data 100; std::thread t([data]() { data 50; std::cout 子线程修改后 data data std::endl; }); t.join();还有一种玩法是传一个“可调用对象”比如重载了operator()的结构体。这种写法在写线程池的时候比较常见可以把线程要执行的任务封装成一个对象方便管理状态。2.2 join和detach选错可能直接崩创建线程之后紧接着必须决定一个问题要不要等这个线程跑完。join()是阻塞等待主线程会一直停在这里直到子线程执行完毕。好处是生命周期清晰主线程知道子线程已经结束了可以放心使用它产生的结果。std::thread t(worker); t.join(); // 主线程停在这等t跑完 std::cout worker已经结束可以安全读取结果 std::endl;detach()则是把线程丢到后台让它自己跑自己的主线程不再管它。这个操作很危险网上几乎所有的C多线程教程都会提醒detach之后线程和主线程之间不再有直接的生命周期关联。有一个经典陷阱是这样的#include iostream #include thread void func(int x) { std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout 子线程读到 x x std::endl; } int main() { { int local 42; std::thread t(func, std::ref(local)); t.detach(); } // 局部变量local在这里析构了 std::this_thread::sleep_for(std::chrono::seconds(2)); return 0; }我在很多设备上跑过这段代码结果不固定可能打印垃圾值可能直接崩溃运气好才打印42。原因很简单子线程还在访问local但local已经出了作用域被销毁这就是典型的悬空引用。所以我个人的规矩是detach()只在极其明确“线程不需要访问任何外部变量也不需要使用外部对象”的场景才用。否则老老实实join()宁可多等一会儿也比让程序随机崩溃强。2.3 参数传引用必须用std::ref参数传递这里也容易踩坑。如果你往std::thread里传一个普通变量作为参数默认是按值拷贝。void change(int x) { x 100; } int main() { int n 0; std::thread t(change, n); // 这里传的是n的副本 t.join(); std::cout n std::endl; // 输出还是0n没变 return 0; }编译器在把参数包装进线程函数时默认做的是“decay copy”也就是拷贝一份。所以想让线程修改外部变量必须用std::ref()显式告诉线程“我要传引用”。std::thread t(change, std::ref(n)); t.join(); std::cout n std::endl; // 输出100这个std::ref我在刚用std::thread的时候漏过好几次每次都是函数里明明改了值外面读出来还是老样子排查了半天才想起来是参数被拷贝了。做这行就是这样很多“诡异”的问题归根结底是对API的行为细节不够熟。3. 线程之间怎么安全地协作锁与同步3.1 数据竞争多线程的头号敌人线程创建和销毁讲完了接下来是真正的重头戏——多个线程同时访问同一份数据。先看一个经典的反面例子。两个线程同时执行counter最后结果是多少#include iostream #include thread int counter 0; void increment() { for (int i 0; i 100000; i) { counter; } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout counter std::endl; // 不是200000而是一个更小的数字 return 0; }我实测跑过很多次结果五花八门199524、197883都有偶尔才碰巧是200000。原因是counter在机器层面不是一条指令而是“读取-修改-写回”三步。两个线程可能同时读到counter的旧值各自加1后再同时写回结果就丢了一次修改。这就叫数据竞争。数据竞争的可怕之处在于它不是“每次都会出错”而是“偶发出错”这在线上环境里特别难复现。所以多线程代码里保护共享数据是第一优先级。3.2 用lock_guard代替手动lock/unlock保护共享数据最基础的手段是互斥锁std::mutex。简单说就是同一时间只允许一个线程进入关键区。#include iostream #include thread #include mutex int counter 0; std::mutex mtx; void increment() { for (int i 0; i 100000; i) { mtx.lock(); counter; mtx.unlock(); } }这段代码确实能得到200000但手写lock()和unlock()有一个隐患如果lock()之后、unlock()之前发生异常一旦跳出函数锁永远不会被释放其他线程就会卡死在lock()上。这种问题在复杂的业务逻辑里特别容易出现。正确答案是使用RAII风格的锁封装最常用的是std::lock_guard。void increment() { for (int i 0; i 100000; i) { std::lock_guardstd::mutex lock(mtx); counter; } }lock_guard在构造时自动加锁在作用域结束时包括异常导致提前退出自动解锁。不管代码怎么走锁一定会被释放。我在实际项目里基本不写裸的mtx.lock()全部用lock_guard或者unique_lock这就是个习惯问题但也是能少熬夜加班的好习惯。3.3 死锁是怎么产生的以及怎么避免锁用多了就会遇到另一个问题死锁。想象这样一个场景有两个线程线程A先锁住锁1再试图锁锁2线程B先锁住锁2再试图锁锁1。如果时间点凑巧A拿着锁1等锁2B拿着锁2等锁1两边谁也不让谁程序就永远卡住了。std::mutex mtx1, mtx2; void threadA() { std::lock_guardstd::mutex lock1(mtx1); std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guardstd::mutex lock2(mtx2); // 等一下锁2 } void threadB() { std::lock_guardstd::mutex lock2(mtx2); std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guardstd::mutex lock1(mtx1); // 等一下锁1 }两个线程各执一锁互相等待谁也跑不下去。避免死锁有两个常见思路。第一种是固定加锁顺序所有线程都按同一个顺序加锁。比如全局统一定义“必须先锁mtx1再锁mtx2”B线程也改成先锁mtx1死锁就不可能发生。这种方式简单但靠人的自觉代码一多容易乱。第二种更稳妥是用C17提供的std::scoped_lock或者老版本的std::lock它可以一次性锁住多把锁内部会处理加锁顺序不会死锁。void threadA() { std::scoped_lock locks(mtx1, mtx2); // C17 // 同时持有两把锁干活 }注意锁的范围要尽量小。持锁时间越长其他线程等待的时间就越长越容易积累卡顿。但也不能一会儿一锁锁粒度太细通信开销会变大。这个度要靠实际压测去把握。3.4 条件变量让线程别再空转等待互斥锁解决的是“同一时间只能一个人进”的问题。但很多业务场景是一个线程等着某个条件成立另一个线程去改变它。比如生产者往队列里放数据消费者从队列里取数据队列为空时消费者该怎么办暴力方案是空转轮询循环while (queue.empty())干等着。这样CPU会被白白烧掉尤其是队列长时间为空时消费者线程占着一个核心在那傻等。这时候要用条件变量std::condition_variable。它的作用是让线程在条件不满足时挂起等条件满足时再被唤醒。条件变量的标准用法是配合std::unique_lock和while循环。直接看一个经典的生产者消费者模型。#include iostream #include thread #include mutex #include condition_variable #include queue std::queueint dataQueue; std::mutex mtx; std::condition_variable cv; int productCount 0; void producer() { for (int i 1; i 10; i) { { std::lock_guardstd::mutex lock(mtx); dataQueue.push(i); std::cout 生产了 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 !dataQueue.empty(); }); int value dataQueue.front(); dataQueue.pop(); std::cout 消费了 value std::endl; if (productCount 10) { break; } } } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; }这里面有几个关键点。cv.wait(lock, predicate)的第二个参数是一个谓词用于检查条件是否真的满足。这个写法很重要因为条件变量存在虚假唤醒。系统可能在没有notify的情况下唤醒线程如果在wait返回后直接访问队列很可能队列还是空的。所以wait一定要配合while循环或谓词重查条件不要用裸的if去判断。唤醒时有notify_one()和notify_all()两种。一个负责唤醒一个等待线程一个负责唤醒所有。C11里这两者的用法在面试里被问到的频率非常高尤其会问“为什么wait要用while而不是if”原因就是虚假唤醒这点一定要能脱口而出。4. 无锁方案与原子变量把性能拉满4.1 std::atomic轻量级的“锁”互斥锁能解决并发问题但它有个短板性能开销。每次加锁解锁都涉及系统调用级别的同步原语即使没有竞争也有成本。如果只是对单个整数做自增、自减这种简单操作用锁属于杀鸡用牛刀。std::atomic就是干这个的。它能让单个变量实现原子操作即“要么一次做完要么一次都不做”中间不会被其他线程插一脚。#include iostream #include thread #include atomic std::atomicint counter{0}; void increment() { for (int i 0; i 100000; i) { counter.fetch_add(1); // 原子自增 } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout counter.load() std::endl; // 稳定输出200000 return 0; }fetch_add是原子地执行“加1并返回旧值”底层对应CPU的lock xadd指令效率比锁高得多。load()是原子地读取当前值store()是原子地写值。但要注意std::atomic不是万能的。它只保证单个操作原子如果你需要对多个变量同时做修改或者有一组操作必须整体不可分割那还是要用锁。原子变量适合的是“计数器、标志位、指针的简单读写”这类场景。4.2 CAS与ABA问题std::atomic在无锁编程里最常用的两个方法是compare_exchange_weak和compare_exchange_strong也就是常说的CASCompare-And-Swap。它的逻辑是如果当前值等于期望值就把它改成目标值并返回真否则不修改返回假。最典型的应用就是“无锁栈”的实现。入栈时不断尝试把头指针从old_head换成new_node如果期间有其他线程改了头指针就重新读继续尝试。std::atomicNode* head; void push(Node* new_node) { Node* old_head head.load(); do { new_node-next old_head; } while (!head.compare_exchange_weak(old_head, new_node)); }这段代码的核心逻辑是先把当前头指针读出来把新节点指向它然后尝试把头指针换成新节点。如果在确认期间头指针被别人改过了old_head会被刷新成最新值循环重来。这场面其实就是ABA问题的温床。设想一下线程A读了头指针为X准备把新节点插到X前面。此时线程B把头指针从X改成Y再改回X然后线程A的CAS发现头指针还是X以为没人动过于是成功了。但在这期间X指向的节点可能已经被释放或者改变了内容A以为基于的是“新鲜”的X实际却是“过期”的X。我在项目里处理ABA问题一般用两种思路。一是对节点加上版本号或标记位CAS时同时比较“指针标记”让每次修改都留下痕迹。二是使用像hazard pointer或epoch-based reclamation这样的内存回收策略延迟释放可能被其他线程引用的节点。面试时能把ABA问题讲清楚并且能答出“加标记版本号可以解决”这个方向基本就能过关。4.3 memory_order的内存序到底是什么std::atomic还有一个让新手很头疼的东西memory_order也就是内存序。先把话题放远一点。现代CPU为了性能执行指令时可能会乱序编译器在优化时也可能调整代码顺序。在多线程场景下这种乱序可能导致线程A先写了数据再设置标志位而线程B看到标志位时却不保证能看到数据已经写完。为了让跨线程的“先后关系”变得可靠需要内存序来约束。C11提供了几种常用的内存序内存序含义适用场景memory_order_relaxed只保证原子性不保证顺序计数器、统计值不涉及同步逻辑memory_order_acquire防止后面的读写跑到它前面读锁、读取标志位时用memory_order_release防止前面的读写跑到它后面写锁、发布数据时用memory_order_seq_cst全局顺序一致默认值默认选择性能够用可以和生活类比release像是“把门锁上”表示门里面的东西都已经整理好了再对外通知acquire像是“把门打开”开了门才能保证看到屋里全部的东西。如果只是发一个数字给计数器用不需要这种“开门关门”的约束用relaxed就行。作为一个务实建议刚开始接触不需要过度优化直接用默认的memory_order_seq_cst它语义最强正确性最好。等用久了性能测试发现有瓶颈了再反过来研究内存序优化。一上来就玩relaxed/release/acquire很可能写出难以排查的诡异bug。5. 用async/future简化一次性任务5.1 std::async一句代码开线程很多时候我们需要的并不是一个长期运行的线程而是“丢一个任务出去让它并行跑后面再收结果”。这种情况用std::async比手动std::thread要安全省事得多。#include iostream #include future int compute(int x) { return x * x; } int main() { std::futureint result std::async(std::launch::async, compute, 5); std::cout 计算中... std::endl; std::cout 结果是: result.get() std::endl; return 0; }std::async会自动在合适的时候创建线程来执行任务并且通过std::future把结果带回来。调用get()时会阻塞直到结果可用。这个设计比手动std::thread好在哪里好处是不用自己管理join和detach也不用自己定义返回值传递方式。线程函数里就算抛出了异常异常也会被存进future调用get()时再原样抛出不容易造成悬空和泄漏。5.2 promise线程主动给主线程传结果std::async适合“设置好任务后等结果回来”的场景。还有一种情况是主线程不知道子线程什么时候出结果但希望子线程完成后主动把数据送回来。这时候可以用std::promise配std::future。#include iostream #include thread #include future void setValue(std::promiseint prom) { std::this_thread::sleep_for(std::chrono::seconds(1)); prom.set_value(42); // 子线程“承诺”给42 } int main() { std::promiseint prom; std::futureint fut prom.get_future(); std::thread t(setValue, std::move(prom)); t.detach(); std::cout 等待子线程传结果... std::endl; std::cout 得到结果: fut.get() std::endl; return 0; }promise和future是一对绑定关系。子线程持有promise主线程持有future。子线程调用set_value时主线程的future::get()就会解除阻塞并拿到值。这个机制在异步回调、任务队列里很好用可以安全地跨线程传递结果。需要注意一个promise只能调用一次set_value重复设置会抛异常。如果需要多次传结果那不应该用promise/future而是考虑用回调或者安全队列。5.3 launch模式的取舍std::async有一个容易忽略的参数launch policy。std::launch::async明确要求在新线程里运行。std::launch::deferred不创建线程到调用get()或wait()时才在当前线程里执行说白了就是“懒加载”。默认参数是std::launch::async | std::launch::deferred交给系统选择。我之前就踩过一个坑默认策略下某些编译器在资源紧张时可能会选择deferred导致我以为程序在并行实际却是在主线程同步执行性能完全没上来。所以对性能有明确要求的场景我会显式传std::launch::async不留给系统决定的空间。6. 实战排查与踩坑记录6.1 数据竞争怎么找上工具别靠肉眼多线程bug的麻烦在于写得再仔细也不能保证没数据竞争。我曾经调试过一个线上偶发崩溃查了两天没结果最后用ThreadSanitizerTSan一把梭直接定位到了两处隐藏的数据竞争。TSan是编译器的内存检测工具用法简单加-fsanitizethread重新编译然后跑程序它就会在出现数据竞争时打印详细报告。g -fsanitizethread -g -O1 test.cpp -o test ./test它会报告哪些线程、哪两行代码、同时访问了哪个变量。注意这个工具必须在编译和链接时都加-fsanitizethread而且不建议和-O2等高级优化混用会误报或漏报。日常开发我一般会在测试环境跑一版带TSan的程序专门用来趟一遍多线程问题再发布正式版。6.2 死锁现场怎么查gdb看线程栈程序突然卡死不退出第一反应不应该是“太难了重写”而是挂上gdb看所有线程卡在哪。gdb -p 进程ID (gdb) thread apply all bt这条命令会打印当前进程所有线程的调用栈。如果看到两个线程各自停在lock()等待状态而且它们持有的锁正好互相需要那基本可以认定是死锁。我处理过的死锁案例里最常见的两个原因一是加锁顺序不一致二是lock_guard锁的范围跨了函数调用持锁期间干了太多事情。排查到具体位置之后优先调整锁粒度让持锁时间最短再统一加锁顺序十有八九能解决。6.3 伪共享False Sharing性能刺客再讲一个平时不容易发现的性能大坑——伪共享。它源于CPU缓存的设计。CPU从内存读数据时按“缓存行”读取一个缓存行通常是64字节。如果两个线程各自修改的变量恰好落在同一个缓存行里即使它们操作的是完全不同的变量CPU也会强制它们互相竞争缓存的所有权导致性能断崖式下跌。我举一个类似的情况两个线程分别对两个印在一个纸板上同一缓存行的数字1虽然两个线程各拿一支笔但如果纸板只有一个且不允许复制两人就得互相等对方写完再抢效率极低。解决伪共享的办法很直接把需要独立访问的变量填充到一个缓存行以上让它们占不同的缓存行。用C代码可以这样struct alignas(64) AlignedData { int value; };指定64字节对齐后AlignedData就会单独占一条缓存行两个线程各改各的value不会互相干扰。之前我优化一个报文处理模块时不改逻辑只补齐对齐并发吞吐量提升了30%以上这种收益真的是“做对了就立竿见影”。6.4 多线程面试题速查既然标题里出现了“多线程面试题”这个热词我顺手把常被问到的几类问题列一下当作复习提纲线程与进程的区别是什么什么时候选线程什么时候选进程std::thread的join和detach有什么区别为什么detach要慎用数据竞争是什么怎么解决为什么volatile不能替代互斥锁什么是死锁死锁的四个必要条件是什么如何避免条件变量为什么要配合while循环使用什么是虚假唤醒std::atomic和std::mutex怎么选ABA问题是什么什么是伪共享如何避免std::async和手动创建std::thread的优缺点把这些点在脑子里过一遍并且能结合实际代码讲出自己的体会比单纯背概念强得多。7. 把多线程用到工程里7.1 一个简单的线程池雏形手动开线程有个痛点频繁创建销毁线程代价高而且线程数量不受控。工程上更常用的做法是维护一个线程池任务丢进去线程池里的工作线程自己去取任务执行。下面是一个非常精简的线程池实现核心还是条件变量那套#include thread #include queue #include vector #include mutex #include condition_variable #include functional class ThreadPool { public: explicit ThreadPool(size_t size) : stop(false) { for (size_t i 0; i size; i) { workers.emplace_back([this]() { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(queueMtx); cv.wait(lock, [this]() { return stop || !tasks.empty(); }); if (stop tasks.empty()) return; task std::move(tasks.front()); tasks.pop(); } task(); } }); } } template class F void enqueue(F f) { { std::lock_guardstd::mutex lock(queueMtx); tasks.emplace(std::forwardF(f)); } cv.notify_one(); } ~ThreadPool() { { std::lock_guardstd::mutex lock(queueMtx); stop true; } cv.notify_all(); for (std::thread worker : workers) { worker.join(); } } private: std::vectorstd::thread workers; std::queuestd::functionvoid() tasks; std::mutex queueMtx; std::condition_variable cv; bool stop; };这个版式简单但够用。析构时设置stop标记并唤醒所有线程每个工作线程看到stop tasks.empty()就退出。把这个类的思路吃透再看各种工业级线程池实现理解会顺畅很多。7.2 我给自己定的几条多线程铁律踩过各种坑之后我现在写多线程代码基本遵循这几条算是我自己的“红线”一是能不用裸的std::mutex::lock就不用一律用RAII封装。手写lock/unlock一旦中间抛异常就是给别人留坑。二是锁的范围越小越好。把锁当成一次公共厕所的门需要进去才锁上出来马上解锁。拿锁之后打死不在里面做耗时操作比如网络请求、文件写入。三是避免直接用volatile解决并发问题。volatile只能阻止编译器优化掉变量的读取不能保证原子性也不能保证内存序它不是并发工具。四是先保证正确再追求性能。多线程代码一旦并发bug上线排查成本极高。先用最简单的互斥锁把逻辑调通跑稳再用原子变量、无锁结构去优化瓶颈而不是一开始就上高深技巧。五是普通项目里优先考虑std::async。很多场景只是“等一个结果”用std::async就够不必亲手管理线程生命周期。最后再分享一个我项目里常干的“土办法”凡是修改了共享数据的代码我都会在改动后立刻写一段多线程压测的小程序跑个几百万次自增或入队出队配合ASan/TSan检查一遍。这个习惯看起来笨但真能避免很多“线上偶发”变“必然事故”的情况。C多线程本身不难难的是它对“确定性”的要求极高——一旦你默认它正确它就会在你最意想不到的时候还你一个惊喜。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进