Swift 4 弱引用實現

SwiftGG翻譯組發表於2018-08-02

原文連結:swift.gg/2018/08/02/…
作者:Mike Ash
譯者:BigNerdCoding
校對:Yousanflics,numbbbbb,Cee
定稿:CMB

Swift 開源不久我就寫了篇關於弱引用實現的文章。時移勢易,Swift 4 中的弱引用實現已經與舊文不一致了。應 Guillaume Lessard 建議,今天我將會介紹新版實現,並比較其與老版的區別。

舊實現

考慮到有些人可能已經忘記了舊實現並且不願重看前面的文章,下面我們就一起簡要的回顧下之前的實現方式。

在舊實現中,Swift 物件有兩個引用計數:強引用計數和弱引用計數。當強引用計數為 0 而弱引用計數不為 0 時,物件會被銷燬,但是記憶體並不會被立即釋放。記憶體中會保留弱引用指向的殭屍物件。

在載入弱引用時,執行時會對引用物件進行檢查。如果是殭屍物件,則會對弱引用計數進行遞減操作。一旦弱引用計數為 0,物件記憶體將會被釋放。換句話說,殭屍物件的所有弱引用被載入訪問後殭屍物件才會真正被清空。

雖然我喜歡該實現的簡單性,但它有一些缺陷。其中一個就是,殭屍物件可能會長時間停留在記憶體中。對於那些擁有很多例項的類(因為它們包含許多屬性,或使用類似 ManagedBuffer 分配了內聯的額外記憶體),這會造成嚴重的記憶體浪費。

另外,在寫完舊文後我還發現:對於併發讀取,該實現是非執行緒安全的。雖然已經有補丁修復了這個問題,但從相關討論可以看出,開發者希望找到一個更好的實現方式,避免出現類似問題。

物件資料

Swift 中的 “物件” 其實是由一組資料構成。

首先,最容易想到的就是原始碼中宣告的那些可直接訪問的儲存屬性。

其次就是物件的類資訊。該資訊主要被用於動態派發和 type(of: ) 內建函式。雖然動態派發和 type(of: ) 內建函式從側面暗示了它的存在,但是實際上該資訊大多是被隱藏的。

第三種就是各種引用計數資訊。除非你進行一些非常規操作,例如,讀取物件的原始記憶體或說服編譯器讓你呼叫 CFGetRetainCount,否則這些資訊對你來說是完全透明不可見的。

第四種就是 Objective-C 執行時儲存的輔助資訊,例如 Objective-C 弱引用列表(Objective-C 的弱引用實現是通過單獨追蹤每個弱引用)和關聯物件。

那麼這些資訊最終都儲存在哪裡呢?

在 Objective-C 中,類資訊和儲存屬性(例如,例項變數)內聯在物件記憶體中。其中類資訊位於指標所在第一塊記憶體,其後才是例項變數。輔助類資訊則儲存在外部表中。當你需要操作關聯物件時,執行時機制會使用記憶體地址去一個大的雜湊表中查詢它。為了實現多執行緒安全,該表在操作時會加鎖,所以存在一定程度訪問速度問題。引用計數的儲存位置,則取決於具體作業系統版本和 CPU 架構,它有時位於物件記憶體中,而有時又儲存在外部表中。

在 Swift 舊有實現中,類資訊,引用計數和儲存屬性全部內聯在物件記憶體中。而輔助資訊則依舊儲存在單獨的外部表中。

下面我們不妨將具體實現程式碼先放一邊,仔細思考下:理論上應該如何儲存這些資訊呢?

每種儲存方案都有利弊。將資料儲存在物件記憶體中雖然能提高訪問速度,但是會讓記憶體空間變得吃緊。與之相對,外部儲存方案則是通過犧牲速度來換空間。

Objective-C 傳統儲存方案不將物件引用計數儲存在記憶體中,部分原因正是基於此。因為在 Objective-C 引入引用計數概念時,裝置的效能遠不如現在,而且記憶體容量也極為有限。Objective-C 程式中大多數物件只有一個所有者,即引用計數為 1 。此時在物件記憶體中騰出 4 個位元組空間儲存該引用計數 1 是很浪費的。而外部表方案中,數值 1 可以通過預設預設值方式表示從而減少記憶體消耗。

每次進行動態方法派發時都需要物件的類資訊,所有作為最常用資訊,類資訊應該直接儲存在記憶體中,存在外部表中是不合適的。

而例項變數這類儲存屬性在編譯期就確定了,而且有現實的訪問速度需求,所以存在物件記憶體中也是最合理的設計。另外,當物件沒有儲存屬性時,系統不會為其分配記憶體空間也就不存在浪費問題。

每個物件都需要保留引用計數。雖然不是每個物件的引用計數都為 1,但它依舊是一個相對常見的情形,加上現在記憶體足夠,它可以直接儲存在記憶體中。

大多數物件都不會有弱引用或關聯物件資料,所有它們應該儲存在外部以期節約記憶體空間。

對於那些有弱引用或關聯物件資料的物件來說,訪問速度確實不夠快但這是合理的權衡結果。那麼問題來了,該舊實現有沒有改進空間和可行方法呢?

Side Tables

在 Swift 弱引用的新版實現程式碼中,引入了 side tables 概念來改進上訴缺陷。

Side table 本質就是用於儲存額外資訊的單獨記憶體塊,並且它還是可選的。也就是說,對於那些無需儲存額外資訊的物件來說並沒有多餘開銷。

每個物件都有一個指向其對應 side table 的指標,而 side table 也有一個指標指向該物件。另外,side table 可以儲存關聯的物件資料等其他資訊。

為了避免 side table 帶來的 8 位元組空間開銷,Swift 做了一個漂亮的優化。通常記憶體中的第一個字(Word)是類資訊,第二個字則是引用計數。當物件存在 side table 需求時,第二個字將儲存指向 side table 的指標。因為引用計數是必要資訊,所以此時會將引用計數儲存到 side table 中。至於程式執行時到底是哪種情形,則由該塊記憶體中的一個標誌位進行區分。

通過將弱引用從指向物件本身改為指向 side table ,Swift 得以在保留原有引用計數設計的同時修復了舊設計中的缺陷。

因為 side table 比較小並且弱引用不再指向物件本身,這樣之前大型殭屍物件的記憶體空間將能立即釋放從而降低了記憶體浪費。同時該實現也讓執行緒安全問題變得更易解決:不再需要提前將弱引用置空。因為 side table 比較小,指向它的弱引用可以持續保留,直到這些引用自身被覆寫或銷燬。

這裡需要提醒一下,當前 side table 實現中只儲存引用計數和指向原始物件的指標。類似儲存關聯物件等用途只是一個猜想和假設。因為 Swift 還沒有內建關聯物件功能,而 Objective-C API 仍在使用全域性表。

該技術還有不少潛力可挖,也許在不久的將來能看到其應用在關聯物件等內容上。我希望它能為類擴充中的儲存屬性和其他有趣的功能開啟一扇新窗。

程式碼

因為 Swift 已經開源,所有相關程式碼都能直接訪問。

關於 side table 的大部分程式碼都在 stdlib/public/SwiftShims/RefCount.h

高層級的弱引用 API 以及相關注釋都在 swift/stdlib/public/runtime/WeakReference.h

更多關於堆物件的實現和註釋在 stdlib/public/runtime/HeapObject.cpp

上述連結其實帶著版本資訊,以便後面的讀者也能找到本文內容當時的上下文。如果你想看最新的實現程式碼,你在點選連結後切換到 master 分支即可。

總結

弱引用是一個重要的語言特性。Swift 最初的實現方式非常聰明,也有一些不錯的特性,但是同時也存在一些問題。通過引入 side table,Swift 開發工程師在保留原有特點的同時還解決了這些缺陷。Side table 的實現也為將來更多新特性創造了更多可能性。

今天內容到此為止。下次我還會帶來與程式設計和程式碼相關的新內容。當然你也可以將你感興趣的話題傳送給我

相關文章