ARTICLE DETAIL

资讯详情

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

C++11多线程std::thread从创建到join/detach的完整指南

C++11多线程std::thread从创建到join/detach的完整指南 做C服务端开发和客户端基础库的同学应该都有过被std::thread这套多线程API绕晕的经历。线程启动、结束、join、detach看似每个接口都不难但组合在一起却藏着不少深坑忘记回收线程资源导致程序直接异常终止、detach之后线程还在访问已销毁的局部变量、传引用参数时编译报错一脸懵……这些问题在面试里被问烂在实际工程里更是高频踩雷。这篇笔记是我整理C11并发与多线程系列的第2篇重点讲线程从创建到结束的完整路径、创建线程的几种主流写法、join和detach的内在机制以及我实际排查过程中总结出来的避坑细节。适合刚接触C11多线程、或者写了几天std::thread但经常莫名崩溃的人。1. 线程启动与结束的基本姿势1.1 线程生命周期从构造到资源回收C11里std::thread的设计思路很直接——你只需要给它一个可调用对象它就会立刻在这个新线程里去执行。很多人第一次写的时候会下意识找start之类的接口其实不用线程在std::thread对象构造完成的那一瞬间就已经开始跑了。比如下面这段最基础的代码#include iostream #include thread void worker() { std::cout child thread running... std::endl; } int main() { std::thread t(worker); t.join(); std::cout main thread done. std::endl; return 0; }这里std::thread t(worker)完成构造后worker函数立刻在子线程中执行。主线程随后执行t.join()这个调用会一直阻塞直到worker跑完才继续往下走然后回收线程相关的资源。我经常把线程的生命周期拆成四个阶段来理解创建构造线程对象、启动自动进入可运行状态、执行运行线程函数体、资源回收通过join或detach处理。其中三个环节都很容易出问题尤其是最后一步。标准规定std::thread对象析构时如果还处于joinable状态就会直接调用std::terminate强制终止整个程序连清理静态对象的机会都不给。这个设计完全是故意的它就是为了逼你明确选择线程的收尾方式避免一个线程对象析构了、底层线程却还在后台跑最终访问到一块已经被释放的内存。C委员会宁可程序立刻崩溃也不让你在未定义行为里拖泥带水。1.2 一个关键原则主线程与子线程的时序不确定很多新手写多线程代码时会天然假设主线程创建了子线程子线程就一定会尽快执行或者主线程一定会等一等子线程。但实际上线程创建成功之后子线程什么时候真正开始跑、跑到哪一步完全由系统调度器决定两个线程之间没有任何时间上的先后保证。举个例子你可能会在主线程里给一个变量赋值然后创建子线程去读取这个变量你觉得我已经先赋值了线程才创建的它读到的肯定是新值。这个思路在单线程里成立但在多线程里完全站不住脚。CPU缓存、指令重排、操作系统的调度延迟都可能让子线程读到一个你意想不到的状态。所以从创建线程那一刻起你就必须默认不要再依赖执行顺序来保证数据安全该加锁的地方要加锁该用原子变量的地方用原子变量这块我会在这个系列后面专门写一篇。我的习惯是代码里凡是涉及线程交互都把时序不确定当成前提来设计。这样写出来的代码虽然有时候看起来保守一些但在高并发和复杂场景下反而更稳。2. 创建线程的多种方法2.1 用普通函数创建线程最直观的入口最常见的用法就是传入一个普通函数名函数的参数直接跟在函数名后面作为std::thread构造函数的参数。例如void print_msg(const std::string msg, int times) { for (int i 0; i times; i) { std::cout msg - i std::endl; } } std::thread t(print_msg, hello thread, 3); t.join();这段代码会以hello thread和3作为参数在新线程里调用print_msg。需要注意的是参数默认是拷贝传递的。这里的msg是一个const std::string但std::thread内部会把参数decay后拷贝一份再传给线程函数所以即使传入的是一个临时字符串线程内部也能安全使用不会访问到主线程里那个已经失效的引用。这算是个好消息。不过用普通函数创建线程有几个点容易忽略。第一个是当线程函数的参数需要非常量左值引用时直接传递会编译失败原因就是std::thread内部做的是拷贝传递非常量左值引用无法跟一个右值临时对象绑定后面我会专门讲std::ref。第二个是如果线程函数是重载函数直接传函数名可能导致编译器不知道选哪个重载版本这个时候需要手动强转函数指针类型才能让std::thread的模板参数推导正确。2.2 用函数对象仿函数创建线程小心最烦人的解析函数对象重载了operator()所以也可以直接作为线程入口。这种方法在需要保持一定内部状态的场景下很实用或者在面向对象风格里不想把逻辑暴露成普通函数时也经常这样写。class Task { public: void operator()(int val) const { std::cout Task run with val std::endl; } }; std::thread t(Task(), 42); t.join();这段代码初看没问题但如果你稍微改一下写法就会踩到C里著名的most vexing parse陷阱。比如你写std::thread t(Task());编译器会把它解释成一个名为t、参数是一个函数指针的函数声明而不是一个线程对象。你调用t.join()的时候编译器直接报错说t不是一个类类型。我在给别人做code review时不只一次看到这种坑尤其是刚从Java转过来写C的同学特别容易中招。解决办法很简单要么用多一对括号std::thread t((Task()));要么直接用花括号初始化std::thread t{ Task() };后者更推荐。另外一个容易被忽视的点是仿函数对象被传入std::thread以后线程内部保存的是这个对象的拷贝。如果你修改的是原始对象线程里看到的并不会同步变化。需要共享状态的话要么改成传引用要么在仿函数内部自己管理状态。2.3 用lambda表达式创建线程现在我的主力写法如果线程逻辑不算特别复杂lambda确实是最舒服的写法。代码紧凑捕获列表还能直接控制共享变量是值捕获还是引用捕获语义非常清晰。int counter 0; std::thread t([counter]() { for (int i 0; i 1000; i) { counter; } }); t.join(); std::cout counter std::endl;这里捕获列表[counter]表示按引用捕获counterlambda在线程里修改的就是主线程里的那个变量。不过必须提醒一下这个写法存在数据竞争多个线程同时加减counter时结果不确定我这里只是演示捕获方式真正工程里要加锁或者用std::atomic。不要看完这段直接抄走就在生产环境跑。用lambda创建线程最大的坑藏在生命周期管理上。如果你写成[]按引用捕获全部外部变量然后执行detach把线程丢到后台主线程函数一旦返回这些引用就全部悬空。子线程还傻乎乎地继续访问那块栈内存程序轻则输出乱码重则段错误。我自己习惯是最小化捕获范围能用值捕获就不用引用捕获能只捕捉几个变量就不写[]那个偷懒写法宁可多写几个名字也不要留下一个隐藏的野指针。2.4 用类成员函数和静态成员函数创建线程需要额外传对象地址用类成员函数创建线程时语法上要多传一个参数——对象地址因为成员函数在底层是需要this指针才能调用的。典型写法class Counter { private: int value 0; public: void increment() { value; } int get() const { return value; } }; Counter c; std::thread t(Counter::increment, c); t.join(); std::cout c.get() std::endl;这里c被传进去作为成员函数的this指针。如果不传编译器会报错因为成员函数不能脱离对象单独调用。这里最大的坑在于如果这个Counter对象在栈上而线程采用detach方式分离了主线程先返回导致对象析构子线程再去调用increment就会访问一块已经析构的对象内存这是典型的未定义行为崩溃没有规律可循有时能跑有时崩非常恶心。所以只要线程可能比主线程活得更久对象的生存期管理就必须提前规划好比如把对象分配到堆上用shared_ptr托管线程内部自己持有一份引用计数。静态成员函数则不依赖具体对象直接传函数名和参数即可这一点跟普通函数一致std::thread t(ConfigLoader::load, config.json); t.join();2.5 用std::function和可调用对象包装线程函数除了上面几种直接创建线程的方法实际项目中还经常配合std::function把线程入口函数包装起来这种写法在写线程池的时候尤其常见。比如你希望把任务的类型擦除掉统一塞进一个队列让工作线程从队列里取出来执行。因为std::function可以保存任意可调用对象线程入口就变得非常灵活。std::functionvoid() task []() { do_something(); }; std::thread t(std::move(task)); t.join();这里把task移动给std::thread而不是直接传task是为了避免不必要的拷贝。std::thread内部本身就支持移动语义std::move之后原来的task就空了由线程持有这份可调用对象。线程池实现里经常可以看到vector std::thread 配合emplace_back直接在线程容器里构造线程对象因为std::thread不可拷贝只能移动或者就地构造。2.6 几种创建方式的选型心得绕了一圈我给出一个我个人的选型倾向如果线程函数逻辑比较长且以后可能复用优先用普通函数或者静态成员函数如果逻辑短小且只在这个场景使用直接用lambda如果函数需要访问对象内部状态用成员函数并且仔细设计好对象生命周期如果要在任务队列、线程池这种场景里做任务抽象用std::function移动语义。选型的时候有一个容易被忽略的判断标准——错误信息的可读性。lambda写得太长会让堆栈信息变得很乱调试的时候不太友好普通函数名反而一目了然。所以我更建议把 lambda 保持简洁里面只做参数转化和调用业务函数真正的重逻辑还是放进有名字的函数里。3. join与detach从原理到实战3.1 join的本质阻塞等待与资源回收join是std::thread最常用的收尾方式。调用t.join()后主线程会在这里阻塞直到子线程执行完然后线程资源被回收之后t对象就处于非joinable状态。这个阻塞等待非常关键它其实帮我们解决了一类很典型的问题主线程需要子线程的计算结果或者主线程必须确保子线程完全结束以后才能安全退出。在上面那个Counter例子里如果主线程不等increment执行完就去读c.get()结果就可能是0或者别的中间值join是这里最简单的同步手段。但join也有一个容易让人忽略的代价如果子线程运行时间很长甚至因为某些原因永远不结束主线程就会一直卡在这里程序看起来就像假死了。我之前排查过一个性能问题就是一个工作线程在等待网络响应主线程调了join导致整个界面卡住。那个场景正确的做法是超时控制或者条件变量而不是裸用join。另外join不能被重复调用同一个线程对象连续调用两次join会抛出std::system_error异常。所以在不确定线程是否已经回收时最好先调用joinable()判断一下。3.2 detach的本质分离并托管detach()的意思是分离调用之后这个std::thread对象就和底层线程脱钩了线程在后台继续运行而线程资源的回收由C运行时库来接管。分离出去的线程通常被叫做守护线程它不依赖主线程的存活状态。这样说起来很简单但它带来的生命周期问题也是整个多线程里最危险的。void background_work() { std::this_thread::sleep_for(std::chrono::seconds(2)); std::cout background work done std::endl; } int main() { std::thread t(background_work); t.detach(); // 主线程不等待直接往下执行 }上面这段代码里如果main函数执行完直接结束进程后台线程可能还没跑完就已经被终止了。控制台能不能看到输出完全看运气。这是因为进程一旦退出所有线程都会被系统拆除。detach只意味着线程对象不再管理这个线程但不可能让一个已经被销毁的进程继续运行线程。detach真正适合的场景是那些一次性的后台任务而且这个任务不能依赖主线程的任何局部变量也不能依赖主线程栈上的对象否则主线程一返回这些变量全部失效。比如一个异步写日志的线程、清理临时文件的线程、发送心跳的线程在执行前保证把需要的参数都拷贝到自己的栈上才能放心detach。我在实际项目里对detach的态度是能用进程生命周期内的全局资源就用绝不让后台线程访问某个函数的局部变量能用线程池就不用裸detach因为线程池内部对任务和线程生命周期有更可靠的管理。3.3 joinable()判断线程能否被回收joinable()用来判断线程对象是否关联了一个可被回收的活动线程。默认构造的std::thread对象不可join调用join或detach之后不可join一个线程对象被移动后源对象也不可join。因为只有处于joinable状态的线程对象析构时才会触发terminate所以joinable()是防止程序意外崩溃的好工具。典型用法是在收尾时做一次判断if (t.joinable()) { t.join(); }这个防御性写法在出现异常分支时特别有用。如果线程创建后、调用join前有代码抛出了异常函数栈回退时会析构std::thread对象此时如果线程还joinable就直接terminate了。处理办法是用RAII包装线程对象析构函数里判断joinable并自动join或者用try-catch捕获所有异常确保在线程对象析构前完成收尾。C20里有std::jthread可以自动做到这一点但在C11/C17项目里还是得自己注意。3.4 join与detach的使用边界对比维度joindetach主线程行为阻塞等待子线程结束不等待继续执行子线程资源join回收后由调用方管理交给运行时库管理子线程能否访问主线程局部变量可以只要主线程还没返回不可以主线程返回即悬空适用场景需要同步结果、任务生命周期明确一次性后台任务、无外部依赖重复调用再次调用会抛std::system_error再次调用会抛std::system_error析构安全性join后安全析构detach后安全析构这张表我建议收藏写代码之前先对着看一眼心里就有底了。4. 参数传递、线程ID与引用陷阱4.1 值传递与隐式拷贝std::thread的默认行为std::thread构造函数对外表现是忽略引用修饰统一按值拷贝。这意味着即使你写了一个接收std::string的函数如果直接把一个std::string变量传进去实际上线程里操作的也只是一份拷贝你在线程内部修改变量主线程里的原变量纹丝不动。这种设计有它的道理如果直接把引用传进去调用者稍不注意生命周期线程就可能访问到悬空引用。所以C标准库宁可默认按值拷贝把一个安全基础先打牢。值传递带来的次生问题也很常见。比如线程函数接受std::string但你传进去的是一个const char*。表面看起来没问题因为const char可以隐式转换成std::string。但关键点在于这个隐式转换发生在什么时候答案是发生在子线程的上下文中而不是在std::thread构造时。如果你紧接着在主线程里修改或释放了这块const char指向的内存而子线程还没来得及执行转换它就会去读一块已经被释放的内存这属于典型的未定义行为。我在实际项目里踩过这个坑所以现在的规则很明确调用std::thread之前自己先把参数转换到位比如显式构造一个std::string再传进去从源头杜绝隐式转换的时序问题。4.2 std::ref、std::cref与std::move的正确姿势我的经验是把std::ref和std::move当作std::thread传参的左右护法。左护法是std::ref用于强制按引用传递右护法是std::move用于把大对象或者独占资源高效地转移到线程里。void modify(int val) { val 10; } int number 0; std::thread t(modify, std::ref(number)); t.join(); // 此时 number 10没有std::ref的话上面这段代码根本编译不过因为modify需要的int不能绑定到std::thread内部创建的临时拷贝上。用std::ref包装后线程内引用的是主线程的变量修改才能生效。同理如果只想读引用用std::cref。std::move则适合那些拷贝代价特别大的对象比如包含大量数据的容器或者像std::unique_ptr这样的独占所有权对象。用一个简单的例子std::unique_ptrData ptr std::make_uniqueData(); std::thread t(process_data, std::move(ptr)); t.join();这里把unique_ptr的所有权转移给线程线程内部持有这份数据的唯一使用权避免了拷贝开销也避免了多线程共享所有权带来的复杂度。用std::thread传move-only参数时最好配合显式的参数类型声明因为模板推导有时会跟你较真。4.3 成员函数传this与对象生命周期用类成员函数创建线程时对象指针的传递本质上也是引用的一种只不过它用的是原始指针。这种写法非常容易产生悬空指针对象在栈上创建线程detach后继续执行成员函数而主线程已经离开作用域栈对象被析构。这时候子线程里的this已经是一个悬空指针接下来所有成员访问都是未定义行为。处理方式有几种。如果任务短暂且明确需要等待结果用join即可。如果需要长期驻留的后台任务最优解是给对象提供堆分配和shared_ptr托管线程内部也持有一个shared_ptr这样就算主线程不持有了对象也会等线程用完后才安全析构。还有一种偏门的写法是把所需数据先拷贝出来而不是传递对象指针适合那些后台任务只需要对象一小部分状态的场景。4.4 线程IDstd::thread::id与性能标记std::thread提供了get_id()而当前线程可以用std::this_thread::get_id()获取自己的ID。这个ID在标准库里是一个不透明的std::thread::id类型可以比较、可以输出、可以当作map的key。它跟操作系统的原生线程ID不是一个东西如果你要跟系统级工具对照还得用std::thread::native_handle()拿底层句柄。线程ID在我的日常开发里主要有两个用途。一个是日志系统里标记当前日志是哪个线程打出来的方便排查并发问题另一个是配合thread_local做线程专属缓存时的标识。比如我用一个unordered_mapstd::thread::id, Connection去管理每个线程持有的数据库连接线程ID就是最天然的key。不过要注意线程结束后它的ID可能被复用所以如果map里还用旧ID存连接需要在线程收尾时清理不能只依赖ID做长期缓存。5. 常见问题与排查技巧实录5.1 主线程先结束子线程还在跑崩溃高发区这是我见过最多的问题视频里、网帖里、实际生产环境里都反复出现。核心代码长这样void run() { std::string local hello; std::thread t([local]() { std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout local std::endl; }); t.detach(); } // local 已经析构子线程还在访问未定义行为症状极其随机有时候程序正常有时候输出乱码有时候直接段错误。原因就是这个lambda按引用捕获了局部变量local然后线程被detach到后台可函数run已经返回local在栈上被析构子线程再去读这块栈内存就是访问已释放的内存。我的排查经验是这种问题光靠看代码不一定能立刻发现因为崩溃不一定出现在线程创建的位置而是隔了很远、崩在一个毫无关联的函数里。用ASanAddressSanitizer跑一遍是最快的定位手段它会明确报出use-after-scope。顺着报告里给出的创建栈和访问栈很快就能找到那行detach或者那个引用捕获。5.2 忘记join或detachterminate是必然的std::thread对象在joinable状态下析构会调用std::terminate程序立刻终止。这个立刻终止不给你任何清理的机会也不会抛出可捕获的异常。触发场景主要有三种提前return漏掉了join、函数中间抛出异常导致代码跳过了join、修改代码时把join注释掉了忘记恢复。解决思路务必要从记性上升到机制。最稳的做法是写一个RAII包装类析构函数里判断joinable并自动join或detach。C11时代很多公司内部都有自己的ThreadGuard工具类C20推出std::jthread也是这个思路。如果你还在用C11/14/17我建议尽快做一个简单版本别每次都手动调用join真的容易漏。class ThreadGuard { public: explicit ThreadGuard(std::thread t) : thread_(t) {} ~ThreadGuard() { if (thread_.joinable()) { thread_.join(); } } ThreadGuard(const ThreadGuard) delete; ThreadGuard operator(const ThreadGuard) delete; private: std::thread thread_; };用起来只需保证ThreadGuard对象的生命周期覆盖整个线程生命周期剩下的交给析构函数兜底。5.3 参数隐式转换导致的越界访问我们前面提过const char到std::string的隐式转换时机问题这里再给一个更具体的崩溃场景。你在线程构造函数中传了一个const char线程函数的参数是const std::string编译器允许隐式转换但转换发生在子线程真正开始执行时。如果主线程在子线程开始之前就修改了那个字符串的内容子线程读到的就是不稳定的数据如果主线程直接释放了这块内存子线程大概率崩溃。正确的做法是void print(const std::string str); const char* raw data; std::string safe(raw); // 在线程启动前完成构造 std::thread t(print, safe);在线程创建之前把数据完全拷贝好线程内部用得干净又安全。规则只有一条线程函数需要什么样的参数你就在调用线程里准备好什么类型的对象不要让线程去承担隐式转换的风险。5.4 排查多线程崩溃的实用工具多线程问题最让人头疼的是不确定性。同样一份代码在你机器上跑得好好的上一线就偶发崩溃。我的排查流程基本是这样先复现用ASan/UBSan跑几轮让问题从偶发变必现然后用GDB的thread命令查看所有线程的栈信息重点看崩溃线程上下文最后如果跟数据竞争有关用TSanThreadSanitizer能直接标出竞争发生的位置。工具用途典型输出gdb info threads / thread apply all bt查看所有线程调用栈定位崩溃线程AddressSanitizer检测堆栈越界、use-after-freeuse-after-scopeThreadSanitizer检测数据竞争、死锁风险data racestd::this_thread::get_id日志标记线程来源排查日志错乱有一点要提醒TSan和ASan在某些平台上不能同时开具体配置看编译文档。但在本地调试阶段跑通这几个工具能帮你省下无数个加日志看log的夜晚。5.5 避免用裸线程做大而全的事最后分享一个项目层面的体会。标准库提供了强大的std::thread但在真实业务里我不建议频繁地裸创建线程去执行短期任务。线程的创建、调度、销毁都有成本如果任务是高频、短小的用线程池会更划算。我在做底层通信模块时经常把std::thread包装成线程池的工作线程让线程循环消费任务队列而不是每个请求都创建一个新线程。这样线程数量可控生命周期可控资源开销也低很多。如果你现在正处在刚学会std::thread的兴奋期看到什么都想开个线程我特别能理解这种心情。但工程代码不是炫技稳定性优先。把线程的创建和结束做成有规律的、可预测的方式比什么都重要。写到这里其实还有一个场景没展开就是多个线程同时修改共享数据时如何加锁、如何避免数据竞争。那是这个系列里很大的一块内容我会单独开一篇讲mutex和锁的用法。这篇重点是把线程本身的启动、结束、创建和join/detach的边界理顺。我自己的体会是很多并发问题都不是锁没写好而是从线程生命周期这一层就已经埋下了雷。所以建议你在写下一段并发代码之前先把这个生命周期管理练扎实。最后再送一个小技巧所有std::thread对象创建后第一时间决定它是join还是detach并把这个决定写在注释里时间久了你会感谢当时的自己。
返回列表