二段式构造

“不建议在构造函数中跨线程的传递this指针,如果构造函数抛出异常提前退出主线程导致该对象被析构或者其他的行为,那么多线程访问该对象很可能会出问题,而建议二段式构造 ”

核心问题:在构造函数完成前暴露 this 指针

在C++中,一个对象的生命周期是分步的:

  1. 分配内存: 为对象分配存储空间。
  2. 构造函数执行: 调用构造函数来初始化对象。只有在构造函数成功执行完毕后,对象才被认为是一个完全构造好的、有效的对象。
  3. 对象生命期: 对象处于可正常使用的状态。
  4. 析构函数执行: 对象生命期结束,析构函数被调用来清理资源。
  5. 释放内存: 释放对象占用的存储空间。

问题的关键在于第2步。如果在构造函数执行过程中(即对象还未完全构建好),就将 this 指针传递给另一个线程,那么另一个线程看到的可能是一个“半成品”对象——它的某些成员变量可能还未初始化,或者正处于一种不一致的中间状态。

具体风险分析

  1. 构造函数抛出异常

    • 场景: 你在构造函数中启动了一个新线程(线程A),并将 this 指针传递给线程A。紧接着,构造函数内部某处(在线程启动代码之后)抛出了异常。
    • 后果
      • 因为构造函数异常退出,对象构造失败,这个“半成品”对象的析构函数不会被调用(这是C++标准规定的:只有构造完全成功的对象,其析构函数才会被调用)。
      • 然而,之前启动的线程A已经开始运行,它正试图访问这个已经不完整的、甚至已经开始被销毁的 this 指针所指向的内存。
      • 这会导致未定义行为(Undefined Behavior),最常见的后果是程序崩溃(Segmentation Fault)或数据错乱。
  2. 线程看到未初始化/部分初始化的成员

    • 场景: 即使构造函数没有抛出异常,也存在问题。
    • 后果
      • 构造函数是按成员变量的声明顺序初始化的。如果你在初始化某个成员变量之前就启动了线程,那么线程访问这个未初始化的成员时,行为是未定义的。
      • 从另一个线程的视角看,对象的构造是“突然”完成的,它无法安全地判断对象什么时候才真正“准备好”被使用。这引入了不必要的竞态条件。

解决方案:二段式构造(Two-phase Construction)

“二段式构造”是一种设计模式,它将对象的内存分配/构造资源获取/初始化分离开来。

  • 第一阶段(构造): 在构造函数中只进行最简单的、不可能失败的操作。例如,初始化成员变量为默认值(nullptr, 0等)。这个构造函数通常声明为 noexcept,确保它绝不会失败。
  • 第二阶段(初始化): 定义一个单独的成员函数(通常叫 init(), start(), open() 等),由用户在对象构造之后显式调用。在这个函数里进行那些可能失败、耗时、或需要分配资源(如启动线程)的操作。

这样做的好处:

  • 生命周期明确: 只有当用户成功调用了 init() 函数后,对象才真正处于“就绪”状态。在这之前,对象虽然存在,但处于一种惰性的、未激活的状态。
  • 异常安全: 如果 init() 失败并抛出异常,由于对象已经成功构造(第一阶段完成),它的析构函数会被正常调用,可以安全地清理在第一阶段中分配的任何基础资源。
  • 避免竞态: 你可以在对象完全构造好(即 new 完成)之后,再调用 init() 启动线程。这样,在线程开始运行时,它要访问的对象肯定是已经完全构建好的有效对象。

代码示例:错误方式 vs. 二段式构造

错误方式(在构造函数中启动线程):

#include <thread>
#include <iostream>

class Dangerous {
private:
    std::thread worker_;
    int data_;

    void worker_thread() {
        // 风险:构造函数可能还未初始化完 `data_`,我们就开始访问它了!
        std::cout << "Worker thread accessing data: " << data_ << std::endl;
    }

public:
    Dangerous(int value) : data_(value) { // 假设初始化data_后...
        // 在构造函数中启动线程并传递 `this`
        worker_ = std::thread(&Dangerous::worker_thread, this); 
        // ... 如果这里还有别的代码,并且抛出了异常,worker_线程就悬空了!
        // throw std::runtime_error("Oops!"); // 灾难!
    }

    ~Dangerous() {
        if (worker_.joinable()) {
            worker_.join(); // 如果构造失败,析构不会被调用,线程就会泄露!
        }
    }
    // ... 省略拷贝控制 ...
};

int main() {
    Dangerous d(42); // 高风险!
    return 0;
}

正确方式(二段式构造):

#include <thread>
#include <iostream>
#include <stdexcept>

class Safe {
private:
    std::thread worker_;
    int data_;
    bool initialized_ = false; // 状态标志

    void worker_thread() {
        // 现在可以安全地访问,因为对象肯定已初始化完毕
        std::cout << "Worker thread accessing data: " << data_ << std::endl;
    }

public:
    // 第一阶段:简单、不会失败的构造
    Safe(int value) noexcept : data_(value) {} 

    // 第二阶段:进行可能失败的重要初始化
    void start() {
        if (initialized_) {
            throw std::runtime_error("Already initialized");
        }
        worker_ = std::thread(&Safe::worker_thread, this);
        initialized_ = true; // 标记为已初始化
    }

    ~Dangerous() {
        // 只有在成功初始化后,才需要join线程
        if (initialized_ && worker_.joinable()) {
            worker_.join();
        }
    }

    // 为了更好地安全,通常禁用拷贝
    Safe(const Safe&) = delete;
    Safe& operator=(const Safe&) = delete;
};

int main() {
    Safe s(42);    // 第一阶段:安全构造
    s.start();     // 第二阶段:显式初始化。如果这里抛出异常,s的析构函数会被调用,不会泄露资源。
    return 0;
}

总结与最佳实践

核心思想是:对象的构造函数应该使其达到一个自洽的状态,但不应该做任何会让自己暴露在外部世界(尤其是其他线程)的事情。

  • 绝对要避免在构造函数中将 this 指针传递给另一个线程、注册全局回调、或将其存储到外部可见的地方。
  • 二段式构造是解决这一问题的经典且有效的方法。
  • 在现代C++中,我们通常通过工厂函数(Factory Function) 来强制使用二段式构造,即让构造函数是 private 的,然后提供一个 static create() 函数来创建对象并调用其 init(),如果 init 失败则返回错误或抛出异常,并妥善处理已分配的内存。

遵循这个准则可以极大地提高多线程C++程序的健壮性和可维护性。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值