1. 程式人生 > >Garbage Collection | 引用計數的改善考察(二)

Garbage Collection | 引用計數的改善考察(二)

3 計數域大小受限得引用計數

續前文,引用計數技術需要再每個單元中保留一定空間以存放引用計數值。理論上,再最糟糕得情況下,這個域必須達到足夠存放堆中節點核根所儲存得指標的總數,換句話說,這個域必須核指標一樣大。然而,若是說所有的應用裡計數值都會增長到這麼大,那未免泰國不可思議了。因此,我們可以使用較小的引用計數域來節省空間,代價則識必須小心地處理溢位問題。

3.1 “粘住的”計數值

對於某個單元,引用計數所帶來的開銷反比於這個單元的大小。如果採用指標大小的域(以避開溢位檢查),那麼對於List語言的cons單元而言開銷是50%;如果僅僅採用一個二進位制位,那麼開銷是12.5%。較小的引用計數域可能會溢位,從而打破了“對於任意節點N,RC(N)與指向它的指標數量相等”這一引用計數不變式。兩個問題隨之浮現。

第一,計數值不得超過系統所允許的最大值。第二,一旦引用計數值到達了這個最大值,它就會被“粘住”:它再也不能減少了,這是因為指向該物件實際指標數量可能大於它的引用計數值,我們把這個最大值稱為“sticky”。

//增大減小被“粘住”引用計數值
incrementRC(N) =
     if RC(N) < sticky
         RC(N) = RC(N)+ 1

decrementRC(N) =
     if RC(N) < sticky
         RC(N) = RC(N) - 1

3.2 追蹤式收集恢復計數值

計數值會“粘住”這一事實,意味者一旦某個物件的引用計數值達到最大值,它就無法回收了,這使因為僅僅依靠引用計數技術已經無法將它的計數值降為0.我們必須採用一個後備的追蹤式垃圾收集器來恢復真是的引用計數值。這個收集器開始工作時,先堆整個堆進行一次附加的清掃,把所有單元的計數值設定為0,接著,收集器遍歷所有的存活資料構成的圖,每訪問一個物件,mark過程就會增大它的引用計數值(不超過最大值)。標記階段結束後,堆中每個物件的引用計數值會恢復到它的真實值,或是sticky (在溢位的情況下)。採用後備的追蹤式收集器並非額外的負擔,因為很可能無論如何最後都 需要它來收集環形垃圾。為簡單起見,這裡mark採用遞迴實現。

//後備的追蹤式垃圾收集器,用來恢復“粘住的”引用計數值
mark_sweep() =
    for N in Heap
        RC(N) = 0
    for R in Roots
        mark(R)
    sweep()
    if free_pool is empty
       abort "Memory exhausted"

mark(N) =
    incrementRC(N)
    if RC(N) == 1
       for M in Children(N)
           mark(*M)

3.3 僅有一位的計數值

Wise和其他一些人提出了更加激進的建議:將引用計數域限制在一個二進位制位[Wise and Friedman, 1977; Stoye ef d 1984; Chikayama and Kimura, 1987; Wise, 1993]。於是,這個引用計數位只是簡單地標明一個單元是共享的(計數值為sticky)還是獨享的。對Lisp和其他語言的經驗的研究表明,絕大多數單元不會被共享,因此可以在指向它們的指標被刪除時立刻回收[Clark and Green, 1977; Stoye ef at, 1984; Hand, 1988]。因此,Wise 認為引用計數技 術的努力應該集中在那些未被共享的物件上。一位引用計數的目標就是儘可能地推遲垃圾收集(以及隨之而來的停頓),並且減小標記一清掃垃圾收集的空間開銷。引用計數的存在也為像避免複製或就地更新之類的優化提供了機會。如果我們需要某個物件的一個經過修改的副本,並且我們知道它(將會)不再有其他引用,那麼我們可以直接“借用”指向它的指標並直接修改這一物件,以此來完成“複製”,而不必先複製原物件再釋放原物件。舉例而言,對於那些操縱巨大陣列的程 序,避免複製所帶來的好處是顯而易見的。

一位引用計數最簡單的實現方案是將這個二進位制位儲存在各個單元裡[Wise and Friedman, 1977],但是更好的做法則是將它位儲存在各個指標裡[Stoy et al, 1984],就像用於型別檢查的執行時標籤一樣[Steenkiste and Hennessy, 1987]。第一個指向新建立物件的指標被標為unique。在Update過程複製指標時,系統首先將目標指標標為sticky,然後檢査源指標的計數域,若為unique,則也將其改為sticky。請注意,引用計數無法讓標為sticky的指標恢復成unique 的,只有追蹤式收集器才能讓指標恢復unique標誌並回收共享的單元。不過正如我們之前曾經 提到的,為了回收環形結構的垃圾,很可能無論如何我們都需要後備的追蹤式收集器。

//採用標記指標實現的一位引用計數
Update(R,S) =
    T = sticky(*S)
    if RC(*S) == unique
         *S = T
    delete(*R)
    *R = T

Stoye等人的方案的優點在於能夠直接確定和修改單元的狀態(獨享還是共享),不需要訪問單元本身,這減小了cache失誤和換頁的可能。即使是對於一級cache,一個失誤也會帶來5個時鐘週期的懲罰;而一個缺頁錯誤則要花費無以計數的時鐘週期。所 以,為此付出多幾條指令的代價是非常值得的。

3.4 恢復獨享資訊

一旦共享了某個單元,它的計數值就“粘住”了——引用計數機制無法將計數值恢復成unique。那些被標為sticky的節點無法在刪除指向它們的最後一個指標時回收,而是必須等待下一次垃圾收集。如果引用計數位儲存在節點內,那麼這1位可以和標記一清掃收集器的標記位共享,讓sticky表示“已標記”。標記階段結束後,所有的存活單元都將被標為sticky。不幸地是,標記過程並不會區分共享的單元和獨享的單元。但是儘管獨享資訊已經丟失,Friedman和Wise認為,倘若收集器在回收空間方面比較成功,那麼在下一次收集之前還是有充分的機會讓一位引用計數機制重新執行起來。

如果我們採用兩次遍歷的縮並器(two-pass compacting compactor),那麼就能恢復引用的獨享資訊。標記一縮並收集器通常在標記階段之後進一步完成兩個階段的工作:首先把存活單元縮併到堆的底部,然後用新地址更新指向這些單元的引用。在後一階段,縮並器能夠確定是否存在多個引用指向同一單元。

Wise還曾經採用一個基於半區的節點複製收集器來恢復那些曾經是、但已不再是粘住的指標的unique標籤[Wise, 1993]。合適的節點複製收集器必須維護這樣一個不變式:對於Tospace中任意一個單元N,遷移地址和所有指向N的指標的標記相同,而且當且僅當存在超過一個指標指向N時,這些標籤才是sticky(遷移地址不算在內)。Wise的演算法要求每個單元大到足夠容納source和forward兩個指標,而不是一個。source指標指向Tospace中原來指向這個單元的引用,forward指標則是和通常一樣當作遷移地址來使用。在第一次設定遷移地址時,forward指標被標記為unique並作為copy過程的結果返回,以確保原先Tospace中的指標也是unique的。如果再次訪問到Fromspace中的這個單元,forward指標變為sticky。此時,原先Tospace中的指標也必須變為sticky:這個指標可以通過source指標找到。

3.5 “Ought to be two” 緩衝區

許多對引用計數值的調整隻是暫時的。考慮這樣一個陚值動作,N=select(N),其中N的引用計數值為unique而select則是一個投影函式,返回一個當前由N獨享的域。這類投影函式的一個典型例子就是取出一個表的表尾。問題在於單元select(N)的引 用計數值必須在提領N之前提升為sticky(否則這個單元會在讀取它的域之前被回收),這樣一來獨享資訊就丟失了。Friedman和Wise採用一個軟體cache來保留獨享資訊,在cache裡的節點,雖然它們的真實引用計數值為2,但是計數域RC仍然置為unique——它“ought to be two” [Wise and Friedman, 1977]。

每當某個指標被複制時,若它獨享一個節點,系統就會把這個節點插入cache;若這個 節點己經在cache裡(一個命中),那麼就把它移出cache並標為共享。如果cache溢位,系統會在cache裡任選一個節點(例如,最近最少使用的節點),將它逐出cache並且將它的計數值置為sticky。每當某個指標被刪除時,如果它在cache內,系統會把它移出cache,也就是說,它的計數值從“ought to be 2”變為unique。如果單元的計數值是unique而且不在cache內,那麼它會被遞迴地釋放。

//“ought to be 2”緩衝區
hit(N) =
    if N in cache
       remove N from cache
       return true
    else return false

insert(N) =
    if hit(N)
       RC(N) = sticky
    else put N in cache

delete(N) =
    if not hit(N)
        if RC(N) == unique
           for M in Children(N)
               delete(*M)
           free(N)
Update(R,S) =
    if RC(S) == unique
       insert(S)
    delete(*R)
    *R = S

這個策略只有在cache速度很快的情況下才能成功。Friedman和Wise建議劃出少量暫存器專門用於這個cache。花費一個暫存器就足以在出現形如N=select(N)的賦值時避免增大計數值,而這類斌值在實用程式碼的編譯結果中是十分常見的(例如遍歷一個表)。在這種情況下,陚值操作的開銷通常是在標準引用計數環境下的兩倍。兩個暫存器足以應付形如r=f(s);s=g(t);t=h(r);的程式碼序列(例如,交換r和s的值的程式碼)。然而,採用這種策略除了cache的管理開銷之外,還會增大編譯器在分配暫存器時的壓力。如果這導致了本來不會發生的暫存器溢位,那麼真正的開銷還會更大。

4 硬體引用計數

儘管有了這些優化,人們依然普遍認為引用計數的執行時間大於那些基於追蹤的技術。為了能夠在不向使用者程式徵收高昂的“效率稅”的前提下獲取引用計數帶來的好處,硬體的支援是必要的。Wise和其他一些人設計和製造了基於引用計數的“自管理”的堆儲存器[Wise, 1985; Wise et at., 1994; Gehringer and Chang, 1993; Change and Gehringer, 1993a; Chang and Gehringer, 1993b]。“主動的”儲存器背離了將處理能力(CPU)和儲存器分開的傳統的馮•諾依曼體系。在Wise的設計方案裡,所有維護引用計數的簿記工作都被移交給引用計數儲存器(Reference Counting Memory),簡稱RCM。這樣處理器就可以解脫出來去完成“有用的”工作。除了把處理器從管理堆的負擔中解放出來之外,讓儲存器本身完成引用計數這一方案還給多處理機系統帶來一個很大的好處:客戶程式和追蹤式垃圾收集器之間不再需要同步,也不再需要在引用計數值上加鎖。

專用的體系結構缺乏在商業上成功的先例。開發的成本讓它們過於昂貴。“主動的”儲存器比起其他更加瀲進的設計有一個優勢:如果處理器可以簡單地把“自管理”的堆視作另一組儲存器,那麼也許就能讓各種不同的常規體系結構分享它帶來的好處、分攤開發的費用。

在Wise的設計裡,每一個引用計數儲存器組中都包括資料儲存器和引用計數儲存器,它們關聯在同一個地址上。每一對這樣的儲存器都擁有自己的匯流排和埠:一個數據埠連線到處理器,一個較窄的埠連線到其他RCM。後者執行的速度是資料埠的兩倍,這是因為一個數據寫可以產生兩個引用計數操作(一個增大,一個減小)。每一組RCM維護它自己的可用空間列表。為了獲取一個新節點,處理器需要根據所需節點的型別,讀取苦幹不同的記憶體位置中的一個。此外,Wise的方案中還提供了一個標記一清掃垃圾收集模式,並採用能夠在常數數量的空間內執行的Deutsch-Schorr-Waite收集器。

對RCM系統的最初測試顯示,儘管總體效能依賴於問題的規模,它在提高效率方面仍然具有非常大的潛力。引用計數的執行不會給使用者程式帶來開銷,而且RCM內建的標記一清掃收集器的執行速度,是純軟體實現的、採用“停止並複製”模式的收集器的兩倍。然而,系統的原型是作為一個“裝置”(device)接入NextBus的,因此無法得到cache的支援。與普通的、有cache支援的RAM相比,缺乏cache支援浪費了大約40%的使用者執行時間。儘管如此,如果給定一個足夠大的問題,那麼對於採用RCM和採用有cache支援的市售記憶體加上“停止並複製”的垃圾收集器這兩種方案來說,前者的執行時間介於後者的40%到70%之間。

Gehringer和Change建議使用一個協處理器作為L2 cache。協處理器通過引用計數管理它的儲存器,目的則是希望所有對計數值的操作都能在它的cache中完成。對他們設計的模擬顯示,協處理器可以在物件老化離開cache之前刪除50%到70%的物件,節省57%到72%的匯流排寫流量,以及53%到63%的匯流排讀流量[Chang and Gehringer, 1993a; Chang and Gehringer, 1993b]。基於追蹤的垃圾收集仍然是必要的(例如,為了回收環形結構),但是採用協處理器執行引用計數把兩次收集之間的間隔延長了大約60%。

to be continued....