C++ shared_ptr四宗罪(不得不轉(zhuǎn))

本文版權(quán)歸 liancheng 所有,如有轉(zhuǎn)載請(qǐng)按如下方式標(biāo)明原創(chuàng)作者及出處,以示尊重!!
原創(chuàng)作者:liancheng
原文出處:http://blog.liancheng.info/?p=85

問題描述

在基于C++的大型系統(tǒng)的設(shè)計(jì)實(shí)現(xiàn)中,由于缺乏語言級(jí)別的GC支持,資源生存周期往往是一個(gè)棘手的問題。系統(tǒng)地解決這個(gè)問題的方法無非兩種:

  • 使用GC庫
  • 使用引用計(jì)數(shù)

嚴(yán)格地說,引用計(jì)數(shù)其實(shí)也是一種最樸素的GC。相對(duì)于現(xiàn)代的GC技術(shù),引用計(jì)數(shù)的實(shí)現(xiàn)簡(jiǎn)單,但相應(yīng)地,它也存在著循環(huán)引用和線程同步開銷等問題。關(guān)于這二者孰優(yōu)孰劣,已經(jīng)有過很多討論,在此就不攪這股混水了。我一直也沒有使用過C++的GC庫,在實(shí)際項(xiàng)目中總是采用引用計(jì)數(shù)的方案。而作為Boost的擁躉,首選的自然是shared_ptr。一直以來我也對(duì)shared_ptr百般推崇,然而最近的一些項(xiàng)目開發(fā)經(jīng)驗(yàn)卻讓我在shared_ptr上栽了坑,對(duì)C++引用計(jì)數(shù)也有了一些新的的認(rèn)識(shí),遂記錄在此。

本文主要針對(duì)基于boost::shared_ptr的C++引用計(jì)數(shù)實(shí)現(xiàn)方案進(jìn)行一些討論。 C++引用計(jì)數(shù)方案往往伴隨著用于自動(dòng)管理引用計(jì)數(shù)的智能指針。按是否要求資源對(duì)象自己維護(hù)引用計(jì)數(shù),C++引用計(jì)數(shù)方案可以分為兩類:

  • 侵入式:侵入式的引用計(jì)數(shù)管理要求資源對(duì)象本身維護(hù)引用計(jì)數(shù),同時(shí)提供增減引用計(jì)數(shù)的管理接口。通常侵入式方案會(huì)提供配套的侵入式引用計(jì)數(shù)智能指針。該智能指針通過調(diào)用資源對(duì)象的引用計(jì)數(shù)管理接口來自動(dòng)增減引用計(jì)數(shù)。COM對(duì)象與CComPtr便是侵入式引用計(jì)數(shù)的一個(gè)典型實(shí)例。
  • 非侵入式:非侵入式的引用計(jì)數(shù)管理對(duì)資源對(duì)象本身沒有任何要求,而是完全借助非侵入式引用計(jì)數(shù)智能指針在資源對(duì)象外部維護(hù)獨(dú)立的引用計(jì)數(shù)。shared_ptr便是基于這個(gè)思路。

第一宗罪

初看起來,非侵入式方案由于對(duì)資源對(duì)象的實(shí)現(xiàn)沒有任何要求,相較于侵入式方案更具吸引力。然而事實(shí)卻并非如此。下面就來分析一下基于shared_ptr的非侵入式引用計(jì)數(shù)。
在使用shared_ptr的引用計(jì)數(shù)解決方案中,引用計(jì)數(shù)完全由shared_ptr控制,資源對(duì)象對(duì)與自己對(duì)應(yīng)的引用計(jì)數(shù)一無所知。而引用計(jì)數(shù)與資源對(duì)象的生存期息息相關(guān),這就意味著資源對(duì)象喪失了對(duì)生存期的控制權(quán),將自己的生殺大權(quán)拱手讓給了shared_ptr。這種情況下,資源對(duì)象就不得不依靠至少一個(gè)shared_ptr實(shí)例來保障自己的生存。
換言之,資源對(duì)象一旦“沾染”了shared_ptr,就一輩子都無法擺脫! 考察以下的簡(jiǎn)單用例:

用例一:

Resource* p = new CResource;  
{  
    shared_ptr q(p);  
}  
p->Use() // CRASH   

單純?yōu)榱私鉀Q上述的崩潰,可以自定義一個(gè)什么也不做的deleter:

 struct noop_deleter {  
    void operator()(void*) {  
        // NO-OP  
    }  
};  

然后將上述用例的第三行改為:

shared_ptr q(p, noop_deleter());

但是這樣一來,shared_ptr就喪失了借助RAII自動(dòng)釋放資源的能力,違背了我們利用智能指針自動(dòng)管理資源生存期的初衷(話說回來,這倒并不是說noop_deleter這種手法毫無用處,Boost.Asio中就巧妙地利用shared_ptr、weak_ptr和noop_deleter來實(shí)現(xiàn)異步I/O事件的取消)。

從這個(gè)簡(jiǎn)單的用例可以看出,shared_ptr就像是毒品一樣,一旦沾染就難以戒除。更甚者,染毒者連換用其他“毒品”的權(quán)力都沒有:shared_ptr的引用計(jì)數(shù)管理接口是私有的,無法從shared_ptr之外操控,也就無法從shared_ptr遷移到其他類型的引用計(jì)數(shù)智能指針。

不僅如此,資源對(duì)象沾染上shared_ptr之后,就只能使用最初的那個(gè)shared_ptr實(shí)例的拷貝來維系自己的生存期??疾煲韵掠美?br> 用例二:

 {  
    shared_ptr p1(new CResource);  
    shared_ptr p2(p1);            // OK  
    CResource* p3 = p1.get();  
    shared_ptr p4(p3);            // ERROR  
                                  // CRASH  
}   

該用例的執(zhí)行過程如下:

  1. p1在構(gòu)造的同時(shí)為資源對(duì)象創(chuàng)建了一份外部引用計(jì)數(shù),并將之置為1
  2. p2拷貝自p1,與p1共享同一個(gè)引用計(jì)數(shù),將之增加為2
  3. p4并非p1的拷貝,因此在構(gòu)造的同時(shí)又為資源對(duì)象創(chuàng)建了另外一個(gè)外部引用計(jì)數(shù),并將之置為1
  4. 在作用域結(jié)束時(shí),p4析構(gòu),由其維護(hù)的額外的引用計(jì)數(shù)降為0,導(dǎo)致資源對(duì)象被析構(gòu)
  5. 然后p2析構(gòu),對(duì)應(yīng)的引用計(jì)數(shù)降為1
  6. 接著p1析構(gòu),對(duì)應(yīng)的引用計(jì)數(shù)也歸零,于是p1在臨死之前再次釋放資源對(duì)象
    最后,由于資源對(duì)象被二次釋放,程序崩潰

至此,我們已經(jīng)認(rèn)識(shí)到了shared_ptr的第一宗罪——傳播毒品

  • 毒性一:一旦開始對(duì)資源對(duì)象使用shared_ptr,就必須一直使用
  • 毒性二:無法換用其他類型的引用計(jì)數(shù)之智能指針來管理資源對(duì)象生存期
  • 毒性三:必須使用最初的shared_ptr實(shí)例拷貝來維系資源對(duì)象生存期

第二宗罪

乘勝追擊,再揭露一下shared_ptr的第二宗罪——散布病毒。有點(diǎn)聳人聽聞了?其實(shí)道理很簡(jiǎn)單:由于使用了shared_ptr的資源對(duì)象必須仰仗shared_ptr的存在才能維系生存期,這就意味著使用資源的客戶對(duì)象也必須使用shared_ptr來持有資源對(duì)象的引用——于是shared_ptr的勢(shì)力范圍成功地從資源對(duì)象本身擴(kuò)散到了資源使用者,侵入了資源客戶對(duì)象的實(shí)現(xiàn)。同時(shí),資源的使用者往往是通過某種形式的資源分配器來獲取資源。自然地,為了向客戶轉(zhuǎn)交資源對(duì)象的所有權(quán),資源分配器也不得不在接口中傳遞shared_ptr,于是shared_ptr也會(huì)侵入資源分配器的接口。

有一種情況可以暫時(shí)擺脫shared_ptr,例如:

shared_ptr AllocateResource() {  
    shared_ptr pResource(new CResource);  
    InitResource(pResource.get());  
    return pResource;  
}     
void InitResource(IResource* r) {  
    // Do resource initialization...  
}

以上用例中,在InitResource的執(zhí)行期間,由于AllocateResource的堆棧仍然存在,pResource不會(huì)析構(gòu),因此可以放心的在InitResource的參數(shù)中使用裸指針傳遞資源對(duì)象。這種基于調(diào)用棧的引用計(jì)數(shù)優(yōu)化,也是一種常用的手段。但在InitResource返回后,資源對(duì)象終究還是會(huì)落入shared_ptr的魔掌。
由此可以看出,shared_ptr打著“非侵入式”的幌子,雖然沒有侵入資源對(duì)象的實(shí)現(xiàn),卻侵入了資源分配接口以及資源客戶對(duì)象的實(shí)現(xiàn)。而沾染上shared_ptr就擺脫不掉,如此傳播下去,簡(jiǎn)直就是侵入了除資源對(duì)象實(shí)現(xiàn)以外的其他各個(gè)地方!這不是病毒是什么?

然而,基于shared_ptr的引用計(jì)數(shù)解決方案真的不會(huì)侵入資源對(duì)象的實(shí)現(xiàn)嗎?

第三宗罪

在一些用例中,資源對(duì)象的成員方法(不包括構(gòu)造函數(shù))需要獲取指向?qū)ο笞陨恚窗藅his指針的shared_ptr。Boost.Asio的chat示例便展示了這樣一個(gè)用例:chat_session對(duì)象會(huì)在其成員函數(shù)中發(fā)起異步I/O操作,并在異步I/O操作回調(diào)中保存一個(gè)指向自己的shared_ptr以保證回調(diào)執(zhí)行時(shí)自身的生存期尚未結(jié)束。這種手法在Boost.Asio中非常常見,在不考慮shared_ptr帶來的麻煩時(shí),這實(shí)際上也是一種相當(dāng)優(yōu)雅的異步流程資源生存期處理方法。但現(xiàn)在讓我們把注意力集中在shared_ptr上。

通常,使用shared_ptr的資源對(duì)象必須動(dòng)態(tài)分配,最常見的就是直接從堆上new出一個(gè)實(shí)例并交付給一個(gè)shared_ptr,或者也可以從某個(gè)資源池中分配再借助自定義的deleter在引用計(jì)數(shù)歸零時(shí)將資源放回池中。無論是那種用法,該資源對(duì)象的實(shí)例在創(chuàng)建出來后,都總是立即交付給一個(gè)shared_ptr(記為p)。有鑒于之前提到的毒性三,如果資源對(duì)象的成員方法需要獲取一個(gè)指向自己的shared_ptr,那么這個(gè)shared_ptr也必須是p的一個(gè)拷貝——或者更本質(zhì)的說,必須與p共享同一個(gè)外部引用計(jì)數(shù)。然而對(duì)于資源對(duì)象而言,p維護(hù)的引用計(jì)數(shù)是外部的陌生事物,資源對(duì)象如何得到這個(gè)引用計(jì)數(shù)并由此構(gòu)造出一個(gè)合法的shared_ptr呢?這是一個(gè)比較tricky的過程。為了解決這個(gè)問題,Boost提供了一個(gè)類模板e(cuò)nable_shared_from_this:

所有需要在成員方法中獲取指向this的shared_ptr的類型,都必須以CRTP手法繼承自enable_shared_from_this。即:

class CResource :  
    public boost::enable_shared_from_this<CResource>  
{  
    // ...  
};  

接著,資源對(duì)象的成員方法就可以使用enable_shared_from_this::shared_from_this()方法來獲取所需的指向?qū)ο笞陨淼膕hared_ptr了。問題似乎解決了。但是,等等!這樣的繼承體系不就對(duì)資源對(duì)象的實(shí)現(xiàn)有要求了嗎?換言之,這不正是對(duì)資源對(duì)象實(shí)現(xiàn)的赤裸裸的侵入嗎?這正是shared_ptr的第三宗罪——欺世盜名。

第四宗罪

最后一宗罪,是鋪張浪費(fèi)。對(duì)了,說的就是性能。

基于引用計(jì)數(shù)的資源生存期管理,打一出生起就被扣著線程同步開銷大的帽子。早期的Boost版本中,shared_ptr是借助Boost.Thread的mutex對(duì)象來保護(hù)引用計(jì)數(shù)。在后期的版本中采用了lock-free的原子整數(shù)操作一定程度上降低了線程同步開銷。然而即使是lock-free,本質(zhì)上也仍然是串行化訪問,線程同步的開銷多少都會(huì)存在。也許有人會(huì)說這點(diǎn)開銷與引用計(jì)數(shù)帶來的便利相比算不得什么。然而在我們項(xiàng)目的異步服務(wù)器框架的壓力測(cè)試中,大量引用計(jì)數(shù)的增減操作,一舉吃掉了5%的CPU。換言之,1/20的計(jì)算能力被浪費(fèi)在了與業(yè)務(wù)邏輯完全無關(guān)的引用計(jì)數(shù)的維護(hù)上!而且,由于是異步流程的特殊性,也無法應(yīng)用上面提及的基于調(diào)用棧的引用計(jì)數(shù)優(yōu)化。

那么針對(duì)這個(gè)問題就真的沒有辦法了嗎?其實(shí)仔細(xì)檢視一下整個(gè)異步流程,有些資源雖然會(huì)先后被不同的對(duì)象所引用,但在其整個(gè)生存周期內(nèi),每一時(shí)刻都只有一個(gè)對(duì)象持有該資源的引用。用于數(shù)據(jù)收發(fā)的緩沖區(qū)對(duì)象就是一個(gè)典型。它們總是被從某個(gè)源頭產(chǎn)生,然后便一直從一處被傳遞到另一處,最終在某個(gè)時(shí)刻被回收。對(duì)于這樣的對(duì)象,實(shí)際上沒有必要針對(duì)流程中的每一次所有權(quán)轉(zhuǎn)移都進(jìn)行引用計(jì)數(shù)操作,只要簡(jiǎn)單地在分配時(shí)將引用計(jì)數(shù)置1,在需要釋放時(shí)再將引用計(jì)數(shù)歸零便可以了。

對(duì)于侵入式引用計(jì)數(shù)方案,由于資源對(duì)象自身持有引用計(jì)數(shù)并提供了引用計(jì)數(shù)的操作接口,可以很容易地實(shí)現(xiàn)這樣的優(yōu)化。但shared_ptr則不然。shared_ptr把引用計(jì)數(shù)牢牢地攥在手中,不讓外界碰觸;外界只有通過shared_ptr的構(gòu)造函數(shù)、析夠函數(shù)以及reset()方法才能夠間接地對(duì)引用計(jì)數(shù)進(jìn)行操作。而由于shared_ptr的毒品特性,資源對(duì)象無法脫離shared_ptr而存在,因此在轉(zhuǎn)移資源對(duì)象的所有權(quán)時(shí),也必須通過拷貝shared_ptr的方式進(jìn)行。一次拷貝就對(duì)應(yīng)一對(duì)引用計(jì)數(shù)的原子增減操作。對(duì)于上述的可優(yōu)化資源對(duì)象,如果在一個(gè)流程中被傳遞3次,除去分配和釋放時(shí)的2次,還會(huì)導(dǎo)致6次無謂的原子整數(shù)操作。整整浪費(fèi)了300%!

事實(shí)證明,在將基于shared_ptr的非侵入式引用計(jì)數(shù)方案更改為侵入式引用計(jì)數(shù)方案并施行上述優(yōu)化后,我們的異步服務(wù)器框架的性能有了明顯的提升。

總結(jié)

最后總結(jié)一下shared_ptr的四宗罪:

  • 傳播毒品
    一旦對(duì)資源對(duì)象染上了shared_ptr,在其生存期內(nèi)便無法擺脫。
  • 散布病毒
    在應(yīng)用了shared_ptr的資源對(duì)象的所有權(quán)變換的整個(gè)過程中的所有接口都會(huì)受到shared_ptr的污染。
  • 欺世盜名
    在enable_shared_from_this用例下,基于shared_ptr的解決方案并非是非侵入式的。
  • 鋪張浪費(fèi)
    由于shared_ptr隱藏了引用計(jì)數(shù)的操作接口,只能通過拷貝shared_ptr的方式間接操縱引用計(jì)數(shù),使得用戶難以規(guī)避不必要的引用計(jì)數(shù)操作,造成無謂的性能損失。

探明這四宗罪算是最近一段時(shí)間的項(xiàng)目設(shè)計(jì)開發(fā)過程的一大收獲。寫這篇文章的目的不是為了將shared_ptr一棒子打死,只是為了總結(jié)基于shared_ptr的C++非侵入式引用計(jì)數(shù)解決方案的缺陷,也讓自己不再盲目迷信shared_ptr。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • C++ 智能指針詳解 一、簡(jiǎn)介由于 C++ 語言沒有自動(dòng)內(nèi)存回收機(jī)制,程序員每次 new 出來的內(nèi)存都要手動(dòng) de...
    yangqi916閱讀 1,448評(píng)論 0 2
  • 原作者:Babu_Abdulsalam 本文翻譯自CodeProject,轉(zhuǎn)載請(qǐng)注明出處。 引入### Ooops...
    卡巴拉的樹閱讀 30,333評(píng)論 13 74
  • 轉(zhuǎn)自http://blog.csdn.net/xugangwen/article/details/44811783...
    扎Zn了老Fe閱讀 13,069評(píng)論 1 142
  • 1. 什么是智能指針? 智能指針是行為類似于指針的類對(duì)象,但這種對(duì)象還有其他功能。 2. 為什么設(shè)計(jì)智能指針? 引...
    MinoyJet閱讀 696評(píng)論 0 1
  • 1. 讓自己習(xí)慣C++ 條款01:視C++為一個(gè)語言聯(lián)邦 為了更好的理解C++,我們將C++分解為四個(gè)主要次語言:...
    Mr希靈閱讀 2,981評(píng)論 0 13

友情鏈接更多精彩內(nèi)容