我们这位老朋友 Joe 的冒险仍在继续。就在他刚刚领悟到类成员的正确声明顺序并非按字母排序之后不久,便接到了一项新任务:实现一段需要并行执行的代码。
由于他对线程的理解仅来自于 TikTok 上的入门教程,Joe 却依然自信满满地提交了如下代码(请读者见谅,由于版权与知识产权诉讼的限制,我们无法展示那段让整个开发团队耗费两周时间调试的“经典”原始代码。以下示例仅尝试还原 Joe 所谓“成功”实现的核心逻辑):
#include <cstdio>
#include <thread>
#include <chrono>
using namespace std::chrono_literals;
struct bar {
bar() : i(new long long) {
*i = 0; printf("bar::bar()");
}
~bar() {printf("bar::~bar()"); delete i; i = nullptr;}
void serve() {
while(true) {
(*i)++;
if(*i
std::this_thread::sleep_for(200ms);
(*i) = 0;
printf("."); fflush(stdout);
}
if(stopRequest) break;
}
}
long long* i = nullptr;
bool stopRequest = false;
};
struct foo {
foo() : thread(&foo::threadFunc, this) {
printf("foo::foo()");
}
~foo() {
printf("foo::~foo()"); b.stopRequest = true;
}
void threadFunc() {
b.serve();
}
std::jthread thread;
bar b;
};
int main() {
foo f;
std::this_thread::sleep_for(2000ms);
printf("main returns");
return 0;
}
这段C++代码试图还原Joe制造的简易多线程混乱场景。程序定义了两个结构体:
bar结构体:
foo结构体:
主函数流程:
各位资深的 C++ 开发者请注意:请不要纠结于这段示例代码中简陋的线程同步方式,或是其内存分配与释放的具体实现细节 —— 因为这段代码存在的唯一目的就是演示潜在的崩溃场景。实际上,对于更现代和安全的线程管理,C++ 标准库提供了诸如 std::jthread 这样的工具,并且还配套有完善的管理机制(例如 std::stop_source 和 std::stop_token)。我们强烈建议通过阅读相关文档来学习这些特性的规范用法。不过现在,就让我们暂时让 Joe 继续使用他的“天真”线程方案,从中吸取一些宝贵的教训吧。
在作者的 Linux 系统上,执行上述代码的结果通常如下所示:
> $ ./a.out
bar::bar()
foo::foo()
.........main returns
foo::~foo()
bar::~bar()
然而在某些情况下,程序会输出如下结果:
> $ ./a.out
bar::bar()
foo::foo()
.........main returns
foo::~foo()
bar::~bar()
[1] 93622 segmentation fault (core dumped) ./a.out
Joe也遇到了同样的问题。程序偶尔会在退出时失控崩溃。起初这似乎无伤大雅 —— 毕竟程序在结束时崩溃,也算是一种"结束"。但当他将这段代码整合到更大的模块中后,真正的混乱开始了,最终演变成前文提及的、持续两周的调试噩梦。
根本原因其实很简单。编程大师Jimmy在查阅他随身携带的C++标准文档(特别是[class.dtor]章节)后发现:
在析构函数执行完函数体并销毁函数体内定义的自动存储期对象后,类X的析构函数会依次调用:
1.X的非静态数据成员(非变体成员)的析构函数
2.X的非虚直接基类的析构函数
3.若X是最派生类,还会调用虚基类的析构函数
关键规则:
· 所有析构函数调用都视为通过限定名引用(忽略派生类中可能存在的虚析构函数覆盖)
· 基类和成员的析构顺序与其构造完成顺序严格相反
· 数组元素的析构顺序与其构造顺序相反
特别注意:
析构函数中的return语句不会立即返回调用方,在控制权转移前,必须确保所有成员和基类的析构函数都已完成调用。
关键在于,对象的析构顺序与其构造顺序严格相反 —— 就像被构造时压入栈中,而在析构时又以优雅的逆序弹出。
导致错误行为的罪魁祸首很快锁定为以下两点:
std::jthread thread;
bar b;
问题出在对象的构造顺序上:当线程对象被创建后,会立即启动并执行 threadFunc() { b.serve(); }
。然而,此时 bar 类型的对象 b 尚未完成构造。根据 C++ 的析构规则,在程序退出时,bar b 会优先销毁,而此时后台线程可能仍在执行耗时操作 —— 这就导致线程试图访问一个已经销毁的对象,从而引发未定义行为。
虽然从线程创建、启动到 bar 对象构造完成之间的时间差极小(几乎不可能在常规测试中发现问题),可以通过重写 bar 的构造函数来人为放大这一时间窗口,从而暴露潜在的问题:
bar() { std::this_thread::sleep_for(200ms);
i = new long long; *i = 0; printf("bar::bar() ");}
通过这个案例,我们可以清楚地看到:当线程开始操作对象时,该对象的构造过程尚未完全结束。这个问题其实很容易解决 —— 只需调整类成员的声明顺序:
bar b;
std::jthread thread;
多线程是 C++ 中一项强大却充满风险的特性。它虽能显著提升程序性能,但也带来了复杂性和诸多陷阱。要写出既正确又高效的多线程代码,开发者必须格外谨慎地应对以下几个关键问题:
有时,程序能否稳定运行,竟然取决于类成员变量的声明顺序这样看似无关的细节。
不过,现在是时候暂别乔和他的开发伙伴们了 —— 希望他们那款备受期待的 4A 大作能够顺利发布。接下来,让我们将目光转向其他编程主题,继续探索 C++ 的更多奥秘。