条款37 :使 std::thread 型别对象在所有路径皆不可联结

ZaynPei Lv6

该条款的核心论点是,由于销毁一个可联结(joinable)的 std::thread 对象会导致程序终止,因此开发者必须确保在 std::thread 对象被销毁前,在代码的所有可能路径上都将其变为不可联结状态(设定join()或detach())。

std::thread 的可联结性 (Joinability)

一个 std::thread 对象存在两种状态:

  • 可联结 (Joinable):该 std::thread 对象对应于一个底层的、正在运行或已完成运行的系统线程。
  • 不可联结 (Unjoinable):该 std::thread 对象不对应任何正在运行的线程。这包括:
    • 默认构造的 std::thread(还未关联任何线程)。
    • 已被移动的 std::thread(所有权已转移)。
    • 已被 join 的 std::thread(已等待其完成)。
    • 已被 detach 的 std::thread(已与其底层线程分离)。

最关键的规则:当一个可联结的 std::thread 对象的析构函数被调用时(此时还不应该结束),程序的执行会立即终止。

制定这条严格规则的原因是,另外两种看似合理的备选方案————隐式 join() 和隐式 detach()会带来更严重的问题。

隐式 Join (Implicit Join) 指的是一个管理线程生命周期的对象(例如一个线程包装类)在其析构函数中自动调用 join() 方法的行为。这意味着当该对象离开其作用域(scope)并被销毁时,程序会自动地、无需显式代码指示地阻塞,直到它所管理的线程执行完毕。这是一种设计选择,而不是 C++ std::thread 的标准行为。std::thread 本身不会进行隐式 join。

  • 隐式 Join 的缺陷:性能陷阱。它可能在开发者不经意间引入长时间的阻塞,使程序的性能变得不可预测和难以调试。一个看似简单的函数返回或对象销毁,背后可能隐藏着一个漫长的等待。
  • 隐式 Detach 的缺陷:未定义行为。如果析构函数自动 detach(),线程可能会继续访问已经销毁的局部变量(通过引用或指针捕获),导致内存崩溃。这是比性能问题更严重的安全问题。

由于隐式 join 和隐式 detach 都存在严重缺陷,标准委员会选择了最安全、最明确的策略:直接终止程序,迫使开发者必须显式地处理线程的生命周期

解决方案:使用 RAII 对象确保线程被处理

确保在代码的所有退出路径(包括正常返回、break、continue 和异常)上都执行某个操作的最佳 C++ 实践是RAII (Resource Acquisition Is Initialization)。即创建一个局部对象,并在其析构函数中执行所需的操作。

标准库没有为 std::thread 提供现成的 RAII 类,但我们可以自己轻松实现一个。

你可能疑惑, 上面的隐式 join() 不也是通过 RAII 封装线程类实现的吗? 为什么上面的不可以, 而下面的可以? 问题的根源不在于使用 RAII 本身,而在于那个“坏”的 RAII 对象实现了一个糟糕的、隐晦的策略(比如总是 join 或总是 detach); 而解决方案中的“好”的 RAII 对象,则实现了一个优秀的、明确的策略, 程序员通过将解决策略显式地指出, 确保我们预先设定的、明确的策略(通常是 join)在任何情况下都能被严格执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <thread>
#include <stdexcept>

class thread_guard {
public:
// 构造函数获取线程的引用
explicit thread_guard(std::thread& t) : t_(t) {}

// 析构函数:如果线程还 joinable,就 join 它
~thread_guard() {
if (t_.joinable()) {
t_.join();
}
}

// 删除拷贝构造和赋值操作,防止所有权混乱
thread_guard(const thread_guard&) = delete;
thread_guard& operator=(const thread_guard&) = delete;

private:
std::thread& t_;
};

void do_something_in_background() {
std::cout << "后台任务运行中...\n";
// 模拟抛出异常
throw std::runtime_error("后台任务发生错误!");
std::cout << "后台任务完成。\n";
}

void process() {
std::cout << "process 函数开始。\n";
std::thread t(do_something_in_background);

// *** 关键在这里 ***
// 我们显式地创建了一个 guard 对象,其意图非常明确:
// “我希望在这个作用域的任何出口处,都对线程 t 执行 join 操作”
thread_guard g(t);

std::cout << "process 函数即将结束(或因异常退出)。\n";
// 当 process 因为异常而退出时,g 的析构函数会被调用
// g 的析构函数会安全地 join 线程 t
}

int main() {
try {
process();
} catch (const std::exception& e) {
std::cout << "main 捕获到异常: " << e.what() << std::endl;
}
return 0;
}

线程的创建 (std::thread t(…)) 和“生命周期管理策略” (thread_guard g(t)) 是分离的、显式的。程序员通过创建 thread_guard 对象,清晰地表达了“我要在这个作用域结束时 join 这个线程”的意图。这不再是隐式的行为,而是一种明确的声明。即使函数因为异常而提前退出,这个声明依然有效,保证了程序的健壮性。

C++20 的终极解决方案:std::jthread

C++20 标准直接引入了 std::jthread,它本质上就是一个官方实现的、更加完善的 thread_guard。它的析构函数总是会 join,但这是它公开的、众所周知的、作为其核心设计的行为。