ROOT
ROOT

构造、析构期间被调虚函数发生的惨案

最近有个问题出现长达一个月,经过两次修改未能解决,大致场景如下:

一个多态对象 Children 被注册回调(m_observer对象位于基类 Base 中),正好在析构函数里面回调,导致 crash。

class Base {
    // ...
protected:
    std::shared_ptr<Observer> m_observer;
}
class Children: public Base {
    Children(): Base() {
        // Register 函数,接口有锁保护,避免回调时竞争访问 cb 句柄
        m_observer->Register(std::bind(&Children::callback, this));
    }
    virtual void callback() {};
};

第一次修改是通过在基类的 base 里面对 observable 对象取消回调订阅,来避免回调时对象不存在。

class Base {
    virtual ~Base() {
        m_observer->Register(nullptr); // 取消回调
    }
    // ...
};

后来发现每个包含 m_observer 的类都需要这么干,这样就多了很多重复代码,不够简洁,于是考虑进一步优化,干脆在 Observer 析构函数里面去统一取消回调订阅好了。这样析构函数啥代码也不用写:

class Base {
    virtual ~Base() = default;
    // ...
};

结果出现了这种场景,在 Children 对象析构时正好发生回调,这时候底层 Observable 拿到了 m_observer 对象的计数,导致 m_observer 没有去执行 析构,这时候回调对象刚好不存在了,导致 crash。

这里再延伸一些,这里的 Observable 持有的是 Observer 对象的弱指针,从而实现 弱回调 ,也就是说,Observable 通过 弱指针 提升到 强指针 来判断对方 Observer 是否还活着,如果活着就调对方的注册的回调函数,否则不调。理想是很美好的,实际由于 组合模式 打破了这种原则,因为通过组合模式,持有的仅仅是 Observer,当外层对象析构时候发生回调,相当于ObserverObservable偷走了,这时候回调外层对象已经不存在了,如果采用继承 Observer 接口的方式,那么就不会存在这个问题,因为对象是个完整的 Observer 对象。

这也是 多继承 一个 Observer 接口的优势,对象是 完整 的,只要拿到了 Observer 强指针,就能保证对象还 活着

当然我写这个不是为了鼓吹什么多继承,批判组合模式,只是双方都有应用场景罢了,不能一概而论,得出组合优于继承的结论。

问题还是要解决的,回调最初的方案,是不是在 Base 里面手动解绑回调就能解决问题了呢?

class Base {
    virtual ~Base() {
        m_observer->Register(nullptr); // 取消回调
    }
    // ...
};

分析一下:

  1. 假设回调在 m_observer->Register(nullptr) 发生,那么由于 Register 接口带锁保护,就会等待回调结束后在执行 m_observer->Register(nullptr) 语句,这个期间可以保证对象是 活着 的。
  2. 假设回调在 m_observer->Register(nullptr) 发生,由于回调被取消了,所以不会发生回调,这也很安全。

实际运行过程中还是会 crash。这就有点不可思议了,继续分析问题,发现注册的回调是子类的虚函数:

class Children: public Base {
    Children(): Base() {
        // Register 函数,接口有锁保护,避免回调时竞争访问 cb 句柄
        m_observer->Register(std::bind(&Children::callback, this));
    }
    virtual void callback() {}; // 虚函数作为回调
};

在上述情况 1 的时候发生回调,调的是子类的虚函数callback,而每次调用栈的顶端永远是空地址:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007deaeb1498  x1  000000000000009f  x2  0000007def601a34  x3  0000000000000004
    x4  0000000000020002  x5  0000007def601a20  x6  0000000000000000  x7  7f7f7f7f7f7f7f7f
    x8  0000000000000000  x9  27da922d41dff5aa  x10 0000007def6014a0  x11 0000000000000042
    x12 0000000000000000  x13 0000000000000000  x14 0000000000000004  x15 0000141f5dfff2d0
    x16 0000007e05eddd98  x17 0000007e04b76e6c  x18 0000007deeaa8000  x19 0000007def601a20
    x20 0000000000000000  x21 0000007deaeb1498  x22 0000000000000004  x23 0000007def601a34
    x24 000000000000009f  x25 0000007def602020  x26 0000000000000000  x27 0000000000000001
    x28 0000007e047da458  x29 0000007def601a10
    sp  0000007def601650  lr  0000007e05dc0b8c  pc  0000000000000000
backtrace:
      #00 pc 0000000000000000  <unknown>
      #01 pc 00000000003cfddc ...

函数地址为空,只有虚函数可能发生了,我写了原型程序验证了一下,模拟情况 1 发生的行为:

struct Base {
    virtual ~Base() {
        printf("%s\n", __func__);
        sleep(100); // 保证对象在回调期间还活着
    }
};
struct Children: Base {
    virtual void func() { // 虚函数作为回调
        puts("virtual func call!");
    }
    ~Children() override {
        printf("%s\n", __func__);
    }
};
int main()
{
    Children* c = new Children;
    std::thread t([&c] {
        while (true) {
            c->func(); // 调用子类的虚函数
            sleep(1);
        }
    });
    sleep(5); // (1)
    delete c; // (2)这时候会在基类的析构函数中等待
    t.join(); // crash !!
    return 0;
}

果然 crash 了,看看调用栈如下:

#0  0x0000000000000000 in ?? ()
#1  0x0000555555554f19 in <lambda()>::operator()(void) const (__closure=0x555555769e98) at tt.cpp:43
#2  0x0000555555555229 in std::__invoke_impl<void, main()::<lambda()> >(std::__invoke_other, <lambda()> &&) (__f=...) at /usr/include/c++/7/bits/invoke.h:60
#3  0x0000555555555034 in std::__invoke<main()::<lambda()> >(<lambda()> &&) (__fn=...) at /usr/include/c++/7/bits/invoke.h:95
...

在看看阶段 (1) 对象 c 的虚函数表:

vtable for 'Children' @ 0x555555756c88 (subobject @ 0x555555769e70):
[0]: 0x55555555564a <Children::~Children()>
[1]: 0x555555555680 <Children::~Children()>
[2]: 0x55555555562e <Children::func()>

在阶段(2),对象的虚函数表如下:

vtable for 'Children' @ 0x555555756cb0 (subobject @ 0x555555769e70):
[0]: 0x5555555555ce <Base::~Base()>
[1]: 0x555555555602 <Base::~Base()>
[2]: 0x0

可以得出结论,在基类的析构期间,子类的虚函数表已经清空,这时候调子类的虚函数已经是不安全的了,虽然这时候对象还活着,但 不完整。所以得通过加接口,在析构函数之前去释放回调,这样才是安全的了。

科目二,《Effective C++》也指出,不能在构造、析构函数中调虚函数,原因是这期间虚函数 没有多态性 ,所以即使编码遵守原则,在多线程场景下,也防不住有析构期间 被调用 虚函数的情况,特别是被调的时候。

2020/6/2 更新 前面说通过加接口,在析构函数之前去释放回调,这样不够优雅,因为子类重写得记得多了这么一个接口需要调用,所以继续重构,达到了如下完美的方案:

class Base: public std::enable_share_from_this<Base> // 需要继承这个类从而拿到 this 的智能指针 {
    // ...
protected:
    std::shared_ptr<Observer> m_observer;
}
class Children: public Base {
    Children(): Base() {
        // Register 函数,接口有锁保护,避免回调时竞争访问 cb 句柄
        // 错误写法,this 随时可能析构掉: m_observer->Register(std::bind(&Children::callback, this));
        std::weak_ptr<Base> wpBase = enable_from_this(); // 拿到 this 的弱指针
        m_observer->Register([wpBase] () {
            std::shared_ptr<Base> spBase = wpBase.lock(); // 弱指针提升到强指针
            if (spBase) { // 若对象还活着,则调用回调,这里也可以保证对象是完整的。
                return std::static_point_cast<Children>(spBase)->callback() 
            }
        });
    }
    virtual void callback() {};
};

本质来说,回调传递 this 指针是不安全的,所有 裸指针 都是有风险的,如果用智能指针封装,就能保证对象的完整性了,在这个场景下,只需要将 this 转换成智能指针,这时候 std::enable_share_from_this 就派上用场了。

支持一下
扫一扫,支持Netcan
  • 微信扫一扫
  • 支付宝扫一扫