博客 / 詳情

返回

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

最近有個問題出現長達一個月,經過兩次修改未能解決,大致場景如下:

一個多態對象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就派上用場了。

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

user avatar peter-wilson 頭像 juqipeng 頭像 musicfe 頭像 huaihuaidedianti 頭像 jellyfishmix 頭像 yookoo 頭像 yaochujiadejianpan 頭像 codecraft 頭像
8 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.