注:這篇文章基於我在布達佩斯的RuPy大會上所作的演講。我覺得與其直接將幻燈片釋出出來,不如在我還有印象的時候將它寫成部落格來的更有意義。同樣,我會在將來發布RuPy大會的視訊連結。我計劃將在RubyConf大會上發表類似的演講,除了有關於Python的部分,並且將對比MRI\、JRuby和Rubinius的垃圾回收器是怎樣工作的。
如果想要對Ruby垃圾回收器以及內部原理有更加深入的瞭解,你可以在我即將出版的新書《Ruby Under a Microscope》中找到答案。
在”Ruby Python”大會上,我想對比Ruby和Python內部的垃圾回收機制是一件很有意思的事情。在開始之前,我們為什麼要討論垃圾回收機制呢?畢竟這是一個最迷人的,最令人激動的主題,不是嗎?你們有多少人對垃圾回收機制感到興奮?(許多大會參與者竟然舉起了雙手!)
最近,在Ruby社群中有一篇帖子,關於怎樣通過修改Ruby GC的設定來提高單元測試的速度。這棒極了!通過減少GC垃圾回收的處理來提高測試的速度,這是一件好事,但是不怎的,GC不會真正的讓我感到興奮。就如咋一看就感覺令人厭煩,枯燥的技術帖子。
事實上,垃圾回收是一個令人著迷的主題:垃圾回收演算法不僅是電腦科學歷史一個重要的部分,更是前沿研究的一個主題。例如,MRI Ruby直譯器使用的”Mark Sweep”演算法已經超過了50年的歷史,與此同時,在Rubinius直譯器中使用的一種垃圾回收演算法,是在Ruby中的另一種實現方式,這種演算法僅僅是在2008才被研究出來。
然而,”垃圾回收”的這個名稱非常不恰當。
應用程式的心臟
垃圾回收系統要做的不僅僅是”回收垃圾”。事實上,它主要完成三個重要任務:
- 為新的物件分配記憶體
- 標記垃圾物件
- 回收垃圾物件佔用的記憶體
想象你的應用程式是一個人的身體:所有你寫的優雅程式碼,業務邏輯,演算法,將會成為你的應用程式的大腦或智慧。與此類似的,你認為垃圾回收器會成為身體的哪一個部分呢?(我從大會的聽眾中得到了很多有趣的答案:腎,白細胞)
我認為垃圾回收器是一個應用的心臟。正如心臟為身體的其他部分提供血液和養料一樣,垃圾回收器提供記憶體和物件供程式使用。如果你的心臟停跳,你將活不了幾秒。如果垃圾回收器停止執行或者變慢,就像動脈阻塞一樣,你的程式將變的慢下來,最後死掉!
一個簡單的例子
通過例子來驗證理論是一種很好的方式。這裡有一個簡單的類,用Python和Ruby寫成,我們可以將它們作為一個簡單的例子:
於此同時,兩種程式碼如此相似,讓我感到非常吃驚:Python和Ruby在表達相同的語義時幾乎沒有差別。但是,兩種語言的內部實現方式是否相同呢?
空閒物件連結串列
在上面的程式碼中,當我們呼叫了Node.new(1)之後,ruby將會做什麼?也就是說,Ruby怎樣建立一個新的物件?
令人驚訝的是,Ruby做的事情非常少!事實上,在程式碼執行之前,Ruby直譯器會提前建立成千上萬的物件放置到一個連結串列中,這個連結串列被稱為”空閒物件連結串列”(free list)。空閒物件連結串列(free list
)在概念上看起來像下面的樣子:
每一個白色方塊可以想象成一個預建立的,沒有使用的Ruby物件。當我們呼叫Node.new,Ruby簡單的使用一個物件,並且將它的引用返回給我們:
在上圖中,左邊的灰色方塊代表一個活躍的Ruby物件,已被使用,而其餘的白色方塊程式碼沒有使用的物件。(注意:當然,圖中是一種簡化的實現版本。事實上,Ruby將會使用另外一個物件儲存字串”ABC”,使用第三個物件儲存Node的定義,以及其他的物件儲存程式碼處理過的抽象語法數”AST”。)
如果我們再次呼叫Node.new,Ruby僅僅返回另外一個物件的引用。
這種使用預建立物件連結串列的簡單演算法發明於50多年前,它的作者是傳說中的電腦科學家,約翰·麥卡錫,正是他實現了最初的Lisp直譯器。Lisp不僅是第一個函數語言程式設計語言,並且包含了電腦科學中許多突破性的進展。其中之一便是通過垃圾回收機制自動管理記憶體。
標準版Ruby,也就是”Matz’s Ruby Interpreter”(MRI),使用了一種類似於約翰麥卡錫在1960年實現的Lisp的垃圾回收演算法。就像Lisp一樣,Ruby會預先建立物件並且在你建立物件或值的時候返回物件的引用。
在Python中分配物件記憶體
從上面我們可以看出,Ruby會預先建立物件,並且儲存在空閒物件連結串列(free list)中。那麼Python呢?
當然Python內部也會由於各種原因使用空閒物件連結串列(它使用連結串列迴圈確定物件),Python為物件和值分配記憶體的方式常常不同於Ruby。
假設我們建立一個Node物件使用Python:
Python不同於Ruby,當你建立物件的時候,Python會立即向作業系統申請分配記憶體。(Python 事實上實現了自己的記憶體分配系統,它在作業系統記憶體堆上提供了另外一層抽象,但是今天沒有時間深入探討。 )
當我們建立第二個物件時,Python將再次向作業系統申請更多的記憶體:
看起來相當簡單,當我們建立Python物件的時候,將花費時間申請記憶體。
Ruby開發者生活在一個髒亂的房間
回到Ruby,由於我們分配越來越多的物件,Ruby將繼續為我們從空閒物件連結串列(free list)獲取預分配物件。因此,空閒物件連結串列將變得越來越短:
或者更短:
請注意,我將一個新的值賦給了n1,Ruby會遺留下舊的值。”ABC”, “JKL”和”MNO”等結點物件會依然保留在記憶體中。Ruby不會立即清理舊的物件,儘管程式不再使用!作為一名Ruby開發者就像生活在一個髒亂的房間,衣服隨意扔在地板上,廚房的水槽中堆滿了髒盤子。作為一個Ruby開發者,你必須在一大堆垃圾物件中工作。
Python開發者生活在一所整潔的房子
垃圾回收機制在Python和Ruby中迥然不同,讓我們回到前面三個Python中Node物件的例子:
從內部來看,每當我們新建一個物件,Python將在物件對應的C語言結構中儲存一個數字,叫做引用計數(reference count)。最初,Python將它的值設為1。
值為1表明每個物件有一個指標或引用指向它。假設我們建立一個新的物件,JKL:
正如前面所說,Python將”JKL”的引用設定為1。同樣注意到我們改變n1指向了”JKL”,不再引用”ABC”,同時將”ABC”的引用計數減少為0。
通過這一點,Python垃圾回收器將會立即執行!無論何時,只要一個物件的引用計數變為0,python將立即釋放這個物件,並且將它的記憶體返回給作業系統。
上圖中,Python將回收”ABC”物件的記憶體。記住,Ruby只是將舊的物件遺留在那裡,並且不去釋放它們佔用的記憶體。
這種垃圾回收演算法被稱為”引用計數”,由喬治柯林斯發明於1960年。非常巧合的是在同一年約翰麥卡錫大叔發明了”空閒物件連結串列演算法”。正如Mike Bernstein在Ruby Conference大會上所說”1960年是屬於垃圾回收器的…”。
作為一個Python開發者,就像生活在一個整潔的房間中。你知道,你的室友有些潔癖,他會把你使用過的任何東西都清洗一遍。你把髒盤子,髒杯子一放到水槽中他就會清洗。
現在看另外一個例子,假設我們讓n2和n1指向同樣的結點:
上圖左邊可以看到,Python減少了”DEF”的引用計數並且立即回收了”DEF”物件。同時可以看到,由於n1和n2同時指了”JKL”物件,所以它的引用計數變為了2。
標記回收演算法
最終髒亂的房間將堆滿垃圾,生活不能總是如此。Ruby程式在執行一段時間之後,空閒物件連結串列最終將被用盡。
上圖中所有的預分配物件都被用盡(方塊全部變成了灰色),連結串列上沒有物件可用(沒有剩餘的白色方塊)。
此時,Ruby使用了一種由約翰麥卡錫發明的被稱為”標記回收”的演算法。首先,Ruby將停止程式的執行,Ruby使用了”停止這個世界,然後回收垃圾”的方式。然後,Ruby會掃描所有的指向物件和值的指標或引用。同樣,Ruby也會迭代虛擬機器內部使用的指標。它會標記每一個指標所能到達的物件。在下圖中,我使用了”M”指出了這些標記:
上面三個”M”標記的物件為活躍物件,依然被我們的程式使用。在Ruby直譯器內部,通常使用”free bitmap”的資料結構來儲存一個物件是否被標記:
Ruby將”free bitmap”儲存在一個獨立的記憶體區域,以便可以更好的利用Unix的”copy-on-write”特性。更詳細的資訊,請參考我的另一篇文章《為什麼Ruby2.0的垃圾回收器讓我們如此興奮》。
如果活躍物件被標記了,那麼其餘的便是垃圾物件,意味著它們不再會被程式碼使用。在下圖中,我使用白色的方塊表示垃圾物件:
接下來,Ruby將清理沒有使用的,垃圾物件,將它們鏈入空閒物件連結串列(free list):
在直譯器內部,這個過程非常迅速,Ruby並不會真正的將物件從一個地方拷貝到另一個地方。相反的,Ruby會將垃圾物件組成一個新的連結串列,並且鏈入空閒物件連結串列(free list)。
現在,當我們要建立一個新的Ruby物件的時候,Ruby將為我們返回收集的垃圾物件。在Ruby中,物件是可以重生的,享受著多次的生命!
標記回收演算法 vs. 引用計數演算法
乍一看,Python的垃圾回收演算法對於Ruby來說是相當讓人感到驚訝的:既然可以生活在一個整潔乾淨的房間,為什麼要生活在一個髒亂的房間呢?為什麼Ruby週期性的強制停止執行程式,去清理垃圾,而不使用Python的演算法呢?
然而,引用計數實現起來不會像它看起來那樣簡單。這裡有一些許多語言不願像Python一樣使用引用計數演算法的原因:
- 首先,實現起來很困難。Python必須為每一個物件留有一定的空間來儲存引用計數。這會導致一些細微的記憶體開銷。但更遭的是,一個簡單的操作例如改變一個變數或引用將導致複雜的操作,由於Python需要增加一個物件的計數,減少另一個物件的計數,有可能釋放一個物件。
- 其次,它會減慢速度。儘管Python在程式執行過程中垃圾回收的過程非常順暢(當你把髒盤子放到水槽後,它立馬清洗乾淨),但是執行的並不十分迅速。Python總是在更新引用計數。並且當你停止使用一個巨大的資料結構時,例如一個包含了大量元素的序列,Python必須一次釋放許多物件。減少引用計數可能是一個複雜的,遞迴的過程。
- 最後,它並不總是工作的很好。在我演講的下一部分,也就是下一篇帖子中能看到,引用計數不能處理迴圈引用資料結構,它包含迴圈引用。
下一次…
下週我將釋出演講的其他部分。我將討論Python怎樣處理迴圈引用資料結構,以及在即將到來的Ruby2.1中,垃圾回收器是怎樣工作的。