条款37 :使 std::thread 型别对象在所有路径皆不可联结
该条款的核心论点是,由于销毁一个可联结(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 |
|
线程的创建 (std::thread t(…)) 和“生命周期管理策略” (thread_guard g(t)) 是分离的、显式的。程序员通过创建 thread_guard 对象,清晰地表达了“我要在这个作用域结束时 join 这个线程”的意图。这不再是隐式的行为,而是一种明确的声明。即使函数因为异常而提前退出,这个声明依然有效,保证了程序的健壮性。
C++20 的终极解决方案:std::jthread
C++20 标准直接引入了 std::jthread,它本质上就是一个官方实现的、更加完善的 thread_guard。它的析构函数总是会 join,但这是它公开的、众所周知的、作为其核心设计的行为。