構造、析構期間被調虛函數發生的慘案,長教訓!

最近有個問題出現長達一個月,通過兩次修改未能解決,大體場景以下: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); // 取消回調
    }
    // ...
};

分析一下:線程

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

點擊關注,第一時間瞭解華爲雲新鮮技術~

相關文章
相關標籤/搜索