本文版權(quán)歸 liancheng 所有燥撞,如有轉(zhuǎn)載請(qǐng)按如下方式標(biāo)明原創(chuàng)作者及出處,以示尊重C灾摹叨吮!
原創(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)簡單,但相應(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奢方,就一輩子都無法擺脫! 考察以下的簡單用例:
用例一:
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è)簡單的用例可以看出忠聚,shared_ptr就像是毒品一樣,一旦沾染就難以戒除唱捣。更甚者咒林,染毒者連換用其他“毒品”的權(quán)力都沒有:shared_ptr的引用計(jì)數(shù)管理接口是私有的,無法從shared_ptr之外操控爷光,也就無法從shared_ptr遷移到其他類型的引用計(jì)數(shù)智能指針垫竞。
不僅如此,資源對(duì)象沾染上shared_ptr之后蛀序,就只能使用最初的那個(gè)shared_ptr實(shí)例的拷貝來維系自己的生存期欢瞪。考察以下用例:
用例二:
{
shared_ptr p1(new CResource);
shared_ptr p2(p1); // OK
CResource* p3 = p1.get();
shared_ptr p4(p3); // ERROR
// CRASH
}
該用例的執(zhí)行過程如下:
- p1在構(gòu)造的同時(shí)為資源對(duì)象創(chuàng)建了一份外部引用計(jì)數(shù)徐裸,并將之置為1
- p2拷貝自p1遣鼓,與p1共享同一個(gè)引用計(jì)數(shù),將之增加為2
- p4并非p1的拷貝重贺,因此在構(gòu)造的同時(shí)又為資源對(duì)象創(chuàng)建了另外一個(gè)外部引用計(jì)數(shù)骑祟,并將之置為1
- 在作用域結(jié)束時(shí)回懦,p4析構(gòu),由其維護(hù)的額外的引用計(jì)數(shù)降為0次企,導(dǎo)致資源對(duì)象被析構(gòu)
- 然后p2析構(gòu)怯晕,對(duì)應(yīng)的引用計(jì)數(shù)降為1
- 接著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í)道理很簡單:由于使用了shared_ptr的資源對(duì)象必須仰仗shared_ptr的存在才能維系生存期,這就意味著使用資源的客戶對(duì)象也必須使用shared_ptr來持有資源對(duì)象的引用——于是shared_ptr的勢力范圍成功地從資源對(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就擺脫不掉欣除,如此傳播下去,簡直就是侵入了除資源對(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ù)器框架的壓力測試中凯肋,大量引用計(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ù)操作吱殉,只要簡單地在分配時(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囚痴。