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

華為雲開發者社群發表於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就派上用場了。

 

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

相關文章