最近有个问题出现长达一个月,经过两次修改未能解决,大致场景如下:
一个多态对象 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
,当外层对象析构时候发生回调,相当于Observer
被Observable
偷走了,这时候回调外层对象已经不存在了,如果采用继承 Observer
接口的方式,那么就不会存在这个问题,因为对象是个完整的 Observer
对象。
这也是 多继承 一个 Observer
接口的优势,对象是 完整 的,只要拿到了 Observer
强指针,就能保证对象还 活着。
当然我写这个不是为了鼓吹什么多继承,批判组合模式,只是双方都有应用场景罢了,不能一概而论,得出组合优于继承的结论。
问题还是要解决的,回调最初的方案,是不是在 Base
里面手动解绑回调就能解决问题了呢?
class Base {
virtual ~Base() {
m_observer->Register(nullptr); // 取消回调
}
// ...
};
分析一下:
- 假设回调在
m_observer->Register(nullptr)
之前 发生,那么由于Register
接口带锁保护,就会等待回调结束后在执行m_observer->Register(nullptr)
语句,这个期间可以保证对象是 活着 的。 - 假设回调在
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
就派上用场了。