std::thread是 C++11 给并发编程开的第一道门,也是最容易在第一个小时就撞墙的一道门。撞的方式还很吓人:不是编译错误,不是抛异常,而是整个进程被std::terminate直接干掉,运行库只留下几行terminate called without an active exception然后 abort。这篇把线程的生命周期、参数传递的拷贝陷阱、detach的悬垂风险一次讲透,最后给出一个能安全替代裸std::thread的 RAII 包装。
1. 引子:为什么我的程序「什么都没输出就死了」
一个非常典型的最小例子:
#include<cstdio>#include<thread>voiddo_work(){std::printf("子线程跑完了\n");}voidwill_terminate(){// 反例,不要这么写(违反 Core Guidelines CP.20):析构时仍 joinable()// 运行库会直接调用 std::terminate() 终止整个进程,不是抛异常,catch 不住std::thread t{do_work};// 既没 t.join(),也没 t.detach() —— 一离开这个作用域就炸}这段没有main(),是纯展示片段(真跑起来会 abort 掉整个进程,不适合放进可运行示例)。它的关键点是:std::thread的析构函数在对象仍joinable()时会调用std::terminate(),而不是帮你默默 join 或默默 detach。为什么标准要定这么「暴烈」的规则?因为「该 join 还是该 detach」这个决策无法由类自己猜出来 —— 猜错了要么死锁、要么产生幽灵线程。与其悄悄做错,不如当场崩掉让你发现问题。
官方文档:std::thread::~thread — cppreference(明确写了「若
joinable()为 true,调用std::terminate()」)
2. 线程的生命周期
std::thread对象和「操作系统线程」是两件事。对象只有两个状态:关联着一个执行体(joinable() == true)和不关联(joinable() == false)。
std::thread t{fn, args...} │ ▼ ┌──────────────────────────────┐ │ joinable() == true │ 关联着一个执行体 └──────────────┬───────────────┘ │ ┌──────────────┴───────────────┐ │ │ t.join() t.detach() │ │ ▼ ▼ 等待该线程结束 解除关联,线程继续在后台跑 回收其资源(栈、句柄) 变成「孤儿线程」,退出时由运行库收尾 │ │ └──────────────┬───────────────┘ ▼ ┌──────────────────────────────┐ │ joinable() == false │ ← 只有这时才能安全析构 └──────────────┬───────────────┘ │ 若此时直接析构(joinable 仍为 true) ▼ ┌──────────────────────────────┐ │ std::terminate() │ 整个进程被终止,无法捕获 └──────────────────────────────┘join()与detach()的共同点是「解除关联」,区别在于join()会等并且回收资源,detach()什么都不等。两者都只能对joinable()的对象调用一次;重复调用抛std::system_error。
最小的正确用法:
// thread_basic.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread thread_basic.cpp -o thread_basic#include<chrono>#include<cstdio>#include<thread>intmain(){// 能拿到几个硬件线程就返回几;拿不到时返回 0,所以别写死用它开线程池std::printf("hardware_concurrency() = %u\n",std::thread::hardware_concurrency());// 子线程里不做任何输出,避免和主线程的输出交错(顺序不稳定)std::thread t{[]{std::this_thread::sleep_for(std::chrono::milliseconds{10});}};std::printf("构造之后: joinable() = %s\n",t.joinable()?"true":"false");t.join();std::printf("join 之后: joinable() = %s\n",t.joinable()?"true":"false");}hardware_concurrency() = ~~ 构造之后: joinable() = true join 之后: joinable() = falsehardware_concurrency()那行用~~占位:这个值随机器变化(常见 4/8/16),在容器里还可能是 0 或宿主机的核数。
官方文档:std::thread::hardware_concurrency、std::thread::joinable
只能移动,不能拷贝。线程对象代表一份独占的系统资源,拷贝语义无法定义,所以std::thread的拷贝构造/拷贝赋值被删除了:
#include<thread>#include<utility>voidnoop(){}voidmove_only(){std::thread a{noop};// std::thread b = a; // ✗ 编译错误:拷贝构造被删除std::thread b=std::move(a);// ✓ 移动之后,a 不再 joinable// a.joinable() 现在是 false;责任转移到了 b 身上b.join();}这也是它为什么能放进std::vector(vector只要求可移动),但需要emplace_back或push_back(std::move(t))。
3. 传参的陷阱:std::thread 按值拷贝实参
这是第二个高频坑。std::thread会把每个实参按值复制(decay-copy)一份到自己的内部存储里,再把这些副本交给线程函数。这叫「按值 decay-copy」,意味着数组退化成指针、引用被去掉引用、函数名变成函数指针,而且对象会被拷一份。
// thread_args.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread thread_args.cpp -o thread_args#include<cstdio>#include<functional>#include<string>#include<thread>// 形参按值:线程里改的是自己那份副本voidappend_by_value(std::string s){s+="-copy";}// 形参是引用:只有配 std::ref 才能真的改到外面的对象voidappend_by_ref(std::string&s){s+="-ref";}intmain(){std::string msg="task";// ① 直接传变量名:std::thread 先拷一份进自己的存储,改的是副本std::thread t1{append_by_value,msg};t1.join();std::printf("① 按值形参 + 直接传 msg -> msg = %s\n",msg.c_str());// ② 形参要引用:必须用 std::ref 包一层,才是真的引用std::thread t2{append_by_ref,std::ref(msg)};t2.join();std::printf("② 引用形参 + std::ref(msg) -> msg = %s\n",msg.c_str());}① 按值形参 + 直接传 msg -> msg = task ② 引用形参 + std::ref(msg) -> msg = task-ref更反直觉的是:就算线程函数的形参是引用,直接传变量名也编译不过。因为std::thread内部是把自己的副本移动进去调用函数的,右值绑不到非 const 左值引用上:
#include<string>#include<thread>voidappend_by_ref(std::string&s){s+="-ref";}voiddirect_reference_fails(){std::string msg="task";// 反例,不要这么写:std::thread 先 decay-copy 实参、再 move 进函数调用,// 非 const 左值引用形参接不住右值 —— 编译期直接报错// std::thread t{append_by_ref, msg}; // ✗ 编译错误// 正确写法(同时也要保证 msg 活得比线程久):// std::thread t{append_by_ref, std::ref(msg)}; // ✓}官方文档:std::thread 构造函数(args 的 decay-copy 规则)、std::ref / std::cref
std::ref是把双刃剑。它让线程真的引用外部对象,但你同时得保证那个对象的生命周期长于线程。下面这个就是典型的悬垂:
#include<cstdio>#include<string>#include<thread>voidread_later(conststd::string&s){std::printf("%s\n",s.c_str());}voiddangling_reference(){std::string local="temporary";// 反例,不要这么写:detach 之后线程可能比 local 活得久,// 等它真去读 local 时,对象早已析构 —— 悬垂引用,未定义行为std::thread{[&local]{read_later(local);}}.detach();}悬垂引用的可怕之处在于它可能「看起来正常」:小字符串有 SSO,析构后栈上的字节往往还没被覆盖,于是你读到的是垃圾数据而不是崩溃。等到上线跑在另一种内存压力下才炸。
什么时候必须用std::ref:要在线程里改外部对象、且能证明该对象活得更久时。其他时候优先按值传,值语义最不容易出错。
4. detach 的危险:幽灵线程
detach()的语义是「这个线程从此与我无关」。听起来很轻松,实际上它引入三种很难查的问题:
| 问题 | 具体表现 |
|---|---|
| 悬垂访问 | 线程读写了已析构的局部对象 / 已释放的资源(上一节的例子) |
| 程序退出时被砍 | main返回后进程开始销毁静态对象,detached 线程可能还在跑,访问到已销毁的全局对象 |
| 无法等待、无法回传错误 | 没有join就没有同步点,线程里的异常也只能自己吞掉(否则直接 terminate) |
官方文档:std::thread::detach — cppreference(注意它最后那句警告:任何线程结束后,访问该线程生成的对象都是 UB)
实务建议:detach()几乎只在一个场景下合理 ——「这个线程是自给自足的守护任务,不碰调用方的任何东西,也不需要在退出前被等待」。其余场景都该用join(),或者干脆换任务队列 /std::async(见《std::async、future、promise》与《手写一个线程池》)。
5. RAII 包装:joining_thread
既然「忘写 join 就 terminate」,那正确做法不是靠记性,而是让类型系统替你保证。这正是 C++ Core Guidelines 的 CP.20(用 RAII 管理并发资源)和 CP.26(detach线程时要保证它活得够久)的立场。
标准库没提供这个类,我们按std::jthread(C++20)的思路手写一个:
// joining_thread.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread joining_thread.cpp -o joining_thread#include<cstdio>#include<thread>#include<utility>#include<vector>// RAII 包装:让「线程一定会被 join」成为类型保证,而不是靠人记classJoiningThread{std::thread t_;public:JoiningThread()noexcept=default;template<typenameF,typename...Args>explicitJoiningThread(F&&f,Args&&...args):t_{std::forward<F>(f),std::forward<Args>(args)...}{}~JoiningThread(){if(t_.joinable())t_.join();// 析构必 join:异常路径也不会漏}JoiningThread(constJoiningThread&)=delete;// 线程不可拷贝JoiningThread&operator=(constJoiningThread&)=delete;JoiningThread(JoiningThread&&other)noexcept:t_{std::move(other.t_)}{}// 只可移动JoiningThread&operator=(JoiningThread&&other)noexcept{if(this!=&other){if(t_.joinable())t_.join();// 先收掉自己的t_=std::move(other.t_);}return*this;}voidjoin(){if(t_.joinable())t_.join();}};intmain(){constexprintkThreads=4;constexprintkPerThread=1000;std::vector<longlong>partial(kThreads,0);// 每个线程写自己那一格,彼此不重叠 → 无数据竞争std::vector<JoiningThread>pool;pool.reserve(kThreads);// 预留,避免 realloc 期间挪动线程对象for(inti=0;i<kThreads;++i){pool.emplace_back([i,&partial]{longlongsum=0;for(intk=0;k<kPerThread;++k)sum+=static_cast<longlong>(i)*kPerThread+k+1;partial[i]=sum;});}// 其实不写这行也行 —— pool 析构时会自动 join。写出来只是为了让你看清同步点for(auto&jt:pool)jt.join();longlongtotal=0;for(longlongv:partial)total+=v;std::printf("各线程部分和: %lld %lld %lld %lld\n",partial[0],partial[1],partial[2],partial[3]);std::printf("总和 = %lld(期望 %d)\n",total,kThreads*kPerThread*(kThreads*kPerThread+1)/2);std::printf("池大小 = %zu\n",pool.size());}各线程部分和: 500500 1500500 2500500 3500500 总和 = 8002000(期望 8002000) 池大小 = 4这段有三个设计点值得单独说:
~JoiningThread里那个join()是关键。即使emplace_back之后某处抛了异常,pool析构时每个元素的对象析构函数仍然会被调用,线程全都被 join 掉 —— 这是裸std::thread做不到的异常安全。这和lock_guard保证解锁是同一个思路。- 每个线程只写
partial[i]这一格,各格内存互不重叠,因此不需要加锁。这是并发里最省钱的写法:能用「划分数据」解决的,就别用锁。如果多个线程写同一个变量,就必须上std::mutex(下一篇的主题)。 pool.reserve(kThreads)不是可有可无的。不预留的话,vector扩容时会把已有元素移动构造到新内存 —— 虽然我们实现了移动构造,逻辑正确,但预留能省掉 32 次线程对象搬运,也避免了「扩容过程中线程对象被移动」这种让人心里发毛的时刻。
官方文档:C++ Core Guidelines · CP.20(用 RAII 而不是裸 lock/unlock,同理适用于 join)、CP.26(detach 前先想清楚生命周期)、std::jthread(C++20 自带的 RAII 线程)
小提示:如果你能用C++20,标准库已经有
std::jthread了 —— 它析构时自动 join(还会通过std::stop_token请求线程停止),逻辑和我们上面这个JoiningThread基本一致,别再自己写。本文的示例保持C++17可编译。
6. 延伸阅读
- std::thread — cppreference:完整成员清单,构造函数那段把 decay-copy 规则写得很清楚
- std::thread::~thread:
std::terminate那条规则的原文出处 - std::thread::hardware_concurrency:注意返回值可能是 0 这个坑
- C++ Core Guidelines · 并发部分(CP 篇):所有并发规则的索引,建议整节过一遍
7. 一句话总结
std::thread的对象本身只是一张「线程句柄」,它的规则只有三条要记牢:析构前必须join()或detach(),否则std::terminate;实参一律先拷一份,要真引用得用std::ref并保证对方活得更久;它只可移动不可拷贝—— 剩下的,交给一个 RAII 包装(C++17 手写JoiningThread,C++20 直接用std::jthread)就都不用操心了。