管理线程

读完能用 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();,如果中间那句抛了异常会发生什么?该怎么改才稳?

从喊来帮厨开始:每个线程都要有人收尾

上一章你雇了第二个厨师,让后厨同时开火。可雇人只是第一步——雇来的帮厨,你总得:他那道菜做完了,你是站在旁边等他一起出餐(会合),还是让他自己慢慢收尾、你先去忙别的(放养)?这一章讲的就是「喊来帮厨之后怎么管他」。

管不好会怎样?最常见的是:你喊了帮厨,转身就走,连「等他」还是「放养」都没交代——后厨直接乱套,程序当场崩溃。还有更隐蔽的:你把自己临时支起的一张小工作台借给帮厨用,结果你先收了台子,帮厨还埋头在那张已经没了的台子上切菜——这种「东西没了人还在用」的错,往往要等很久才暴雷。

所以这一章的目标,就是把「喊帮厨、交代他、给他备料、必要时把他转交给别人」这套管人的活儿,一项一项做对、做稳。

启动线程:给帮厨派一件「能调用的活」

启动一个线程,就是构造一个 对象,并把「要它干的活」交给它。这件活可以是普通函数、,或者一个 lambda 表达式。构造函数返回前,新执行线程已经开始参与调度,但主线程与新线程谁先执行下一步没有顺序保证。

这里有一个 C++ 老坑必须先打预防针:。用临时函数对象启动线程时,std::thread t(Task()); 会被编译器理解成「声明一个函数」而不是「启动一个线程」。具体怎么躲,留到第六节代码里细说。

管帮厨:join 还是 detach,必须选一个

线程一旦启动,std::thread 对象就进入 状态。它描述的是是否仍有关联,不是线程此刻是否还在运行。接下来你必须在这个对象析构前解除关联:

是「等会合」:主线程停下来,直到子线程跑完,才继续往下。 是「放养」:让子线程脱离这个对象、在后台自己跑,主线程继续干别的、不等它。被 detach 出去的线程,就叫

为什么非选不可?因为如果你两样都不做,std::thread 对象析构时发现自己仍 joinable,它的析构函数会调用 std::terminate() 终止整个程序。实现可能输出诊断,也可能不输出,不能依赖固定报错文字。下面这张状态机动画把「创建 -> 三选一 -> 各自下场」演给你看:

猜一猜:std::thread t(f); 之后,如果你既不写 t.join() 也不写 t.detach(),程序会怎样?先想一下,再单步看第三条分支。

可交互
std::thread t(f)创建 → joinable(帮厨待安顿)t.join()主厨停下来等帮厨做完结束not joinable(已会合)t.detach()放帮厨自己干,主厨不等分离 · 后台独立跑not joinable(已放养)既不 join 也不 detacht 析构时仍 joinablestd::terminate 💥程序直接崩溃① join:等会合② detach:放养③ 忘了管:崩溃

第 1 / 7 步 · std::thread t(f) 创建:新线程立刻开跑,t 处于 joinable——帮厨还没安顿

线程一创建就 joinable,主厨必须三选一:join 等会合、detach 放养、或忘了管直接崩。可暂停、单步、拖进度。

std::thread 的命运三选一:join(等它结束)、detach(放它后台独立跑), 二者必居其一;两样都没做,线程对象析构时就调用 std::terminate 把整个程序掐掉。

看清楚了:join 和 detach 都让线程对象变成 not joinable,只有「忘了管」会撞上 std::terminate。join 和 detach 到底差在哪?再看一张双场景泳道动画,同一条子线程,主线程的反应完全不同:

可交互
场景 ① join:主厨等帮厨会合主线程在 join() 处停下来等子线程跑完,再继续主线程子线程⏳ 等待t.join()时间 →场景 ② detach:放养,各跑各的主线程子线程主厨收工↑ 帮厨仍在后台独立跑

第 1 / 6 步 · join|主厨先干自己的活,跑到 t.join() 这一刻

join 让主厨在会合点停下来等帮厨;detach 让帮厨自己后台跑、主厨不等。可暂停、单步、拖进度。

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_errorjoin() 还可能因试图等待当前线程等错误而抛出。状态由多条路径共同维护时,先检查并收拢所有权:

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 只能移动

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 后子线程可能比创建它的函数活得更久,要是它还攥着指向那个函数局部变量的引用,函数一返回就翻车:

可交互
oops() 栈帧函数一返回就整个销毁int local局部变量(砧板)已释放std::ref(local)后台子线程detach 后独立跑func(local)持有指向 local 的引用读已释放内存:未定义行为💥

第 1 / 4 步 · ① oops() 被调用:栈上支起它的工作台,里面有局部变量 local(一块砧板)

detach 后子线程还攥着指向局部变量的引用,可那块栈内存随函数返回已被销毁——悬空引用。可暂停、单步、拖进度。

detach 的子线程可能比创建它的函数活得更久。若它还持有指向那个函数局部变量的引用, 函数一返回,引用就悬空——再去读写就是访问已释放内存的未定义行为。

小结

  • std::thread t(可调用对象) 启动线程,可调用对象可以是函数、函数对象或 lambda;用临时函数对象时当心「最令人头疼的解析」
  • 每个 std::thread 在析构前必须 join()(等会合)或 detach()(放养)二选一,否则析构触发 std::terminate 崩溃;用 joinable() 判断当前状态
  • 怕异常跳过 join,用满足单一所有者不变量的 thread_guardscoped_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::threadstd::unique_ptr 这类 move-only(只移动)类型只支持移动、禁止拷贝——因为它们独占某种资源的所有权,拷贝会产生「两个对象都管同一份资源」的歧义。详见本章「转移所有权」一节。

资料与写作方式声明

本章以C++ 并发编程实战(第2版)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…