現代程式語言大多具備垃圾收集機制,若想確切地知道運作方式,可從實作C++智慧指標(smart pointer)開始著手,這也有助於理解各種垃圾收集演算法的優缺點,以及必要時能夠選擇適當的垃圾收集器。

以堆疊來包裹堆積

想理解程式語言的垃圾收集機制,往往就會涉及堆疊(stack)、堆積(heap)的說明,大部份文件會以資源是否動態配置,來區別這兩個存放位置,但實際的差異是在於資源生命週期的不同。

因為,函式執行結束時,堆疊必須為空,因此存放在堆疊的資源,我們可以理解為,其生命週期局限於函式執行期間,然而,有些資源無法預期其使用的生命週期,這類資源會存放在堆積,正因為位於堆積的資源無法預期生命週期,才必須在不使用的時候自行清除。

問題就在於「無法預期」。因為程式運行後,資源之間的互用關係錯綜複雜,開發者也很難掌握哪些資源還在使用,而哪些是已經沒人要的垃圾,這時,就會希望有個類似生活中資源回收業者之類的程式,可以代為判斷是否為垃圾,並予以清除。

但C++沒有垃圾收集機制,該怎麼辦呢?實際上,有些開發者自以為無法預期生命週期的資源,原因就只是懶得想罷了。

例如,函式中若需要動態配置連續空間來當成陣列使用,在函式結束前,我們就要用delete[]刪除,只是有時會忘了這件事,此時,我們如果可以設計一個會在函式結束前刪除資源的類別,就不會因為忽略而造成資源沒有回收。

什麼樣的資源會自動在函式執行後結束生命週期呢?就是哪些放在堆疊的資源,如果可以用堆疊的資源,也就是函式的區域物件來包裹堆積的資源,一旦堆疊資源結束生命週期時,就去刪除堆積資源,這件事就解決了,而C++可以透過定義包裹器的解構器(destructor)來做這件事。

如果包裹器各自只管理一份資源,就沒有其他問題了,然而在現實程式中是不可能的。因為,一份資源可能被指定給多個物件,這時包裹器該如何共享資源,就決定了包裹器的複製建構式、指定運算子與解構式怎麼寫了。

C++智慧指標

在C++ 98有auto_ptr類別,雖然現在已經廢棄,不過實作上最簡單(可參考〈auto_ptr〉)。

auto_ptr被用來建構另一auto_ptr實例時,來源實例包裹的資源會被接管,將auto_ptr指定給另一auto_ptr時,目標實例包裹的資源會被刪除,並接管來源實例的資源,因此,在解構式中,我們只要檢查資源是不是nullptr,若結果為否,就delete資源。

事實上,auto_ptr被廢棄的原因在於,接管資源的動作是隱含的,開發者容易忽略了資源被接管,而持續使用沒有包裹任何資源的auto_ptr。另一個問題是,它無法管理動態配置的連續空間,因為不會使用delete[]來刪除。

因此解決問題的方式之一就是,不允許複製建構與複製指定,如果真的要轉移資源,必須透過的明確語義的方法來釋放、重置資源。而對於第二個問題的因應,我們可以讓使用者指定刪除器,自行決定怎麼刪除資源,這個概念在C++ 11中的實作品是unique_ptr(想看看實作原理可參考〈unique_ptr〉),因為轉移資源意謂著資源是不能共享的,一旦unique_ptr呼叫了release,在還沒有用reset重置管理的資源前,是不能透過unique_ptr來試圖操作資源。

不過,現實程式中有更多情況會需要共享資源,而一旦資源被共享,情況就會變得複雜許多。因此,最基本的考量就是:還有誰使用著被管理的資源呢?

在C++ 11當中,有shared_ptr使用參考計數,如果資源被複製建構或指定,參考計數增一,若某個auto_ptr解構時,參考計數就減一;如果auto_ptr解構時,發現參考計數為零,就表示沒有其他地方使用這份資源了,此時,可以直接刪除(想看看實作原理可參考〈shared_ptr〉)。

unique_ptr、shared_ptr被稱為智慧指標,然而,從實作上就可以知道,它們並不是指標,而是行為上具有指標行為的指標包裹器。具體而言,它們就是重載了*、->運算子的類別,對於沒有重載對應運算子的指標行為,例如+、++、-、--等,就不會支援,若真要進行這類操作,必須透過get取得管理的指標直接操作。

垃圾收集器

shared_ptr的好處是,資源在不使用的時候就會被刪除。然而,若資源的刪除也有其成本,以及開發者希望資源的刪除不是參考計數為零時,而是在某個時間點,例如,使用者沒在操作應用程式時再來檢查與清除。

那麼,該如何處理這種情況?我們可以設計一個資源清單(list),專門用來記錄資源的參考計數,當資源的包裹器被複製建構或指定,而需要增加參考計數時,我們可透過使用資源的記憶位址來查找資源清單,處理對應的參考計數。

當資源的包裹器解構時,清單中對應的參考計數會減一,若參考計數為零,並不會馬上刪除資源,而是在某個時間點,呼叫collect之類的方法。基於這樣的做法,我們會逐一取得資源清單中的參考計數,若某個資源的參考計數為零,從清單中移除並刪除該資源,這就是垃圾收集的相關文件中,所談到的基本垃圾收集器原理。在《The Art of C++》第二章,有個簡單的垃圾收集器,就是基於參考計數的實作,有興趣可以參考。

然而,shared_ptr有個問題,在shared_ptr實例間不小心形成環狀下,會因為參考計數計算錯誤,而令資源明明不再使用,參考計數卻不為零而未被刪除。想解決這個問題的方式之一,是透過C++ 11提供的weak_ptr──當shared_ptr實例用來建構weak_ptr實例,或指定給weak_ptr時,動態配置資源的參考計數並不會增加,因此,形成環狀也就不會造成參考計數的錯誤(可參考〈weak_ptr〉。

至於垃圾收集器這邊,就有其他的方案了,像是標記/清除、節點複製、分代收集等,現代程式語言可能結合了多個方案,例如,Python使用了參考計數,並輔以標記/清除、分代搜尋等,如果確定資源不會形成環狀,還可以透過gc模組的disable停用垃圾收集器。

思考資源的使用方式

若開發者使用C++,有興趣的話,可以自行實作shared_ptr等智慧指標,對於資源如何釋放這件事,就會有更多的認識,在運用到動態配置資源時,也就會有多個機會思考,設想這些資源的生命週期與使用情境,從而決定該選用哪個智慧指標。而這就像在生活當中,將一切棄置用品訴諸資源回收業者前,消費者自身還是要作好基本的分類。

試著自行實作個簡單的垃圾收集器,也是個有趣的挑戰,這有助於我們理解標記/清除、節點複製、分代收集等方案的優缺點。有些語言允許開發者操作或選擇垃圾收集器,這時,對於相關演算的認識就很重要了,重要的是回頭思考資源的使用方式,而不是將一切都交給垃圾收集器就沒事了。

專欄作者

熱門新聞

Advertisement