构造、析构期间被调虚函数发生的惨案,长教训!

发布于: 2020 年 08 月 10 日

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



一个多态对象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); // 取消回调
}
// ...
};



分析一下:



  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++》也指出,不能在构造、析构函数中调虚函数,原因是这期间虚函数没有多态性,所以即使编码遵守原则,在多线程场景下,也防不住有析构期间被调用虚函数的情况,特别是被调的时候。



更新ing。。



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



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就派上用场了。



点击关注,第一时间了解华为云新鲜技术~



发布于: 2020 年 08 月 10 日 阅读数: 47
用户头像

传送门:https://bbs.huaweicloud.com/ 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算!

评论

发布
暂无评论
构造、析构期间被调虚函数发生的惨案,长教训!