管理线程
读完能用 std::thread 启动线程(函数/函数对象/lambda),正确地 join 或 detach、用 RAII 守卫做到异常安全,能安全地给线程传参(按值/std::ref/std::move),并能转移线程的所有权。
学习目标
- 能写出用
std::thread启动线程、并在每个线程对象析构前正确join()或detach()的程序 - 能区分参数的衰减复制、
std::ref引用包装和std::move所有权转移,并判断「detach + 局部变量引用」为何会悬空 - 能回答:一个函数
std::thread t(work); some_call_that_may_throw(); t.join();,如果中间那句抛了异常会发生什么?该怎么改才稳?
机制总览
线程所有权、参数传递与结束协议
- 1
创建线程
按值复制或用显式引用包装传参,确保被引用对象活过线程执行。
- 2
转移所有权
thread 可移动不可复制,owner 变化必须在控制流中可见。
- 3
结束线程
join 建立等待关系;detach 只有在独立寿命和退出策略明确时使用。
章级决策实验
线程所有权、参数传递与结束协议
沿线程生命周期检查谁负责 join、对象能活多久、异常路径怎样收尾。
选择推理阶段
当前阶段 · 创建线程
按值复制或用显式引用包装传参,确保被引用对象活过线程执行。
可核验证据
类型检查、对象寿命图与 sanitizer。
std::thread 是一种必须消费的所有权;代码需要在创建时就证明参数寿命和 join/detach 决策。
失效—证据矩阵
线程所有权、参数传递与结束协议
创建线程
典型失效
临时对象和栈引用在线程启动后已失效。
核验证据
类型检查、对象寿命图与 sanitizer。
转移所有权
典型失效
线程对象被覆盖或离开作用域时仍 joinable,触发 terminate。
核验证据
joinable 断言、move 路径与异常测试。
结束线程
典型失效
异常跳过 join,或 detached 线程访问已销毁状态。
核验证据
RAII joiner、停止日志与 shutdown 测试。
从喊来帮厨开始:每个线程都要有人收尾
上一章你雇了第二个厨师,让后厨同时开火。可雇人只是第一步——雇来的帮厨,你总得管:他那道菜做完了,你是站在旁边等他一起出餐(会合),还是让他自己慢慢收尾、你先去忙别的(放养)?这一章讲的就是「喊来帮厨之后怎么管他」。
管不好会怎样?最常见的是:你喊了帮厨,转身就走,连「等他」还是「放养」都没交代——后厨直接乱套,程序当场崩溃。还有更隐蔽的:你把自己临时支起的一张小工作台借给帮厨用,结果你先收了台子,帮厨还埋头在那张已经没了的台子上切菜——这种「东西没了人还在用」的错,往往要等很久才暴雷。
所以这一章的目标,就是把「喊帮厨、交代他、给他备料、必要时把他转交给别人」这套管人的活儿,一项一项做对、做稳。
启动线程:给帮厨派一件「能调用的活」
启动一个线程,就是构造一个 ↡C++ 标准库里代表一个执行线程的类型,声明在 thread 标准头文件里。构造时接收可调用对象和参数;对象若仍 joinable 就析构,会调用 std::terminate。它不可拷贝、只能移动。 对象,并把「要它干的活」交给它。这件活可以是普通函数、↡重载了 operator() 的类型的对象,可以像函数那样被调用。把它的实例交给 std::thread,新线程就执行它的 operator()。,或者一个 lambda 表达式。构造函数返回前,新执行线程已经开始参与调度,但主线程与新线程谁先执行下一步没有顺序保证。
这里有一个 C++ 老坑必须先打预防针:↡C++ 的语法规则——凡是「能被解析成函数声明」的写法,编译器优先当成函数声明。比如 std::thread t(Task()); 会被当成「声明一个返回 std::thread 的函数 t」,而不是用临时 Task 对象启动线程。。用临时函数对象启动线程时,std::thread t(Task()); 会被编译器理解成「声明一个函数」而不是「启动一个线程」。具体怎么躲,留到第六节代码里细说。
管帮厨:join 还是 detach,必须选一个
线程一旦启动,std::thread 对象就进入 ↡std::thread 仍关联一个执行线程的状态;即使任务函数已经返回,只要尚未 join 或 detach,joinable() 仍返回 true。对象在此状态析构会调用 std::terminate。 状态。它描述的是是否仍有关联,不是线程此刻是否还在运行。接下来你必须在这个对象析构前解除关联:
↡std::thread 的成员函数。调用它的线程会停下来等这个 std::thread 代表的线程跑完,然后才继续。字面意思「会合」——主厨等帮厨做完那道菜,两人会合后再一起走。join 之后该 thread 变为 not joinable。 是「等会合」:主线程停下来,直到子线程跑完,才继续往下。 ↡std::thread 的成员函数。调用它后,子线程脱离这个 std::thread 对象、在后台独立运行,主线程不再等它、也无法再 join 它。被 detach 的线程叫分离线程。detach 之后该 thread 变为 not joinable。 是「放养」:让子线程脱离这个对象、在后台自己跑,主线程继续干别的、不等它。被 detach 出去的线程,就叫 ↡已经被 detach、在后台独立运行、不再受任何 std::thread 对象关联的线程。它的资源由运行时在它结束时自动回收,但主线程无法再等它或获取它的结果。。
为什么非选不可?因为如果你两样都不做,std::thread 对象析构时发现自己仍 joinable,它的析构函数会调用 std::terminate() 终止整个程序。实现可能输出诊断,也可能不输出,不能依赖固定报错文字。下面这张状态机动画把「创建 -> 三选一 -> 各自下场」演给你看:
猜一猜:
std::thread t(f);之后,如果你既不写t.join()也不写t.detach(),程序会怎样?先想一下,再单步看第三条分支。
第 1 / 7 步 · std::thread t(f) 创建:新线程立刻开跑,t 处于 joinable——帮厨还没安顿
线程一创建就 joinable,主厨必须三选一:join 等会合、detach 放养、或忘了管直接崩。可暂停、单步、拖进度。
看清楚了:join 和 detach 都让线程对象变成 not joinable,只有「忘了管」会撞上 std::terminate。join 和 detach 到底差在哪?再看一张双场景泳道动画,同一条子线程,主线程的反应完全不同:
第 1 / 6 步 · join|主厨先干自己的活,跑到 t.join() 这一刻
join 让主厨在会合点停下来等帮厨;detach 让帮厨自己后台跑、主厨不等。可暂停、单步、拖进度。
上场景 join,调用线程在 join() 处等待目标线程结束;下场景 detach,线程对象不再提供等待、取回结果或传播异常的通道。分离线程可以活过创建它的函数和原 std::thread 对象,却不能活过整个进程:main 返回或进程终止时,不会为了它自动等待。
这四项必须同时成立。join 只解决收尾顺序,不自动保护共享数据;detach 只解除线程对象的关联,也不自动延长任何参数的生存期。
上手玩一玩这两张图
上面两张图都是可控教学动画:点「播放」看全程,也可以用「上一步 / 下一步」一帧一帧抠,或拖进度条停在任意时刻。建议这样玩:
- 在
<ThreadLifecycleDiagram />里单步走到第三条分支(「忘了管」),盯住它怎么一步步走向std::terminate——这就是下一节代码里第一个坑的可视化。 - 在
<JoinVsDetachTimeline />里对比两个场景的主线程泳道:join 场景它中间有一段「⏳ 等待」,detach 场景它一路畅通——这就是「等」与「不等」的全部区别。
玩熟这两张图,再去读代码,你会发现每一行 join/detach 都对应着图里某个节点的点亮。
代码:把每件管人活儿做对
启动:函数、lambda,与那个老坑
最朴素的启动——把一个函数交给线程:
#include <thread>
void work() { /* 帮厨要做的活 */ }
int main() {
std::thread t(work); // 喊来帮厨,立刻开干
t.join(); // 等他做完再继续
}如果想就地写逻辑、还能顺手带上几个变量,用 lambda 最省事——它还天然避开了下面要讲的解析坑:
int batch = 42;
std::thread t([batch] { // 捕获 batch,就地写活
process(batch);
});
t.join();现在说那个老坑。如果你想用一个临时函数对象启动线程,很容易这样写——但它是错的:
struct Task {
void operator()() const { /* ... */ }
};
std::thread t(Task()); // ❌ 不是启动线程!join / detach / joinable:先问能不能 join
join() 和 detach() 对同一个关联只能成功一次,做完后 joinable() 就变成 false。对 not joinable 对象调用任一函数都会抛 std::system_error;join() 还可能因试图等待当前线程等错误而抛出。状态由多条路径共同维护时,先检查并收拢所有权:
std::thread t(work);
// ... 中间可能有各种分支 ...
if (t.joinable()) { // 还没安顿?那就 join
t.join();
}不要把 detach() 当成“省掉 join”的快捷写法。调用成功后,没有线程对象可以等待它、请求停止、取得返回值或直接传播异常;任务访问的数据必须在整个执行期有效,进程还要另有停止和错误上报协议:
std::thread t(process_lifetime_service); // 只访问进程级受控状态
t.detach(); // 之后不能再 join
// 此后 t 不再关联任何线程,t.joinable() == false业务代码通常更适合由拥有者持有线程并在关闭阶段 join()。只有任务确实独立、其所有数据和依赖都有足够生存期,而且系统已另行定义停止与失败协议时,才使用 detach。
thread_guard:用 RAII 给「必须 join」上保险
只在「正常路径」末尾写 t.join() 是不够的——万一中间抛了异常,join() 被跳过,std::thread 析构时仍 joinable,直接 std::terminate。经典做法是写一个 RAII 守卫,把 join 交给析构函数兜底:
class thread_guard {
std::thread& t;
public:
explicit thread_guard(std::thread& t_) : t(t_) {}
~thread_guard() {
if (t.joinable()) // 析构时若还没 join,就补上
t.join();
}
thread_guard(const thread_guard&) = delete; // 禁拷贝
thread_guard& operator=(const thread_guard&) = delete; // 禁赋值
};用它包住线程后,无论函数正常返回还是中途抛异常,栈展开都会析构 guard、自动把线程 join 掉:
void f() {
std::thread t(work);
thread_guard guard(t); // 一创建就上保险
some_call_that_may_throw(); // 哪怕这里抛异常……
} // …guard 析构也会 join t,绝不漏还可以让 RAII 对象直接拥有线程,彻底收紧外部操作入口。下面的 scoped_thread 在构造时接管一个 joinable 线程,析构时完成 join;内部线程不会再暴露给调用者:
#include <stdexcept>
#include <thread>
#include <utility>
class scoped_thread {
std::thread t;
public:
explicit scoped_thread(std::thread t_) : t(std::move(t_)) {
if (!t.joinable()) throw std::logic_error("no thread");
}
~scoped_thread() { t.join(); }
scoped_thread(const scoped_thread&) = delete;
scoped_thread& operator=(const scoped_thread&) = delete;
};
void f() {
scoped_thread owner{std::thread(work)};
some_call_that_may_throw();
}这里依赖一个明确不变量:owner 的析构发生在创建它的线程上,内部对象仍 joinable,且没有其他代码能改变它。C++20 的 std::jthread 能提供自动 join 和停止协作,但本书示例边界是 C++17,因此不能用它替换正文 API。
传参:拷贝、引用、移动,三种姿势
在 C++17 中,std::thread 构造器先在创建线程的一侧对可调用对象和各参数做 decay-copy,将结果放进内部存储;新线程再用这些保存值调用任务。数组会退化成指针,普通引用会丢失引用性质,保存值在调用时按右值传递。
按值形参接收的是独立值,修改它不会影响原变量:
void update_copy(int n) { n = 100; }
int x = 0;
std::thread t(update_copy, x); // 保存 x 的独立值
t.join();
// x 仍是 0若函数签名是 void update(int&),直接写 std::thread(update, x) 通常无法编译,因为保存值在调用时不能按预期绑定到非 const 左值引用。要真正传引用,必须用 std::ref 包装:
std::thread t(update, std::ref(x)); // ✅ 真正传 x 的引用
t.join();
// x == 100不可拷贝、只能移动的对象(如 std::unique_ptr)既不能按值拷贝、也不该共享,得用 std::move 把所有权移动进线程:
#include <memory>
void consume(std::unique_ptr<Big> p);
auto up = std::make_unique<Big>();
std::thread t(consume, std::move(up)); // ✅ 移动所有权进线程
t.join();
// 此后 up 为空成员函数同样可以作为入口。第一个参数是成员函数指针,第二个参数提供目标对象;使用指针或 std::ref 时仍要证明对象活到线程结束:
struct worker {
void run(int task_id);
};
worker w;
std::thread t(&worker::run, &w, 42);
t.join(); // join 保证 w 在线程使用期间仍存在转移所有权:std::thread 只能移动
↡C++11 引入的「把资源的所有权从一个对象转移给另一个对象、而不做深拷贝」的机制,用 std::move 触发。move-only 类型(如 std::thread、std::unique_ptr)只支持移动、禁止拷贝。
在 std::thread 上体现得最纯粹:它不可拷贝、只能移动(move-only)。因为一个
std::thread
独占一个底层线程的所有权,拷贝会造成「两个对象都管同一个线程」的歧义。要交出线程,用
std::move:
std::thread t1(work);
std::thread t2 = std::move(t1); // 所有权从 t1 转给 t2
// 此后 t1 not joinable(空壳),t2 负责 join
t2.join();移动构造的目标是空对象,所以能安全接管。移动赋值不同:如果目标对象已经 joinable,赋值会调用 std::terminate,不会替你 join 或 detach。
std::thread a(work_a);
std::thread b(work_b);
// a = std::move(b); // 错:a 仍 joinable,会 terminate
a.join();
a = std::move(b); // 对空的 a 赋值,a 接管 b
a.join();正因为能移动,线程可以从函数返回、可以存进容器。注意 std::vector 里存 std::thread 时必须用移动入容器:
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i)
threads.emplace_back(work, i); // 就地构造,无拷贝
for (auto& t : threads)
t.join(); // 逐个等会合运行时选线程数 + 认线程身份
开几个线程合适?std::thread::hardware_concurrency() 只返回实现提供的并发数提示(常见实现接近逻辑核数),它可能返回 0。这个值不是线程池的正确答案:计算任务还要考虑其他进程和争用,阻塞任务还要看等待比例,任务太小时线程开销反而占主导。
每个正在执行的线程都有 std::thread::id,用 get_id() 取,可比较、打印或作为关联键。同一时刻执行的不同线程 ID 不同,但线程结束后 ID 可以被复用;默认构造或已经移走、join、detach 的 std::thread 会返回代表“没有线程”的 ID:
unsigned n = std::thread::hardware_concurrency();
if (n == 0) n = 2; // 0 表示拿不到,给个合理默认
std::thread t(work);
std::thread::id main_id = std::this_thread::get_id(); // 主线程自己的 id
std::thread::id work_id = t.get_id(); // 子线程的 id
t.join();容易踩的坑
下面这个坑最隐蔽,专门用一张翻车动画演给你看。detach 后子线程可能比创建它的函数活得更久,要是它还攥着指向那个函数局部变量的引用,函数一返回就翻车:
第 1 / 4 步 · ① oops() 被调用:栈上支起它的工作台,里面有局部变量 local(一块砧板)
detach 后子线程还攥着指向局部变量的引用,可那块栈内存随函数返回已被销毁——悬空引用。可暂停、单步、拖进度。
小结
std::thread t(可调用对象)启动线程,可调用对象可以是函数、函数对象或 lambda;用临时函数对象时当心「最令人头疼的解析」- 每个
std::thread在析构前必须join()(等会合)或detach()(放养)二选一,否则析构触发std::terminate崩溃;用joinable()判断当前状态 - 怕异常跳过
join,用满足单一所有者不变量的thread_guard或scoped_thread,把 join 交给析构函数兜底 - 构造参数会先衰减复制:传引用要
std::ref、移动不可拷贝对象用std::move;最忌给 detach 的线程传局部变量引用 std::thread只移动不拷贝;移动赋值前目标必须为空;hardware_concurrency()只是提示,thread::id在线程结束后可以复用
练习
问题 1(改代码型) 下面这个函数有异常安全隐患:如果 risky() 抛异常,t.join() 会被跳过,导致 std::terminate。请用本章的 thread_guard 把它改成无论是否抛异常都能正确 join,并写出守卫存活期间调用者必须遵守的不变量。
void run() {
std::thread t(work);
risky(); // 可能抛异常
t.join();
}问题 2(问答型) 同事写了 std::thread t(update, std::ref(local)); t.detach();,其中 local 是当前函数的局部变量。他说「我用了 std::ref,引用传对了,没问题」。他对吗?请指出隐患并给一条修法。
问题 3(独立实现型) 实现 collect_thread_ids():用 hardware_concurrency() 取得线程数提示(拿不到时退化为 2),把线程放进 std::vector<std::thread>。各线程只把 ID 写入各自槽位,主线程全部 join 后再统一打印,避免并发写 std::cout。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- std::thread
C++ 标准库里管理一个执行线程关联的类型,声明在
<thread>中。构造器会保存可调用对象和参数并启动执行;对象若在 joinable 状态析构,会调用std::terminate。它不可拷贝、只能移动。- 函数对象(functor)
重载了
operator()的类型的对象,可以像函数那样「被调用」。把它的实例交给std::thread,新线程就执行它的operator()。它能携带自己的状态(成员变量),比普通函数更灵活;不过就地写逻辑时 lambda 通常更省事。详见本章「启动线程」一节。- 最令人头疼的解析(most vexing parse)
C++ 的一条语法规则:凡是「能被解析成函数声明」的写法,编译器就优先把它当函数声明。比如
std::thread t(Task());本想用临时对象启动线程,却被当成「声明一个返回std::thread的函数 t」,线程根本没启动。多套一层括号、用花括号、或改用 lambda 即可躲开。详见本章「容易踩的坑」一节。- joinable(可会合)
std::thread仍关联一个执行线程的状态。即使任务已经运行完,只要尚未 join 或 detach,joinable()仍为 true;处于此态的对象析构会调用std::terminate。- join
std::thread的成员函数。调用它的线程(通常是主线程)会停下来等这个std::thread代表的线程跑完,然后才继续。字面意思「会合」——主厨等帮厨做完那道菜,两人会合后再一起走。join之后该线程对象变为 not joinable。详见本章「管帮厨」一节。- detach
std::thread的成员函数。调用它后,子线程脱离这个std::thread对象独立运行,调用者不再拥有等待、停止或取得结果的直接通道。detach之后该线程对象变为 not joinable;进程退出不会等待分离线程。- 分离线程(detached thread)
已经被
detach、在后台独立运行、不再受任何std::thread对象关联的线程。其资源会在线程结束时回收,但调用者不能再 join,也没有直接的停止、结果和异常通道。它可能活过创建作用域,访问的数据必须覆盖整个执行期;整个进程结束时它也会终止。- 移动语义(move semantics)
C++11 引入的机制:把资源的所有权从一个对象转移给另一个对象,而不做深拷贝,用
std::move触发。std::thread、std::unique_ptr这类 move-only(只移动)类型只支持移动、禁止拷贝——因为它们独占某种资源的所有权,拷贝会产生「两个对象都管同一份资源」的歧义。详见本章「转移所有权」一节。