5.3. 思考顺序

我们这位老朋友 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结构体:

  • 管理动态分配的long long型变量i(作为计数器)
  • 在serve()方法中持续递增计数器
  • 当计数达到1024768次时(忽略这个数字恰巧是1024x768分辨率):
    • 暂停200毫秒
    • 重置计数器
    • 输出一个点(实际应用中有其他操作)
  • 构造函数/析构函数包含调试信息输出 注意:此处使用原始指针而非智能指针(原因成谜)

foo结构体:

  • 负责启动/停止执行bar::serve()的线程
  • 使用std::jthread管理线程生命周期
  • 通过stopRequest标志位控制线程退出
  • 实际工程中经过两周调试才稳定(团队决定不再提及这段代码)

主函数流程:

  • 创建foo实例自动启动线程
  • 主线程休眠2秒(原版无此休眠,实际有复杂操作)
  • 注意:此示例代码仅为演示崩溃场景,非线程同步最佳实践

各位资深的 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++ 中一项强大却充满风险的特性。它虽能显著提升程序性能,但也带来了复杂性和诸多陷阱。要写出既正确又高效的多线程代码,开发者必须格外谨慎地应对以下几个关键问题:

  • 线程间的同步与协调
  • 竞态条件(race conditions)
  • 死锁(deadlocks)
  • 非确定性行为
  • 调试困境(线程被调试器暂停时无法复现问题)

有时,程序能否稳定运行,竟然取决于类成员变量的声明顺序这样看似无关的细节。

不过,现在是时候暂别乔和他的开发伙伴们了 —— 希望他们那款备受期待的 4A 大作能够顺利发布。接下来,让我们将目光转向其他编程主题,继续探索 C++ 的更多奥秘。