.NET 垃圾收集器的過去現在和未來(轉載)

專職做c++兩個多年頭了,今天偶然看到了這篇文章,精闢簡要的講解了.net gc的內容,糾正了目前很多書籍和bbs上的錯誤說法,適當的和c++進行了比較,現在的我也仍然感到受益非淺,早些年的.net開發中曾經也一直在想的一些問題也在這邊文章中詳細的說明,曾經被某些經典書籍誤導的觀點也在這裏得到了糾正

 

                                                            NET垃圾收集器的過去、現在和未來(一)

譯者         程化
 
 
Patrick Dussud介紹:
Patrick Dussud在微軟工作了11年,曾經負責VBA、Jscript、MS Java等語言運行時的垃圾收集器(Garbage Collector)的設計,目前負責.NET CLR垃圾收集器的設計。他是.NET CLR的架構師,WinFX的首席架構師,Windows架構師組的成員。
在微軟之前,Patrick是德州儀器(TI)Explorer工作站系統的主要設計人,Lucid公司Energize產品的首席架構師。
 
Charles:好的,今天我們又回到了42號樓,採訪對象是Patrick Dussud,垃圾收集器的創造者。Patrick,最近怎樣?
 
Patrick:很好。
 
Charles:你還沒有上過Channel 9,我們試圖聯繫你已經有些日子了。開始的話題似乎應該是,什麼是垃圾收集器?我們從這個最基本的地方開始,垃圾收集器負責什麼?
 
Patrick:垃圾收集器使用戶內存管理自動化。在以前的C++中,你必須用“malloc”或者“new”來分配內存,然後在適當的時候釋放內存。你必須保證在釋放之前內存沒有被別人使用,如果你把內存給了別人,往往你就不確定應該何時釋放內存了。當你釋放了內存,不知道別人正在使用這塊內存時,就產生了程序崩潰的問題。所以,當你顯式進行“new”和“delete”時,內存管理是一個複雜的問題,並且,此時你的代碼不可組合。要麼你必須確定對自己的內存有完全的控制,因此,要達到這種完全隔離的目的,你必須在將內存傳遞給別的模塊時進行完全拷貝,這樣,別的模塊就只對這個完全拷貝的內存負責。要麼你就得在某個地方形成對整個內存池的統一的管理,這就是自動化內存管理,這就是垃圾收集器的工作。
垃圾收集器本質上就是負責跟蹤所有對象被引用到的地方,關注對象不再被引用的情況,回收相應的內存,並且用高效率的方式來做這件事,很可能其效率甚至高於傳統的“new”和“delete”範疇。事實上,我們試圖超過“new”和“delete”,因爲垃圾收集器給我們提供了新的機會,而你不會對新機會設置限制。舉個例子,你必須知道每個對象在何處被引用,你必須確定每個對象是否真的被引用了。而一旦你做到了這一點,你會發現自己可以移動對象,壓縮對象佔用的內存空間,把對象在整個內存內搬來搬去,因爲你知道對該對象的每處引用,你可以修改所有的引用。在C++中這是不可能的。如果我們除了使“delete”自動化外,還是象“new”和“delete”那樣管理內存,我們一定會比“new”和“delete”慢,因爲我們僅僅增加了額外的開銷。但是,做了內存空間的智能壓縮之後,我們發現自己的速度能夠超過“new”和“delete”,因爲我們能夠保持非常緊湊,從而形成緩存本地化,頁面本地化等等優勢,因此,結果很好,尤其是對於非常難以管理的服務器內存來說更是如此。例如,對於服務器堆空間碎片化或者相似的問題來說,事實上,我們做得比過去任何嘗試都要好。性能不會隨着時間的過去而下降,我們得到了穩定的內存管理速度。
 
Charles:有趣。很多時候我們都聽人說,“我願意寫非託管代碼,我不願意寫託管代碼,我可不願意我的對象被別人控制”。很多C++程序員都這樣想。
 
Patrick:是的,確實如此。這是對象的“微管理”問題。這個問題要靠經驗甚至信念。當進行和“去除內存”有關係的操作的時候,大家對垃圾收集器感到最不放心。此時我們要和終止器,以及那些析構函數打交道。,在C++中,“析構函數在你進行delete時被調用”這點非常確定。對於我們來說,由垃圾收集器來關注對象消亡的事情,析構函數,其實就是終止器的調用時機由垃圾收集器決定。很多人對此非常吃驚。特別地,我們必須注意在析構函數中引用了哪些對象,因爲當你析構若干對象的時候,這些對象的析構函數被調用的先後順序是無法預先確定的。有可能你會先析構底層對象,然後才析構高層對象,如果高層對象析構時要對底層對象做點額外工作,就會失敗,因爲底層對象已經被析構了。當然,底層對象的內存還在,我們對於內存的管理很注意一致性,高層對象執行析構代碼時想要訪問的對象都可以訪問到,只是這些對象的狀態已經不能被析構函數改變了。這裏必須要非常小心。
舉個例子,你想用一個類層次實現文件系統,最底層的類封裝操作系統的文件句柄,當文件句柄類不再被引用的時候,你想在析構函數中關閉操作系統句柄,從而避免泄漏資源。然後我們搭建高層。如果你做一個字處理器,往往會有好幾層對象,所有的終止化操作層層遞進,因爲你往往想要先保存緩存內容,這樣當最終關閉文件時,所有被緩存的內容都被自動寫入了。順帶說一下,這並不是編寫字處理器的好方法,正確的方法應該是顯式關閉文件。對應用程序來說,第一步往往是區分對象的副作用與對象的生命週期。如果隨着對象的消亡,有其他東西需要結束,你應該提供顯式的方法。(就這個例子來說)當你調用這個顯式的Close方法時,一切良好。但是,如果你忘記調用Close了,而你的對象已經沒有被引用了,這個時候該怎樣做?本質上來說,如果你的程序不能保證高層對象能夠在清空緩存時一直向下處理到文件句柄,如果文件句柄先關閉了,很明顯會出問題。我們就面對這個問題。我們用一個簡單的辦法來解決這個問題。我們有一種對象被稱爲“關鍵終止化對象”,它封裝了widby(.NET 2)中的OS句柄類,它最後被終止化。當我們有一系列對象需要終止化時,關鍵終止化對象最後被終止化,從而直到高層幹完工作前,它都可以看到文件句柄。在一般意義上,我們沒有一個保證機制,因爲我們不想因爲終止化調用順序問題引入複雜的對象關係圖。一般說來,終止化代碼沒有調用順序,我們的簡單方案只是一個保險,以防程序員在對象銷燬時沒有正確地處理最後的副作用。事實上,調試模式下,我們許多的終止化代碼中都有一句調用,說如果垃圾收集已經開始,而程序又進入到了這段終止化代碼中,這就是個錯誤,我們拋出錯誤,開發人員負責修改這個錯誤。
 
Charles:很有趣。關鍵終止化對象的語義是什麼?你們如何定義關鍵終止化對象?
 
Patrick:我們從關鍵句柄繼承。這些東西內建在CLR裏面。大家可以從關鍵句柄繼承,但是隻有系統級代碼纔有這種需求。
 
Charles:讓我們談談CLR垃圾收集器的歷史,比如,你當時面對的第一個挑戰之類的……
 
Patrick:垃圾收集器的歷史是,我寫了微軟絕大部分垃圾收集器。我們寫的第一個產品級垃圾收集器現在還在用,那就是Jscript和VBScript的垃圾收集器。
當時我們聚了4個人,決定利用一些週末搞出Jscript來,因爲我們覺得用Jscript進行網頁編程很酷。很早之前,關於Perl的工具我們就有過爭論,它對內存進行非常顯式的管理,解釋器會根據要求生成new和delete。我認爲,“不,我們必須引入垃圾收集器,因爲微管理會成本過高。”我的一個朋友說,“好的,我來寫顯式管理,你寫垃圾收集器,我們看看誰的好。”我沒有按時完成任務,我朋友完成得比我快,因爲顯式處理delete要好實現得多。然後我們開始運行他寫的代碼,但是發現代碼的速度太慢了。他說,“好吧,我放棄了,我認爲你的代碼不會像我的這樣慢。”然後我完成了垃圾收集器,最終放到了產品中。這個垃圾收集器非常簡單,編程上很保守。我們並不知道對內存的所有引用之處,如果有個整數湊巧看起來很像某個對象的地址,我們就認爲對象還活着。我們很保守,不會銷燬所有能夠銷燬的對象,不會大量移動對象,因爲如果有一個整數實際上指向某個對象,但我們不確定它是否是個指針,因爲它看起來是個整數,那我們就不敢改變整數的內容,因爲沒準這是價格啊什麼的。這個垃圾收集器非常有限,也不復雜。
然後,也是這羣朋友一起開始了Java虛擬機(JVM)——微軟Java虛擬機的研發。我爲這個虛擬機寫了另一個垃圾收集器。這個垃圾收集器繼承自Jscript的垃圾收集器,也比較保守。在那個時候,所有的JVM都進行保守編程。然後,我諮詢了另一個微軟外的朋友,我們一起討論,“如果我們想做一個Windows上最棒的垃圾收集器,我們應該怎樣做?”於是,我們一起工作,寫了一些規格說明書,然後我開始實現。有趣的是,我用的是LISP來實現,因爲在那時,LISP有最好的調試工具,保護方面也很強,比如所有的數組都有邊界檢查。我們有非常好的調試器。我用LISP編寫,然後用LISP寫了一個JVM的模擬器,進行調試,然後寫了一個轉換器,把LISP代碼自動轉換成C++代碼,那就是新的JVM垃圾收集器的基礎。
 
Charles:寫一個把LISP轉換成C++的轉換器對你來說是不是個挑戰?
 
Patrick:不是,因爲我原來在用LISP的公司工作。我曾經在德州儀器工作,開發TI Explorer。我寫過一個轉換器,把LISP的一種方言Zeta LISP轉換成標準LISP。我們轉換了所有300萬行系統代碼,全部自動轉換,然後我們拋棄了老的方言。所以,我知道怎樣做這個工作,這不麻煩。當我寫LISP代碼時,我很小心地只用那些方便轉換到C++上的功能。所以,轉換很直接,因爲我有寫LISP轉換器的經驗。
 
Charles:當然,CLR的垃圾收集器是用C++寫的?
 
Patrick:是的,當我們從JVM前進到CLR時,我用了部分JVM垃圾收集器作爲基礎,然後進行了大幅優化。從我的觀點來看,寫一個好的垃圾收集器本質上是寫一個堅固的,支持良好機制的基礎。當你發現了一些能工作的機制後,在這個機制上你不想有太多變化,而機制之間必須足夠正交化。如果你的架構良好,你就可以逐步往上加機制。在表層上,引入我稱之爲“政策”的東西。政策決定在哪些情況下使用何種機制。垃圾收集器的絕大部分速度和效率都來源於對政策的調整。當應用程序使用一般機制時,垃圾收集器會自動發現工作負載的增加,然後進行調整,基本上我們會把應用程序從非常無效的收集模式調整到更有效的收集模式中。年復一年,我們都在研究負載情況,如果某個負載看起來很糟,我們會問,“糟在何處?我們如何才能改善負載情況?”當我們找到方法後,我們就知道,啊,當發生這種情況時,應該使用這些機制,這樣就能使負載好得多。於是,政策就會力求通過觀察關聯因素髮現這些情況。我們觀察所有代齡的收集頻率,我們觀察內存內部的碎片狀況,我們觀察內存佔用,我們觀察內部的記錄,研究垃圾收集器內部哪些東西本來應該不太耗時,但是在特定條件下卻耗時很多。我們觀察所有這些開銷和頻率。從所有這些我們得到結論,喔,這種機制實際上沒多大用,我們本來以爲在儘量重用內存,但是,因爲內存佔用太多,我們做了一次完全的垃圾收集,但是,完全的垃圾收集卻沒有什麼發現,所以,下一次OS告訴我們內存仍然過少的時候,我們最好不要再次對應用程序進行完全的垃圾收集,因爲上次和這次之間沒有發生什麼,我們仍然不能從完全的垃圾收集中得到好處。這就是個動態調整如何進行的例子。事實上,垃圾收集器體現了我們對來自客戶、內部、合作伙伴的許許多多工作負載進行深入觀察的經驗體會。我們努力找到關聯因素,這些因素或者使應用程序表現良好——我們會試圖重現這些因素;或者使應用程序表現惡劣——我們會試圖將應用程序調整到更有效的狀態。
 
Charles:有趣。我想問一個問題,什麼定義了一個對象是否還活着?我們來談談對象的生命週期,以及爲什麼在像垃圾收集器這樣一個非顯式的環境中,開發人員不用明確指出對象的結束。這也正是以前的代碼不可組合的原因。
 
Patrick:我們從頭開始談。如何表達擁有一個對象?我們有局部變量,此時我們說“object i = new object”,這裏的“i”表示對象。這是一種對象來源。另一個來源是靜態變量,講起來更加複雜,不太有趣,但是道理一樣,都是句柄,你可以創建自己的句柄。這就是執行引擎(EE)擁有對象的主要方式。顯然,對象會擁有其他對象。這就是樹圖的開始。本質上,我們可以把一羣對象看作樹圖,或者一系列的樹圖,這些樹圖的根要麼是你棧上所有的變量,要麼是你程序擁有的所有靜態變量。這就是最初的樹集。我們管這叫樹集。在收集的時候,在EE和垃圾收集器模塊之間有劃分明確的協議。
 
Charles:EE是執行引擎嗎?
 
Patrick:是的,就是CLR。當垃圾收集模塊決定要開始收集的時候,它調用到EE中,請求停止所有的線程,這樣纔可以檢查線程堆棧。EE照此辦理,所有的棧被凍結。然後垃圾收集器告訴EE,現在你必須遍歷所有的棧和靜態變量,然後返回最初的樹集。EE中有一個遍歷模塊負責這件事。然後,CLR每次用一個樹調用垃圾收集器模塊。垃圾收集器收到樹後,將遍歷編譯器生成的靜態數據,這些數據告訴我們對象實例的哪個偏移量對應着對其他對象的引用。我們挨個檢查所有的引用位置,對每個位置進行遞歸檢查。當退出遞歸過程的時候,樹圖中由這個根出發能夠到達的各個樹都被檢查過了,這個根能夠到達的所有地方都被標記了。我們用很多方法做標記,這個過程不太有趣。最終,我們能夠說出是否可以到達某個對象,就是靠判斷是否做了標記。基本的想法就是留點痕跡,拿着一個對象,你能說出它被標記了沒有。我們或者在對象內部別人通常不太可能看到的地方寫點東西,或者做一張外部表。我們兩種方法都用,具體用哪種方法看具體情況下的效率。順帶說一句,工作代碼並不按遞歸方式編寫,因爲你可能有一個非常、非常長的檢查鏈,有可能會耗光棧空間。我們用數據棧,只記錄需要檢查的對象的引用。彈棧,檢查裏面的東西,將該對象的所有引用壓棧,如此反覆直到棧變空爲止。棧變空意味着我們已經標記了這個根能夠到達的所有對象。我們對所有的局部變量、保存着引用的寄存器、靜態變量重複這個操作。一旦完成,我們就沒有遺漏地標記了程序能夠到達的每一個對象。此時,我們就能逐個對象地檢查內存,發現它被標記了,好的,留下。沒有被標記?喔,我們有一個垃圾了。特定的時候,我們會決定是否壓縮所有的垃圾。這就是基本想法。重要的是我們稱之爲“完全的垃圾收集”的操作,因爲我們檢查所有的根能夠到達的所有對象。我們也有辦法只收集那些最近分配的對象,我們稱之爲“第0代”收集,此時垃圾收集器只檢查那些最新分配的對象。因此,我們也要找到一個辦法,保證如果較老的對象引用了這些新對象的話,我們可以知道。我們有辦法很快地找到這些特殊的引用位置,不用在所有的對象中去遍歷查找。
 
Charles:現在是很好的闡述“代齡”的意思的時候。對於垃圾收集器來說,這是垃圾收集器最近一次查找的垃圾?
 
Patrick:是的,那是最新的一代,我們叫它“第0代”。一般說來,你都會在這裏找到大量的垃圾。它的局部性也很好,緩存中往往有剛剛創建的對象的引用,如果你幸運的話,大部分剛創建的對象都在緩存中,因此處理起來很快,進行壓縮也很有效率。所以,如果當你處理剛剛創建的對象的時候,這些對象在緩存中,並且都過了生命期,你就碰到了最佳情況。實際情況很少有這樣理想的,但這是你想要首先處理新對象的動力。政策引擎力圖保證這個過程高效。比如,如果我們發現第0代沒有垃圾,我們會說,“哎,也許我們不應該頻繁收集,因爲這次沒有找到東西,浪費了時間”。反過來,如果找到了很多垃圾,我們會說,“嘿,太好了,讓我們一會兒再來一次。”這是政策引擎力圖保證高效運轉的方法之一。
 
Charles:幾年之前,關於“確定性終止化”有過一次大辯論,我曾經和C++開發組的一個程序員聊過“確定性終止化”,託管C++現在也有某種“確定性終止化”。對嗎?畢竟C++中有析構函數。
 
Patrick:C++基本上處於混合世界中。如果對象被顯式地創建和銷燬,它們就不由垃圾收集器管理了,因此,它們需要“確定性終止化”。這些對象處於自己的世界中,即使將這些對象加上“__gc”前綴,試圖指出它們是託管對象,垃圾收集器也幫不上太多忙。關於這個問題,我曾經用了近6個月的時間試圖提供一個整合的解決方案。最後,我們花了些錢,請Chris Sells幫助我們解決了這個問題。他用的辦法非常聰明,然而,通過測量發現,在中等強度的對象分配過程中,效率上的損失至少爲2個基準點。所以,當垃圾收集器對應用程序作用很大的時候,你會付出效率上的損失。但是,在這點上我們不能強求程序員。我們的建議是:不要進行微管理,最終,通過這樣或那樣的方式,我們都會調用終止器,能夠解決問題。垃圾管理器從整個內存角度出發考慮問題,試圖使整個過程高效,而不只侷限在某個特定部分。
 
Charles:我明白了。某種意義上這是一個通用的管理平臺。但很有趣的是,既然這是通用平臺,我爲什麼不能在託管代碼中標記出某個對象說,我想要自己管理這個對象,我會告訴垃圾收集器這個對象何時生命結束,然後垃圾管理器才能收集它?你的意思是,垃圾管理器整體掃描,自行收集各個對象。
 
Patrick:是的。如果由你來告訴垃圾收集器,這並不安全,因爲你可能把對象傳給了程序,而你並不知道,這樣一來,你就可能引入讓程序崩潰的Bug。

                                                              NET垃圾收集器的過去、現在和未來(二)

譯者         程化
Charles:想問個問題,你爲什麼做垃圾收集器?這個工作哪點讓你覺得激動人心?你做垃圾收集器的歷史是怎樣的?
 
Patrick:對我來說,我一直都在做運行庫。很早以前我做LISP,在Schlumberger工作。他們用LISP建立一些很大的系統。我幫助他們從內部LISP工作站遷移到Deck工作站上,後者在當時運行標準LISP。做垃圾收集器的歷史來源於我在LISP上的工作經歷。然後,我在Austin,爲德州儀器的Explorer工作,這在當時是一個受歡迎的LISP工作站。德州儀器的工作涉及運行庫的各個方面,各種庫、解釋器、垃圾收集器,等等。然後我在Lucid工作,我們有一個供Sun工作站C++開發使用的IDE。爲了管理複雜的對象交互網絡,我們有一個內存中數據庫,專門記錄程序中各個元素的關係。比如,一個函數調用了其他五個函數,我們把這記錄下來,這樣我們就能根據少數函數的變化進行增量分析。如果你改變了一個函數,那依賴於這個函數的東西就必須重新編譯,我們能夠跟蹤這種情況。如果你向一個結構體中添加了一個成員,所有使用這個成員,所有知道這個成員長度,所有能夠接觸到這個成員的東西都必須重新編譯,我們也跟蹤這種情況。本質上這就是個跟蹤對象的大網絡。事實上,我們當時沒做一個垃圾收集器帶來了大問題,搞得自己很頭疼。有很多情況下我們擁有一個對象,刪除了它,但是不清楚影響如何,非常頭疼。在微軟,我開始時做VB運行時。VB運行時不進行收集,但是自動管理。它的自動管理靠的是自動插入AddRef和Release。這套機制工作得不錯,唯一的問題是AddRef和Release不可擴展。因爲Release必須是被鎖定的操作——我們要確保即使有兩個線程同時操作,引用計數也是正確的。這樣一來,AddRef和Release方式的自動內存管理就開銷巨大。我做過測量,大家看見了都說開銷不小,如果在多線程的環境下工作,這樣的開銷很要命,因爲我們在多線程下要做“InterLockedIncrement”和“InterLockedDecrement”,而不是普通的增加和減少引用計數。所以,當開始爲VBScript和Java寫運行時的時候,我們知道必須要做垃圾收集器了。對我自己而言,我非常喜歡這個工作,這可以使你的代碼運轉如飛,某種意義上垃圾收集器比程序優化更具備槓桿作用。如果你有一個好的優化器,將C++程序優化提高了5%的性能,你會說,“哇,太棒了,你知道嗎,程序快了5%!”垃圾收集器能使程序快30%,所以槓桿作用非常明顯。當我開始這項工作時,一個挑戰是服務器沒有好的垃圾收集器。那個時候,垃圾收集器擴展性不好。當時的挑戰是做出一個既可以透明擴展,又可以自動適應不同負載的垃圾收集器。對我來說,這項工作已經完成了。我們在做許多工作,舉個例子,Channel 9上有個問題說……
 
Charles:我們來看看這個問題,誰問的?
 
Patrick:有個問題問到延時。有個Channel 9的網友提到,垃圾收集器是影響託管代碼用於多媒體的原因之一。事實確實如此,垃圾收集器會造成內部暫停執行。說起來如果程序內部沒有工作,沒有執行用戶的代碼,那就是在進行垃圾收集。正如我前面說的,原因主要是堆棧,我們必須停止堆棧。我們內部使用一種“併發模式”。併發模式會在某些點上暫停程序,當然,暫停時間很短。然而,在許多情況下,我們會出問題,因此,程序不是暫停很短的時間,而是暫停較長的時間。這也是垃圾收集器目前在解決的問題之一。未來我們會引入一種新的併發收集方式,這是目前在做的很前沿的工作。最終,對於表現良好的程序來說,我們會將暫停時間控制在幾個毫秒。目前,找到何種要素能夠代表“表現良好的程序”也是一個挑戰。我可以寫一個只創建新對象而不使用它們的程序,因此,對象一出生就消亡了,這樣的程序的暫停時間遠遠低於毫秒級別。問題在於,一旦開始使用這些對象,一段時間後,它們就變得難以收集,因爲這些對象和別的仍應該生存的對象攪和在一起,你必須把它們區分開。最終的區分手段就是在併發模式下來一次完全的垃圾收集,然而這又導致應用程序關鍵工作較長的暫停。在這方面業界有許多研究工作,我們在自己的方向上進行得也不錯,未來數個版本就可以體現出來。
 
Charles:我覺得這個工作很困難。基本上你是說爲了收集某個執行中的進程,你必須暫停它,從而能夠訪問內存,清除垃圾。
 
Patrick:是的。我們模仿快照方式。如果你只需要暫停幾個毫秒,來幅邏輯快照,那就不需要暫停更長的時間。問題是,如果在短期內你沒有收集到任何東西的話,這個短期就可能累積起來。這也是沒有併發收集時目前已經發生的情況。第0代沒有收集,第2 代在檢查,應用程序一直在檢查。新垃圾在不斷產生,但是未被清除。內存使用不斷增長,某個時候,我們會說,停下,我們不能一直這樣,每個分配內存的線程都必須暫停,直到併發收集完成爲止。這就是我們正在解決的問題,目前正在開發中,我們甚至還不知道整體編譯是否能通過。願望是美好的,道路是漫長的。我們還有另一個頭疼的問題,當然也是另一個機會所在,那就是巨大的服務器內存空間。服務器如果需要進行完全的垃圾收集,該收集會分佈到機器所有的處理器上。到目前爲止,趨勢看起來一直都是,增加更多的內存,而非增加更多的處理器。隨着多核的到來,比如,每個芯片上有32個核心,這種趨勢可能反轉;但是,直到現在,在64位機器上增加32G或128G內存,要比增加32個核心容易多了。所以,結果就是平均每個核心要管比以前多得多的內存。在服務器上,這將引起比較嚴重的請求響應延時,看起來就是所有的請求處理都很快,然而時不時服務器會停止響應。當完全的垃圾收集發生時,響應會被阻塞,直到收集完成。有很多方法能減輕這種影響。如果有幾臺服務器,而且有一臺服務器做基於響應時間的負載平衡,則負載平衡服務器可以自動把請求從正在進行第2代垃圾收集的服務器轉到別的服務器上,當服務器可以響應之後,負載平衡服務器再把請求發送過來。所以,這也不是個致命的問題,然而,這個問題值得關注,我們對這個問題很感興趣,也在這個領域進行研究。垃圾收集器最美妙的一點就是,這是個前沿的技術,而且確實對人幫助很大。許多人都對我們在垃圾收集器上的工作給予了高度評價,聽起來確實讓人舒服。就自己而言,我們知道工作上還有不足;當然,我們也在努力做得更好。這不是件做了就扔的事情,這是件你一旦開始,就可以在上面工作許多年的事情。順帶說一句,現在我開發已經幹得不多了,我現在是架構師。曾經我編程非常多,現在編得很少了,我們有個新的開發人員,Maoni Steven,她有一個MSDN Blog - Maoni,非常有趣,講了很多東西,是個很好的垃圾收集器信息來源。
 
Charles:太棒了,我應該什麼時候去採訪她。你創建了第一個垃圾收集器,現在還參與得深入嗎?
 
Patrick:是的,我在架構未來的垃圾收集器。
 
Charles:太棒了。對垃圾收集來說,你認爲在未來會不會出現處於垃圾收集管理之下的運行時?那將與現在這種“人工收集的運行時”不同,現在還是程序員寫代碼進行管理。
 
Patrick:在服務器上已經是這樣了。微軟內部所有的服務器都在跑託管代碼。我們的MSNBC,某些部分是包給外部公司完成代碼的。他們被服務器內存碎片化問題深深困擾。服務器在開始的5分鐘跑得非常快,然而,每15分鐘就必須重啓一次,因爲內存碎片化太嚴重了。當他們改到ASP.NET上時,呃,ASP.NET執行相同的請求,所需要的指令比以前要多,因爲託管代碼效率方面有點缺陷,我們一直在努力消除這些低效率之處,然而,生成的代碼還是未能盡善盡美,比如,爲了類型安全,就不得不引入一些檢查之類的。但是,他們發現託管代碼前5分鐘跑得甚至更快,而且可以一直跑下去,不需要重啓。我相信,很明顯,在服務器上託管代碼更好,這有點像彙編代碼和編譯代碼的關係。在很小的領域裏,彙編代碼可以戰勝編譯代碼,你可以說,“瞧瞧,編譯器在這個地方笨死了,我可以寫得更好”但是,你不會用匯編代碼寫整個程序,如果你這樣做,你一定失敗,因爲要寫的東西太多了,而且你讓自己陷入了對整個程序的所有東西進行掌控的境地。我相信垃圾收集器也處於這種位置,我們有許多評測指出我們也處於這個位置。微觀優化某個局部方面,與優化整個程序非常不同。大家應該記得,垃圾收集器從整個應用程序的角度來優化,而不是隻顧及優化某幾個部分卻傷害了其他部分。
 
Charles:對特定的應用程序來說,比如你談到過的媒體應用程序,某些操作還是需要進一步優化。
 
Patrick:是的。比如,我們完全支持混合編程模式,你可以在代碼中執行非託管代碼,這樣就沒有延時了,因爲我們停止線程,檢查到執行的是非託管代碼時,垃圾收集器就立即停止。所以,如果渲染線程執行的是非託管代碼,或者是從託管代碼轉到非託管代碼,都不會有延時。WPF的架構就體現了這點。WPF在底層的渲染和上層的圖形對象模型之間有清晰的劃分。底層渲染由非託管代碼處理,沒有任何延時,所以那兒的動畫工作得很好;上層對象由託管代碼處理,調用非託管代碼完成渲染。這是個很好的劃分,工作得很棒。
 
Charles:很棒。我們看看Channel 9上有沒有其他問題?我們對Patrick Dussud相關問題進行線上即時搜索,看起來littlegulu網友有好多問題。
 
Patrick:好的,有個問題比較有趣。這裏大家有個概念錯誤。大家往往認爲調用垃圾收集器的collect接口時,垃圾收集器會決定是否進行收集。實際情況是,如果我們調用了垃圾收集器的collect接口,這是強制性的,垃圾收集器確實進行收集。實際上,如果進行的是併發收集,代碼會立即返回,也許這就是大家爲什麼會有誤解的原因,但是垃圾收集確實啓動了。有時候垃圾收集很快進行,但程序過一會兒才暫停,這是因爲我們在併發模式中,我們開始收集,然後返回。當你發出collect調用後,收集一定會發生。如果你收集的是第0代或第1代,這是非併發的,代碼在垃圾收集完成後才返回。通常收集耗時不到1毫秒,對第1代小於10毫秒,所以調用執行得非常快。但是,通常情況下,大家不應該顯式調用。原因是收集器引擎會觀察收集頻率,收集效率等等,如果發生了額外的調用,實際上會降低效率。比如,假設剛剛發生了一次自然的收集,程序馬上又進行顯式收集調用,這中間很可能只有少量垃圾對象,因爲大多數對象纔剛剛創建出來。這樣一來,垃圾收集器就會認爲,啊,這太不值得了,也許我們再下一次也不該收集。這樣一來,垃圾收集器努力保持的自然節奏就被打亂了。另一個避免顯式收集的原因是代價高昂。除非你掌握了整個應用程序的情況,否則很難判斷是否進行收集,很難判斷某個子程序在1秒鐘內是否被調用了100萬次,如果你沒有控制程序的所有方面,怎麼可能知道呢?所以,如果你是個類庫,做出判斷,從而進行顯式垃圾收集調用是很困難的。
 
Charles:我想問兩個問題,一個是當時你爲什麼要暴露公共的collect接口?第二個是當我調用collect時,垃圾收集器僅在我的執行環境中收集嗎?
 
Patrick:收集發生在所有的地址空間上。如果你的應用程序有多個域,所有的域都會被同時收集,所以,這是按進程進行的,涉及整個進程。我們爲什麼要暴露這個接口?這個問題很有趣,這其實是爲了某些資源管理問題。假設你有某種稀缺資源,比如數據庫連接,如果你需要數據庫連接自動消亡,那你就需要一個機制啓動垃圾收集器。所以我們提供了這個機制,用顯式代碼調用——GC.collect,讓垃圾收集器進行收集。我在Blog上還發現了另一種說法,大家相信,當發現垃圾收集器沒有跟上應用程序步伐的時候,就必須進行顯式調用。通常情況下這不太可能。垃圾收集器被內存分配觸發,假如你不斷分配,某次分配會觸發垃圾收集。所以,垃圾收集必須跟上應用程序的步伐,因爲垃圾收集提供程序進一步分配的內存。所以,如果你在分配內存,垃圾收集就不可能不啓動,最終垃圾收集會進行。看起來主要發生的是兩件事。第一,程序本身可能有泄漏,所以內存一直在增長,因爲某些靜態變量引用的是大對象,而這些對象一直在增長,比如,這是個鏈表或者類似的不斷增長的東西。這時候,即使你調用collect也回收不到什麼東西。另一個很隱祕的原因是COM的STA套間。COM的問題是,當我們調用到COM裏面時,COM用的是非託管內存。對於用戶來說,這是透明的,看起來我們並沒有調用到COM對象裏面,看起來就是個普通的CLR對象,因爲我們用代理使COM對象變得透明瞭。某些COM對象只能在創建它的線程上刪除。如果你的主線程正在忙於創建對象,這個線程就沒有時間在消息隊列上等待終止器線程的請求,“嘿,你應該殺掉這些對象,因爲它們是你創建的”。這些對象不會消失,逐步堆積,所以內存使用逐步增長。看起來就像是垃圾收集沒有跟上應用程序。實際情況是,垃圾收集積攢了若干終止器線程的請求,而終止器線程必須通過主線程工作,主線程又忙得沒有時間響應終止器線程。通常說來,此時不需要調用GC.collect,只要你在終止器上有內核對象的等待,或者分發了消息,問題就能解決。但是,等待終止器成本較高,要做的工作也不少。最好的解決辦法是不使用COM的STA套間,用MTA套間。但是,如果真有顯式調用垃圾收集器可以避免內存不斷增長的情況,我們很希望知道,因爲這是個bug,我需要知道這種情況,我們需要修改代碼。
 
Charles:這就帶來了另一個話題。你寫的是通用的垃圾收集維護平臺,這恰好基於無數潛在的有關聯的對象,對象可能是任何類型,它們之間的交互可能非常複雜。因此,你必須掌握正確地銷燬它們的時間,這非常有挑戰性。
 
Patrick:這正是我們花費了數年做的事情,這也是政策引擎的作用所在,它就是爲了判別我們應該啓動收集的各種情況,最小化內存使用,最大化程序效率。
 
Charles:我推測垃圾收集器在2000,或2001年就開始運行了?給我們講講你當時無法估計到的一些有趣的事吧。
 
Patrick:是的。通常,隨着時間過去,我們會發現某個應用程序或者消耗了過多內存,或者在垃圾收集時耗費了過多時間,我們力爭拿到這些程序,測量它,找到問題所在:是程序的行爲怪異?是程序寫法不對?比如,創建了一個上百兆的樹,刪除,然後不斷重複這個過程,此時程序的基本特點就是要花大量時間進行內存管理。是垃圾收集器本來可以做得更好一點,但被這樣那樣的情況矇蔽?舉例來說,我們花費了大量精力來處理一個問題,那就是當OS內存即將耗盡,內存負載很高時,我們希望能夠保持工作狀態良好。在這種極限內存情況下,我們力圖收集更多內存,對擁有的內存用得更節省,這項工作目前還在進行中,雖然不敢說完美,但比以前已經做得好多了,我們每天都在進步。
 
Charles:你提到過系統的內存越多,你的工作就越困難。
 
Patrick:是的,一個矛盾是OS的效率和所有程序的效率。當程序發生頁交換的時候,所有程序的效率都會下降,因爲頁交換影響所有人,而且沒有很好的指標可以告訴你具體是哪頁會被交換出去。如果我們能夠防止頁交換,犧牲一些CPU時間換取對頁交換的避免是值得的,而不是像現在這樣,在OS和虛擬機管理器層面上既付出延時,又付出CPU時間。我們在這上面花了很大功夫,我們實際上要求OS爲低內存情況提供通知。我們提出的請求得到了滿足,Windows2000實現了我們的請求。我們這樣使用通知:等待這個通知,一旦收到通知,我們就試圖切換到節省模式下。
 
Charles:好的,讓我們再看一個問題。我相信你應該要回到對未來的構架工作中去了。
 
Patrick:一個問題是,“針對性能敏感的應用來說,最佳實踐是什麼”。創建對象的開銷很低。我們可以按照內存帶寬的速度創建對象。開銷主要在對內存字節的處理上,我們必須清理這些字節,保證對象類型安全,保證內部乾淨,沒有多餘的數據。所以,創建對象是個很快的過程,但對象擁有字節的多寡會產生重大影響。一個最佳實踐是,分配你絕對需要的最少的內存。以前,因爲圓整到內存邊界分配能減少內存碎片,我們往往都會這樣做,“我將分配4字節,然後16字節,然後64字節,因爲它們大小正好,互相銜接,沒有碎片”。垃圾收集器的情況不是這樣。你爲分配的每個字節付出開銷。所以,分配你需要的最小數量。第二個最佳實踐,保證很容易消亡的對象回收成本低,回收過程效率高。如果你把這點發揮到極致,就意味着如果對象被創建在已經被緩存的區域,並且也在那裏消亡,內存被全部回收,那對象就一直在緩存中。正如我之前說過的,實際情況往往不是這樣,但你可以向這裏努力。本質上,分配對象時,如果你能保證除了絕對要使用的情況外,不更長時間地持有對象,就會產生好的性能。然而,你還會有長期數據,所以,如果你有在遊戲生命期間一直存活,或者近似一直存活的數據——比如,數據基本穩定,只是從遊戲的第一階段到第二階段發生變化,情況也不錯。因爲這些數據在第2代區域中,而沒有新東西到第2代,因此第2代區域沒有收集壓力。所以,如果你一方面分配一些非常穩定的東西,一方面分配不停產生,很快消亡的對象,你的情況就非常好——只有非常少的完全的垃圾收集發生,而衆多的第0代收集效率很高,你不會損失什麼。這是最好的情況。最壞的情況我們稱之爲“中年對象”。它們是足夠老到進入第2代,最終又要死的對象。例如,最壞的一種情況發生在你剛剛替換了某種緩存後。假設緩存每10分鐘替換一次,一些老元素被替換掉。這些老元素被保證處於第2代的託管堆中,因爲它們都足夠老,被升級到了那裏。然後,這些對象消亡了,你創建了新的對象來代替它們。這就不是一個好機制,因爲你在第2代區域引入了新對象,增加了這部分的收集壓力——這些重要的垃圾必須被收集,所以垃圾收集器將開始自己的工作,這就使性能變糟。
 
Charles:有趣。舉例來說,在服務器環境下,比如,網站環境,Channel 9下,有可能有的緩存你不想經常過期,然而,一旦過期,就非常影響性能。
 
Patrick:如果只是偶爾發生,問題不大,那些緩慢的死亡影響性能最大。
 
Charles:我想問的最後一個問題和Silverlight的到來有很大關係,我們現在有一個精簡版的CLR,裏面的垃圾收集器是怎樣的。
 
Patrick:Silverlight很棒的一點是,它從CLR借用了大量的東西,概念上基本沒有削減。我們有相同的代碼庫,只是不包括所有的文件。所以,其中的垃圾收集器只是工作站版本,沒有服務器版本。但是,Windows上既有併發收集也有非併發收集,Mac版本只有非併發收集,因爲Mac不提供實現高效併發收集所需要的一些服務。
 
Charles:這點很有趣,是不是說在其他的平臺上,OS快沒有內存時就無計可施了?因爲你在Mac平臺上得不到類似Windows上通知內存快要耗盡的服務。
 
Patrick:是的,這是我們無法得到的一個服務。當然,對於Silverlight來說,有沒有這個服務差別不是太大,因爲當託管堆小於16M的時候,併發收集一樣不能帶來太大的幫助。所以,對大多數的Silverlight應用來說,垃圾收集器足夠好了。
 
Charles:當然。是的,這是個很棒的垃圾收集器,謝謝你創建了它,我也很期待看到它如何演進,也許將來有一天,託管代碼會像你開始的時候說的那樣成爲可組合的。基於你現在做的這些東西,我們可以創建自己的應用,不用搞那些基礎的管道建設了。
 
Patrick:正是如此。我們相信.NET是非常成功的一個架構,人們會大量地使用它。講個小故事。我們最新的Exchange服務器,Exchange 12,其代碼絕大多數都是託管代碼,所有的新代碼都是託管代碼。存儲引擎沒有重寫,還是非託管的,但其餘的東西都是託管代碼了。Exchange組告訴我們的消息是,它們將要重寫所有的容器類,因爲當他們寫非託管代碼時,所有的非託管容器類都不能很好地工作,因爲組合性不夠好。他們試過了STL,MFC,所有這些都不能很好地工作,總有這樣那樣的小問題影響了使用,所以他們要重寫。但是,對於那些能夠工作的非託管代碼,他們都保留了,基本上底層沒有重寫太多,就是直接使用能夠順利工作的模塊,所以,這是我們的方法的一個很好的驗證。
 
Charles:絕對的。我應該去和Exchange組的人聊聊。謝謝你的時間,非常感謝,活兒乾得很棒,夥計!
 
Patrick:謝謝!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章