RAII和垃圾收集

Utensil按: 此文轉自CSDN文檔中心,作者是Elminste。看了這篇文章才知道,之前我稱之爲“像棧一樣令人放心的析構函數”並非常喜愛的C++特性,原來名爲RAII(資源獲取即初始化, Resource Acquisition Is Initialization),而且發現原來它和GC並不是我想象的那麼水火不容。這篇文章,在我看來,進一步說明了Java因爲GC而對面向對象的思想所進行的閹割,而且正是Java寫的程序常常因爲資源泄漏問題而崩潰的罪魁禍首(個人觀點,請勿扔磚)。

 

 

先來看一小段代碼,它取自 Bjarne Stroustrup 的演講“Speaking C++ as a Native”:

 

// use an object to represent a resource ("resource acquisition is initialization")

class File_handle { // belongs in some support library
    FILE* p;
public:
    File_handle(const char* pp, const char* r)
        { p = fopen(pp,r); if (p==0) throw Cannot_open(pp); }
    File_handle(const string& s, const char* r)
        { p = fopen(s.c_str(),r); if (p==0) throw Cannot_open(pp); }
    ~File_handle() { fclose(p); } // destructor
    // copy operations and access functions
};

void f(string s)
{
    File_handle file(s, "r");
    // use file
}
 

 

熟悉 C++ 的朋友對這種簡稱爲 RAII 的技巧一定不會陌生。簡單的說,RAII 的一般做法是這樣的:在對象構造時獲取資源,接着控制對資源的訪問使之在對象的生命週期內始終保持有效,最後在對象析構的時候釋放資源。藉此,我們實際上 把管理一份資源的責任託管給了一個對象。這種做法有兩大好處:

 

第一、我們不需要顯式地釋放資源。以上述代碼中的函數 f 爲例,我們不必擔心“忘記”關閉文件的問題。而且,即使是函數 f 的控制結構發生了改變,例如在函數中間插入 return 或者拋出異常,我們也能確定這個文件肯定會被關閉。特別是在“異常滿天飛”的如今,RAII 是實現異常安全的有力武器。類似的,如果某個類 C 包含一個 File_handle 成員,我們也不必擔心類 C 的對象會在銷燬時“忘記”關閉文件。

 

第二、採用這種方式,對象所需的資源在其生命期內始終保持有效 —— 我們可以說,此時這個類維護了一個 invariant。這樣,通過該類對象使用資源時,就不必檢查資源有效性的問題,可以簡化邏輯、提高效率。

 

好,介紹完了 RAII,下一個要出場的角色是大名鼎鼎的垃圾收集(Garbage Collection,下面簡稱 GC)。隨着 Java 的流行,GC 已經被越來越多的人所接受,下面我簡單介紹一下 GC 的運行機理。

 

首先引入幾個術語:在 GC 的語境中,對於程序可以直接操縱的指針值(例如,保存在局部變量或是全局變量中的),我們稱之爲“根”;假設對象 A1 保存了一個指向對象 A2 的指針,對象 A2 保存了指向對象 A3 的指針,我們稱 A1->A2->A3 構成了一條“指針鏈” —— 當然,指針鏈可以任意地長;假設從程序中的某個根出發,通過一條指針鏈能夠到達對象 A,那麼我們認爲對象 A 是“存活”的,否則,就認爲它已經“死亡”,隨時可以釋放它佔用的內存。

 

所有 GC 實現,其運行方式都是檢查對象是否存活,並將已經死亡的對象釋放,其實現機理一般分爲三大類:一、引用計數(reference counting),這類 GC 實現爲每個對象保存指向它的指針數量,一旦這個數量降爲 0 ,就將這個對象釋放,小有名氣的 boost::shared_ptr 採用的就是就是這種方式;二、標記-清掃(mark-sweep),這類 GC 實現週期性地掃描整個堆,先將其中的存活對象標記出來,然後再將剩下的死亡對象全部釋放;三、節點複製(copying),這類 GC 實現將整個堆分成兩半,並週期性地將存活對象從當前使用的那一半搬到另一半,留在原先位置的死亡對象就自然地被拋棄了。這三類實現中,引用計數的限制最多 (特別是無法回收環形結構),而且一般在效率上居於劣勢,應用較少,後兩類使用較多。這方面的一些細節,可以參考 2003 年第 1 期程序員上的垃圾收集專欄。另外,人民郵電出版社即將推出《垃圾收集》一書的中譯本,這本書可以說是目前世上唯一一本關於 GC 的全面性的專著,對 GC 有興趣的朋友可以找來看一下(嘻嘻,打個廣告,^_^)。

 

毫無疑問,對於程序員來說,在分配了內存之後如果能夠不必操心怎麼釋放它,那一定是非常愜意的。更重要的是,程序員們從此可以向懸掛引用和內存泄漏 告別了 —— 它們可是程序開發中最令人頭痛的 bug 之一。最後,有了 GC 的支持,在程序的各個模塊之間共享數據變得更容易、更安全,有助於簡化模塊之間的接口。雖然在 GC 對效率的影響方面,人們還存在着各種疑慮,但必須承認,GC 是一種有價值的技術。

 

可惜,非常不幸的,現有的 GC 機制和 RAII 之間可以說是水火不容 —— 怎麼會這樣呢?

 

癥結在於這兩位對待析構函數的態度不同。回顧我們對 RAII 的介紹,它的核心內容就是將一份資源託管給一個對象,讓資源在對象的生命週期之內均處於有效狀態,這樣,它就要求資源由對象的析構函數來釋放。而問題正是 在於,當前現有的 GC 機制下面,很難提供對析構函數的支持。可能會有人感到奇怪,讓 GC 實現在釋放對象的時候調用析構函數不就結了嗎?可惜,事情不那麼簡單。

 

在 GC 的語境中,像析構函數這樣在銷燬對象時執行的動作,被稱爲“終結”(finalization),而支持終結一直是 GC 實現上的一個難題,因爲終結動作很可能給收集工作帶來很大的干擾。舉例而言,考慮下面這樣一個終結動作(這裏我採用 C++ 析構函數的形式):

 

class wedget
{
    ... ...
    ~wedget()
    {
        // global_pointer 是一個全局變量
        global_pointer = this;
    }
};
 

假設現在我們有一個 wedget 對象 w,進一步假設在某個時刻,GC 機制發現從任何一個根出發,都無法到達 w ,那麼按照定義它已經死亡,可以執行終結動作然後釋放了。但是,當我們執行終結動作的時候,w 又把指向自己的指針賦給了一個全局變量也就是一個根,也就是重新出現了一條由根出發、可到達 w 的指針鏈,這樣,按照定義 —— 它又復活了!如果你有心,隨便動動腦子就可以想出上述問題的許多變種,其中有一些還可能顯得很“冠冕堂皇”。

 

此時我們該怎麼做呢?復活 w ?那樣的話我們還必須復活所有 w 指向的對象,但要實現這一點很難,這要求我們不能在執行終結動作之前釋放任何對象(你無法預先確知終結動作會影響哪些對象),而且可能陷入死循環(執行完 終結動作之後,你必須重新確定各個對象存活與否,然後再試着執行終結動作 ……)。那麼我們不復活 w ?也不好,這樣一來 global_pointer 就成了一個懸掛引用,GC 保證的安全性就被捅了一個大窟窿。或者我們禁止在析構函數中出現指針操作?困難,如果析構函數調用其他函數,難道你還能遞歸地禁止下去?要不我們禁止調用 其他函數?咳咳,那這個析構函數根本就無法實現任何實質性的功能,不要提釋放資源了。

 

除去實現上的困難之外,用 GC 中的終結機制來釋放資源還有一個更本質上的問題:執行終結機制的時間是無法確定的。且不說除引用計數之外的 GC 實現釋放對象本來就有相當大的延時,就算將來的實現真的能夠保證對象在死亡的瞬間被釋放,同樣無法滿足需求:假設在某一時刻你希望析構某個對象,釋放它佔 有的資源,但只要某處仍然存在一個指向該對象的指針,這個對象就會“頑強”地生存下去。不妨假設一下,如果這裏需要釋放的資源是一個計時收費的網絡鏈接, 那麼 …… (祝你好運,兄弟!這是你的鋪蓋卷,^_^)

 

綜上,我們已經有充分的理由說,現有 GC 環境下面根本不可能應用 RAII ,它們之間水火不容。事實上,像 Java 那樣支持 GC 的語言,一般都不鼓勵你使用終結機制,對象所需的資源必須顯式地釋放。最簡單的,爲這個類添加一個 close 成員函數負責釋放資源。

 

這樣做有什麼缺點呢?對照最初我們對 RAII 優點的介紹就可以知道了:

 

首先,所有對象需要的資源必須顯式地手工釋放。拿最初的例子來說,函數 f 的最後必須加上一句 file.close(),而且我們得開始擔心函數 f 控制結構的改變,無論是中間插入 return 還是可能拋出異常的地方,都必須加上 file.close()。針對這種情況,Java 等語言一般會支持 try ... finally 這個特徵,規定無論因爲何種原因離開函數,都必須調用 finally 代碼塊中的代碼。try ... finally 確實有效地緩解了這一問題,但是仍然不及 RAII 方案理想:第一、在撰寫 try ... finally 中付出的努力是無法重用的,如果你有 10 個函數裏用了 file_handle,你必須把同樣的代碼寫上 10 遍;第二、確保  try 塊中申請的資源和 finally 塊中釋放資源互相配對現在成了程序員的責任,這是多出來的簿記負擔,而且一旦出錯出現資源泄漏是很令人頭痛的 —— 一般來說,這要比內存泄漏隱蔽多了,而且不可能有專門的工具幫忙;第三、如果某個類擁有若干類似 file_handle 這樣的成員,我們必須爲這個類也添加一個 close 函數,並逐個調用成員的 close 函數(搞不好各個成員釋放資源的函數名字還不一樣),這也是一個多出來的簿記負擔 —— 而且 try ... finally 幫不上什麼太大的忙。

 

其次,由於允許顯式地釋放資源,對象無法再像以前那樣保持“所需的資源在其生命期內始終有效”這樣一個 invariant ,因此對象中所有使用資源的方法必須檢測資源的有效性,用戶在使用對象的時候也必須留意資源是否有效(這種錯誤多半以異常形式呈現)。這不僅使得邏輯變得 複雜,而且又是一個多出來的簿記負擔 —— 對於每種需要資源的類,用戶必須記住它們拋出的代表資源無效的異常是什麼。

 

唔 ~~ 到目前爲止,似乎形勢稍稍有點令人沮喪。RAII 是管理資源的利器,而 GC 提供的方便和安全保證更是誘人之極,但偏偏兩者不可得兼。你要麼投向 GC 的懷抱,然後不得不手工管理其他資源,忍受多出來的麻煩和簿記負擔;要麼放棄 GC,老老實實手工管理內存,但卻能夠在管理其他資源的時候享受 RAII 帶來的方便和安全。你當然可以說世界就是這樣的,有時候我們不得不做出權衡,爲了得到一些而放棄另一些,只是 …… 有沒有更好的辦法呢?

 

鏘!鏘!啪!

 

欲知後事如何,且聽下回分解!

 

上回說到,RAII 與現有的 GC 環境互不相容,也提到了問題的癥結在於對析構函數的調用。這並非僅僅是一個令人遺憾的巧合,仔細想想不難發現,在這個矛盾背後,實際上是兩者在“如何看待一個對象”這一問題上的分歧。

 

前面說過,RAII 的核心內容是把資源託管給對象,並保證資源在對象生命週期內始終有效。這樣,我們實際上把管理資源的任務轉化成了管理對象的任務,你擁有對象就等於擁有資 源,對象存在則資源必定存在,反之亦然。RAII 的全部威力均源於此。這種做法的出發點,用 Bjarne Stroustrup 的話來說,是“use an object to represent a resource”(引自本文上篇開頭所引用的代碼中的註釋)。換句話說,在 RAII 的眼中,對象代表了資源,它的行爲和狀態應該與資源的行爲和狀態完全一致,所以資源的申請和釋放也就自然應該和對象的構造與析構對應起來。對於大多數資 源,程序員有權而且需要控制它何時釋放,那麼很自然的,對於管理這些資源的對象,程序員也就應該有權而且需要控制它何時析構。一言以蔽之,這要求程序員有 權控制對象的生命週期。

 

然而現有的 GC 環境並不滿足上述條件。在本文的上篇中介紹過,對於 GC 來說,一個對象的生命週期有多長程序員是無權干涉的,任給對象 A,它是否存活必須嚴格地按照“是否存在一條從根出發的指針鏈能夠到達 A”來判定。在 GC 的眼中,對象只是一段被分配的內存,而歷史已經證明,決定內存何時釋放這個任務決不能交給愛犯錯的程序員,應該由專門的收集器完成,以確保每個內存回收動 作都是安全的。什麼?一個對象代表一份資源?哈!我 GC 大人的眼中只有內存,沒有什麼資源!

 

嘖嘖,兩者眼中對象的角色差異如此之大,難怪會水火不容了。不過,這個矛盾真的沒有調和的可能嗎?倒也不見得。RAII 看重對象的語義,要讓對象代表資源,因此需要程序員能夠控制對象的生命週期,而 GC 眼中只有內存,反覆強調只有讓收集器來回收內存才能保證安全,這活不能讓程序員來幹。因此,要同時滿足這兩者的要求,對象的生命週期和必須和內存的釋放脫 鉤。這不是什麼新鮮想法。事實上,按照 C++ 標準的定義,一個對象的生命週期始於構造函數,終於析構函數,它本來就不是由內存分配釋放來決定的,而 placement new 更是把這一點表現的極爲充分,分配內存、構造對象、析構對象、釋放內存四個操作清清楚楚、各自獨立。至此,我們的解決方案已經呼之欲出了。一方面,我們要 把釋放內存的責任移交給 GC,同時又要允許程序員控制對象生命週期,那麼,正確的做法就是 ……

 

在現有 GC 機制的基礎上,允許程序員顯式地析構對象,同時,若程序試圖訪問已經析構的對象,將拋出異常。

 

這初看起來非常荒謬(我已經可以看到臺下飛來的無數雞蛋和番茄 …… 哎哎?!你們真砸啊?),但其實並非如此。首先,內存的分配和釋放由 GC 機制管理,凡是指針可到達的內存總是有效的,因此我們擁有同樣的安全性保證 —— 沒有懸掛引用和內存泄漏,所以,我們可以放心大膽地在模塊之間共享對象,不必操心懸掛引用的問題,也不必擔心內存管理的細節“弄髒”模塊之間的接口;其 次,由於允許顯式析構對象,程序員能夠控制對象的生命週期,因此我們可以繼續應用 RAII “用對象代表資源”的語義並享受它帶來的便利;最後,試圖訪問已經析構的對象將會拋出異常,堵上了無定義行爲的口子。一句話,現在我們可以把對象放入支持 GC 的堆裏而不必改變它的語義了。

 

瞧!兩邊不是合作得很好麼?

 

必須承認的是,要實現這個方案,實際上是非常簡單的,但是就我所知,目前並沒有任何一個 GC 環境支持這個做法。這是因爲大多數支持 GC 的語言並沒有類似 C++ 中的析構函數這樣的語言特徵,換句話說,壓根沒有應用 RAII 的能力,而 C++ 本身又並不支持 GC。嗯嗯,面對這種情況,我決定效法先賢 —— 自己實現一個用於 C++ 的垃圾收集庫,用智能指針來實現對指針訪問的控制,並提供顯式析構的接口。

 

最後,我來試着回答某些可能出現的質疑:

 

問:顯式析構和顯式調用 close 有什麼區別?不一樣要程序員自己動手嗎?
答:有區別。請參閱本文上篇中,關於 RAII 相對於顯式釋放資源方案的優勢。

 

問:你允許程序員析構對象,那麼可能出現指向已析構對象的指針,這和懸掛指針不是一樣麼?
答:不一樣。如果你試圖訪問已析構對象,只會拋出一個異常(當然,這得通過智能指針來實現),而訪問懸掛指針 …… 不用我多說吧?

 

問:(續上)這樣一來,通過指針訪問對象可能拋出異常,這是原來沒有的。
答:這種說法不準確。在現有的 GC 機制下,對象佔有的資源需要調用(例如)close 成員函數顯式釋放,而在調用了 close 之後,再試圖訪問這個對象一樣會拋出異常,指明使用這個對象所需的資源已經被釋放。而且,對於現有的方案,每種對象釋放資源的函數可能都不同,標識“資源 已釋放”的異常可能都不同,但如果使用顯式析構的方案,釋放資源的手段是一致的(析構對象),標識“資源無效”的異常也是一致的(“對象已析構”),這大 大減輕了程序員的簿記負擔。

 

問:(再續)但是你多一個檢查啊?每個指針訪問都需要檢查對象是否已析構,效率太低。
答:這種說法也不準確。採用顯式調用 close 釋放資源的方案,在該對象內部,每個完成實質性工作(因此需要訪問對象所需的資源)的成員函數同樣必須檢查資源是否有效,也是一個檢查。而且,這樣的檢查 程序員需要爲每個類分別撰寫,而採用上述顯式析構的方案,只有智能指針需要做這個檢查。

 

問:(再續)你上述兩個問題的回答多少有點偏頗吧?並不是對象的每個成員函數都需要訪問資源,你上述兩個辯解對於不需要訪問資源的成員函數是不成立 的。而且,也並不是所有的對象都需要管理資源,如果我想在支持 GC 的堆裏放一個 int 怎麼辦?這樣,“可能會拋異常”和“多一個檢查”無疑是兩個很大的缺點了吧?
答:嗯嗯,這可能是最有力的質疑了,我的回答是這樣的:我會另外提供 一種不支持顯式析構的智能指針,它的使用就像傳統 GC 環境下的指針一樣,也就沒有“可能會拋異常”和“多一個檢查”的問題。換句話說,如果你的對象不管理資源,或者你一定要在資源釋放之後還能繼續訪問對象, 用它你可以退回到傳統的 GC 方案去。

 

問:我就是不喜歡你這套想法!有意見嗎?
答: …… 沒意見。如果你確實無法接受“顯式析構”的想法,你也可以只使用上面提到的另一種智能指針 —— 嗯嗯,除了“顯式析構”之外,我對於如何在 C++ 中具體實現垃圾收集機制也有很多其他的想法,除了“顯式析構”之外,我的收集器應該還會有其他方面的優點。當然,這是其他文章的內容了。

 

好,大致就是這樣,希望大家多提意見。如果哪位對實現垃圾收集器這個具體工作有興趣,請來這邊坐坐:

http://www.allaboutprogram.com/bb/viewtopic.php?t=1520

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章