管理线程
读完能用 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();,如果中间那句抛了异常会发生什么?该怎么改才稳?
从喊来帮厨开始:每个线程都要有人收尾
上一章你雇了第二个厨师,让后厨同时开火。可雇人只是第一步——雇来的帮厨,你总得管:他那道菜做完了,你是站在旁边等他一起出餐(会合),还是让他自己慢慢收尾、你先去忙别的(放养)?这一章讲的就是「喊来帮厨之后怎么管他」。
管不好会怎样?最常见的是:你喊了帮厨,转身就走,连「等他」还是「放养」都没交代——后厨直接乱套,程序当场崩溃。还有更隐蔽的:你把自己临时支起的一张小工作台借给帮厨用,结果你先收了台子,帮厨还埋头在那张已经没了的台子上切菜——这种「东西没了人还在用」的错,往往要等很久才暴雷。
所以这一章的目标,就是把「喊帮厨、交代他、给他备料、必要时把他转交给别人」这套管人的活儿,一项一项做对、做稳。
启动线程:给帮厨派一件「能调用的活」
启动一个线程,就是构造一个 ↡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(只移动)类型只支持移动、禁止拷贝——因为它们独占某种资源的所有权,拷贝会产生「两个对象都管同一份资源」的歧义。详见本章「转移所有权」一节。