最近有個問題出現長達一個月,通過兩次修改未能解決,大體場景以下:c++
一個多態對象Children被註冊回調(m_observer對象位於基類Base中),正好在析構函數裏面回調,致使crash。segmentfault
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。ide
這裏再延伸一些,這裏的Observable持有的是Observer對象的弱指針,從而實現弱回調,也就是說,Observable經過弱指針提高到強指針來判斷對方Observer是否還活着,若是活着就調對方的註冊的回調函數,不然不調。理想是很美好的,實際因爲組合模式打破了這種原則,由於經過組合模式,持有的僅僅是Observer,當外層對象析構時候發生回調,至關於Observer被Observable偷走了,這時候回調外層對象已經不存在了,若是採用繼承Observer接口的方式,那麼就不會存在這個問題,由於對象是個完整的Observer對象。函數
這也是多繼承一個Observer接口的優點,對象是完整的,只要拿到了Observer強指針,就能保證對象還活着。優化
固然我寫這個不是爲了鼓吹什麼多繼承,批判組合模式,只是雙方都有應用場景罷了,不能一律而論,得出組合優於繼承的結論。this
問題仍是要解決的,回調最初的方案,是否是在Base裏面手動解綁回調就能解決問題了呢?編碼
class Base { virtual ~Base() { 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就派上用場了。