問題描述
在基于C++的大型系統的設計實現中,由于缺乏語言級別的GC支持,資源生存周期往往是一個棘手的問題。系統地解決這個問題的方法無非兩種:
- 使用GC庫
- 使用引用計數
嚴格地說,引用計數其實也是一種最樸素的GC。相對于現代的GC技術,引用計數的實現簡單,但相應地,它也存在著循環引用和線程同步開銷等問題。
關于這二者孰優孰劣,已經有過很多討論,在此就不攪這股混水了。我一直也沒有使用過C++的GC庫,在實際項目中總是采用引用計數的方案。而作為Boost的擁躉,首選的自然是shared_ptr。一直以來我也對shared_ptr百般推崇,然而最近的一些項目開發經驗卻讓我在shared_ptr上栽了坑,對C++引用計數也有了一些新的的認識,遂記錄在此。
本文主要針對基于boost::shared_ptr的C++引用計數實現方案進行一些討論。C++引用計數方案往往伴隨著用于自動管理引用計數的智能指針。按是否要求資源對象自己維護引用計數,C++引用計數方案可以分為兩類:
- 侵入式:侵入式的引用計數管理要求資源對象本身維護引用計數,同時提供增減引用計數的管理接口。通常侵入式方案會提供配套的侵入式引用計數智能指針。該智能指針通過調用資源對象的引用計數管理接口來自動增減引用計數。COM對象與CComPtr便是侵入式引用計數的一個典型實例。
- 非侵入式:非侵入式的引用計數管理對資源對象本身沒有任何要求,而是完全借助非侵入式引用計數智能指針在資源對象外部維護獨立的引用計數。shared_ptr便是基于這個思路。
第一宗罪
初看起來,非侵入式方案由于對資源對象的實現沒有任何要求,相較于侵入式方案更具吸引力。然而事實卻并非如此。下面就來分析一下基于shared_ptr的非侵入式引用計數。在使用shared_ptr的引用計數解決方案中,引用計數完全由shared_ptr控制,資源對象對與自己對應的引用計數一無所知。
而引用計數與資源對象的生存期息息相關,這就意味著資源對象喪失了對生存期的控制權,將自己的生殺大權拱手讓給了shared_ptr。這種情況下,資源對象就不得不依靠至少一個shared_ptr實例來保障自己的生存。換言之,資源對象一旦“沾染”了shared_ptr,就一輩子都無法擺脫!考察以下的簡單用例:
用例一:
Resource*p=newCResource;
{
shared_ptrq(p);
}
p->Use()//CRASH
單純為了解決上述的崩潰,可以自定義一個什么也不做的deleter:
structnoop_deleter{
voidoperator()(void*){
//NO-OP
}
};
然后將上述用例的第三行改為:
shared_ptrq(p,noop_deleter());
但是這樣一來,shared_ptr就喪失了借助RAII自動釋放資源的能力,違背了我們利用智能指針自動管理資源生存期的初衷(話說回來,這倒并不是說noop_deleter這種手法毫無用處,Boost.Asio中就巧妙地利用shared_ptr、weak_ptr和noop_deleter來實現異步I/O事件的取消)。
從這個簡單的用例可以看出,shared_ptr就像是毒品一樣,一旦沾染就難以戒除。更甚者,染毒者連換用其他“毒品”的權力都沒有:shared_ptr的引用計數管理接口是私有的,無法從shared_ptr之外操控,也就無法從shared_ptr遷移到其他類型的引用計數智能指針。
不僅如此,資源對象沾染上shared_ptr之后,就只能使用最初的那個shared_ptr實例的拷貝來維系自己的生存期。考察以下用例:用例二:
{
shared_ptrp1(newCResource);
shared_ptrp2(p1);//OK
CResource*p3=p1.get();
shared_ptrp4(p3);//ERROR
//CRASH
}
該用例的執行過程如下:
- p1在構造的同時為資源對象創建了一份外部引用計數,并將之置為1
- p2拷貝自p1,與p1共享同一個引用計數,將之增加為2
- p4并非p1的拷貝,因此在構造的同時又為資源對象創建了另外一個外部引用計數,并將之置為1
- 在作用域結束時,p4析構,由其維護的額外的引用計數降為0,導致資源對象被析構
- 然后p2析構,對應的引用計數降為1
- 接著p1析構,對應的引用計數也歸零,于是p1在臨死之前再次釋放資源對象 最后,由于資源對象被二次釋放,程序崩潰
至此,我們已經認識到了shared_ptr的第一宗罪——傳播毒品:
- 毒性一:一旦開始對資源對象使用shared_ptr,就必須一直使用
- 毒性二:無法換用其他類型的引用計數之智能指針來管理資源對象生存期
- 毒性三:必須使用最初的shared_ptr實例拷貝來維系資源對象生存期
第二宗罪
乘勝追擊,再揭露一下shared_ptr的第二宗罪——散布病毒。有點聳人聽聞了?其實道理很簡單:由于使用了shared_ptr的資源對象必須仰仗shared_ptr的存在才能維系生存期,這就意味著使用資源的客戶對象也必須使用shared_ptr來持有資源對象的引用——于是shared_ptr的勢力范圍成功地從資源對象本身擴散到了資源使用者,侵入了資源客戶對象的實現。
同時,資源的使用者往往是通過某種形式的資源分配器來獲取資源。自然地,為了向客戶轉交資源對象的所有權,資源分配器也不得不在接口中傳遞shared_ptr,于是shared_ptr也會侵入資源分配器的接口。
有一種情況可以暫時擺脫shared_ptr,例如:
shared_ptrAllocateResource(){
shared_ptrpResource(newCResource);
InitResource(pResource.get());
returnpResource;
}
voidInitResource(IResource*r){
//Doresourceinitialization...
}
以上用例中,在InitResource的執行期間,由于AllocateResource的堆棧仍然存在,pResource不會析構,因此可以放心的在InitResource的參數中使用裸指針傳遞資源對象。這種基于調用棧的引用計數優化,也是一種常用的手段。但在InitResource返回后,資源對象終究還是會落入shared_ptr的魔掌。
由此可以看出,shared_ptr打著“非侵入式”的幌子,雖然沒有侵入資源對象的實現,卻侵入了資源分配接口以及資源客戶對象的實現。而沾染上shared_ptr就擺脫不掉,如此傳播下去,簡直就是侵入了除資源對象實現以外的其他各個地方!這不是病毒是什么?
然而,基于shared_ptr的引用計數解決方案真的不會侵入資源對象的實現嗎?
第三宗罪
在一些用例中,資源對象的成員方法(不包括構造函數)需要獲取指向對象自身,即包含了this指針的shared_ptr。Boost.Asio的chat示例便展示了這樣一個用例:chat_session對象會在其成員函數中發起異步I/O操作,并在異步I/O操作回調中保存一個指向自己的shared_ptr以保證回調執行時自身的生存期尚未結束。
這種手法在Boost.Asio中非常常見,在不考慮shared_ptr帶來的麻煩時,這實際上也是一種相當優雅的異步流程資源生存期處理方法。但現在讓我們把注意力集中在shared_ptr上。
通常,使用shared_ptr的資源對象必須動態分配,最常見的就是直接從堆上new出一個實例并交付給一個shared_ptr,或者也可以從某個資源池中分配再借助自定義的deleter在引用計數歸零時將資源放回池中。無論是那種用法,該資源對象的實例在創建出來后,都總是立即交付給一個shared_ptr(記為p)。
有鑒于之前提到的毒性三,如果資源對象的成員方法需要獲取一個指向自己的shared_ptr,那么這個shared_ptr也必須是p的一個拷貝——或者更本質的說,必須與p共享同一個外部引用計數。然而對于資源對象而言,p維護的引用計數是外部的陌生事物,資源對象如何得到這個引用計數并由此構造出一個合法的shared_ptr呢?這是一個比較tricky的過程。為了解決這個問題,Boost提供了一個類模板enable_shared_from_this:
所有需要在成員方法中獲取指向this的shared_ptr的類型,都必須以CRTP手法繼承自enable_shared_from_this。即:
classCResource:
publicboost::enable_shared_from_this
{
//...
};
接著,資源對象的成員方法就可以使用enable_shared_from_this::shared_from_this()方法來獲取所需的指向對象自身的shared_ptr了。問題似乎解決了。但是,等等!這樣的繼承體系不就對資源對象的實現有要求了嗎?換言之,這不正是對資源對象實現的赤裸裸的侵入嗎?這正是shared_ptr的第三宗罪——欺世盜名。
第四宗罪
最后一宗罪,是鋪張浪費。對了,說的就是性能。
基于引用計數的資源生存期管理,打一出生起就被扣著線程同步開銷大的帽子。早期的Boost版本中,shared_ptr是借助Boost.Thread的mutex對象來保護引用計數。在后期的版本中采用了lock-free的原子整數操作一定程度上降低了線程同步開銷。然而即使是lock-free,本質上也仍然是串行化訪問,線程同步的開銷多少都會存在。
也許有人會說這點開銷與引用計數帶來的便利相比算不得什么。然而在我們項目的異步服務器框架的壓力測試中,大量引用計數的增減操作,一舉吃掉了5%的CPU。換言之,1/20的計算能力被浪費在了與業務邏輯完全無關的引用計數的維護上!而且,由于是異步流程的特殊性,也無法應用上面提及的基于調用棧的引用計數優化。
那么針對這個問題就真的沒有辦法了嗎?其實仔細檢視一下整個異步流程,有些資源雖然會先后被不同的對象所引用,但在其整個生存周期內,每一時刻都只有一個對象持有該資源的引用。用于數據收發的緩沖區對象就是一個典型。它們總是被從某個源頭產生,然后便一直從一處被傳遞到另一處,最終在某個時刻被回收。
對于這樣的對象,實際上沒有必要針對流程中的每一次所有權轉移都進行引用計數操作,只要簡單地在分配時將引用計數置1,在需要釋放時再將引用計數歸零便可以了。
對于侵入式引用計數方案,由于資源對象自身持有引用計數并提供了引用計數的操作接口,可以很容易地實現這樣的優化。但shared_ptr則不然。shared_ptr把引用計數牢牢地攥在手中,不讓外界碰觸;外界只有通過shared_ptr的構造函數、析夠函數以及reset()方法才能夠間接地對引用計數進行操作。
而由于shared_ptr的毒品特性,資源對象無法脫離shared_ptr而存在,因此在轉移資源對象的所有權時,也必須通過拷貝shared_ptr的方式進行。一次拷貝就對應一對引用計數的原子增減操作。
對于上述的可優化資源對象,如果在一個流程中被傳遞3次,除去分配和釋放時的2次,還會導致6次無謂的原子整數操作。整整浪費了300%!
事實證明,在將基于shared_ptr的非侵入式引用計數方案更改為侵入式引用計數方案并施行上述優化后,我們的異步服務器框架的性能有了明顯的提升。
結語
最后總結一下shared_ptr的四宗罪:
- 傳播毒品一旦對資源對象染上了shared_ptr,在其生存期內便無法擺脫。
- 散布病毒在應用了shared_ptr的資源對象的所有權變換的整個過程中的所有接口都會受到shared_ptr的污染。
- 欺世盜名在enable_shared_from_this用例下,基于shared_ptr的解決方案并非是非侵入式的。
- 鋪張浪費由于shared_ptr隱藏了引用計數的操作接口,只能通過拷貝shared_ptr的方式間接操縱引用計數,使得用戶難以規避不必要的引用計數操作,造成無謂的性能損失。
探明這四宗罪算是最近一段時間的項目設計開發過程的一大收獲。寫這篇文章的目的不是為了將shared_ptr一棒子打死,只是為了總結基于shared_ptr的C++非侵入式引用計數解決方案的缺陷,也讓自己不再盲目迷信shared_ptr。
原文標題:共享指針四宗罪
文章出處:【微信公眾號:Linux愛好者】歡迎添加關注!文章轉載請注明出處。
-
指針
+關注
關注
1文章
480瀏覽量
70551 -
C++
+關注
關注
22文章
2108瀏覽量
73623 -
計數
+關注
關注
1文章
56瀏覽量
20101
原文標題:共享指針四宗罪
文章出處:【微信號:LinuxHub,微信公眾號:Linux愛好者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論