Go 部落格
從 unique 到 cleanup 再到 weak:提升效率的新底層工具
在去年關於 unique
包的博文中,我們提到了當時正在進行提案評審的一些新特性,我們很高興分享這些特性在 Go 1.24 中已對所有 Go 開發者開放。這些新特性包括 runtime.AddCleanup
函式,它在物件不再可達時將一個函式排隊執行;以及 weak.Pointer
型別,它安全地指向一個物件,同時又不阻止該物件被垃圾回收。這兩個特性結合起來,足以構建您自己的 unique
包!讓我們深入探討這些特性為何有用以及何時使用它們。
注意:這些新特性是垃圾回收器的高階特性。如果您對基本的垃圾回收概念尚不熟悉,我們強烈建議閱讀我們的垃圾回收器指南的引言部分。
清理函式
如果您曾經使用過 finalizer(終結器),那麼 cleanup(清理函式)的概念就會很熟悉。Finalizer 是一種函式,透過呼叫 runtime.SetFinalizer
與分配的物件關聯,在物件變得不可達後的某個時間由垃圾回收器呼叫。從高層來看,清理函式的工作方式相同。
讓我們考慮一個使用記憶體對映檔案的應用程式,看看清理函式如何提供幫助。
//go:build unix
type MemoryMappedFile struct {
data []byte
}
func NewMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
// Get the file's info; we need its size.
fi, err := f.Stat()
if err != nil {
return nil, err
}
// Extract the file descriptor.
conn, err := f.SyscallConn()
if err != nil {
return nil, err
}
var data []byte
connErr := conn.Control(func(fd uintptr) {
// Create a memory mapping backed by this file.
data, err = syscall.Mmap(int(fd), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
})
if connErr != nil {
return nil, connErr
}
if err != nil {
return nil, err
}
mf := &MemoryMappedFile{data: data}
cleanup := func(data []byte) {
syscall.Munmap(data) // ignore error
}
runtime.AddCleanup(mf, cleanup, data)
return mf, nil
}
記憶體對映檔案將其內容對映到記憶體,在本例中是位元組切片的底層資料。藉助於作業系統的一些“魔力”,對位元組切片的讀寫直接訪問檔案的內容。透過這段程式碼,我們可以傳遞 *MemoryMappedFile
,當它不再被引用時,我們建立的記憶體對映將被清理。
注意,runtime.AddCleanup
接受三個引數:要附加清理函式的變數地址、清理函式本身以及清理函式的一個引數。這個函式與 runtime.SetFinalizer
的一個關鍵區別在於,清理函式接受的引數與我們附加清理函式的物件不同。這一改變解決了 finalizer 的一些問題。
finalizer 很難正確使用已不是秘密。例如,附加了 finalizer 的物件不能參與任何引用迴圈(即使是自引用也不行!),否則物件將永遠不會被回收,finalizer 也永遠不會執行,導致記憶體洩漏。Finalizer 還會顯著延遲記憶體回收。回收一個帶 finalizer 的物件記憶體至少需要兩個完整的垃圾回收週期:一個週期確定它不可達,另一個週期在 finalizer 執行後確定它仍然不可達。
問題在於 finalizer 會復活它們附加的物件。Finalizer 直到物件不可達時才會執行,此時物件被視為“死亡”。但是,由於 finalizer 是使用物件的指標呼叫的,垃圾回收器必須阻止回收該物件的記憶體,而是必須為 finalizer 生成一個新的引用,使其再次變得可達,或“存活”。該引用甚至可能在 finalizer 返回後仍然存在,例如如果 finalizer 將其寫入全域性變數或透過通道傳送。物件復活是有問題的,因為它意味著該物件及其指向的一切,以及那些物件指向的一切,依此類推,都變得可達,即使在正常情況下它們本應被作為垃圾回收。
透過不將原始物件傳遞給清理函式,我們解決了這兩個問題。首先,物件引用的值不需要被垃圾回收器特殊保持可達,因此即使物件參與迴圈,它仍然可以被回收。其次,由於清理不需要該物件,它的記憶體可以立即被回收。
弱指標
回到我們的記憶體對映檔案示例,假設我們注意到程式經常從彼此 unaware 的不同 goroutine 中一次又一次地對映相同的檔案。從記憶體角度來看這沒問題,因為所有這些對映將共享物理記憶體,但這會導致大量不必要的系統呼叫來對映和解除對映檔案。如果每個 goroutine 只讀取每個檔案的一小部分,情況尤其糟糕。
因此,讓我們按檔名對對映進行去重。(假設我們的程式只從對映中讀取,並且檔案本身一旦建立後永不修改或重新命名。例如,對於系統字型檔案來說,這樣的假設是合理的。)
我們可以維護一個從檔名到記憶體對映的 map,但這會使何時安全地從該 map 中刪除條目變得不清楚。我們幾乎可以使用清理函式,如果不是因為 map 條目本身會保持記憶體對映檔案物件“存活”的話。
弱指標解決了這個問題。弱指標是一種特殊的指標,垃圾回收器在判斷物件是否可達時會忽略它。Go 1.24 新增的弱指標型別 weak.Pointer
有一個 Value
方法,如果物件仍然可達,則返回一個實際的指標,如果不可達,則返回 nil
。
如果我們轉而維護一個僅弱引用記憶體對映檔案的 map,當沒有人再使用它時,我們就可以清理該 map 條目!讓我們看看這是怎麼樣的。
var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]
func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
var newFile *MemoryMappedFile
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(filename)
if !ok {
// No value found. Create a new mapped file if needed.
if newFile == nil {
var err error
newFile, err = NewMemoryMappedFile(filename)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newFile)
var loaded bool
value, loaded = cache.LoadOrStore(filename, wp)
if !loaded {
runtime.AddCleanup(newFile, func(filename string) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(filename, wp)
}, filename)
return newFile, nil
}
// Someone got to installing the file before us.
//
// If it's still there when we check in a moment, we'll discard newFile
// and it'll get cleaned up by garbage collector.
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(filename, value)
}
}
這個例子有點複雜,但要點很簡單。我們從一個包含所有已對映檔案的全域性併發 map 開始。NewCachedMemoryMappedFile
查詢此 map 以查詢現有對映檔案,如果失敗,則建立並嘗試插入新的對映檔案。由於我們與其他插入操作存在競爭,這當然也可能失敗,因此我們也需要對此小心並重試。(這種設計有一個缺陷,即在競爭中我們可能會浪費地多次對映同一個檔案,並且必須透過 NewMemoryMappedFile
新增的清理函式將其丟棄。大多數時候這可能不是大問題。修復它留給讀者作為練習。)
讓我們看看這段程式碼利用的弱指標和清理函式的一些有用特性。
首先,注意弱指標是可比較的。不僅如此,弱指標具有穩定且獨立的身份,即使它們指向的物件早已不存在,該身份仍然保留。這就是為什麼清理函式呼叫 sync.Map
的 CompareAndDelete
是安全的,該方法會比較 weak.Pointer
,這也是這段程式碼能夠工作的關鍵原因。
其次,觀察到我們可以向單個 MemoryMappedFile
物件新增多個獨立的清理函式。這使我們可以以可組合的方式使用清理函式,並利用它們構建通用資料結構。在這個特定的例子中,將 NewCachedMemoryMappedFile
與 NewMemoryMappedFile
結合起來並讓它們共享一個清理函式可能更有效。然而,我們上面編寫的程式碼的優勢在於它可以以泛型的方式重寫!
type Cache[K comparable, V any] struct {
create func(K) (*V, error)
m sync.Map
}
func NewCache[K comparable, V any](create func(K) (*V, error)) *Cache[K, V] {
return &Cache[K, V]{create: create}
}
func (c *Cache[K, V]) Get(key K) (*V, error) {
var newValue *V
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(key)
if !ok {
// No value found. Create a new mapped file if needed.
if newValue == nil {
var err error
newValue, err = c.create(key)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newValue)
var loaded bool
value, loaded = cache.LoadOrStore(key, wp)
if !loaded {
runtime.AddCleanup(newValue, func(key K) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(key, wp)
}, key)
return newValue, nil
}
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[V]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(key, value)
}
}
注意事項和未來工作
儘管我們盡了最大努力,清理函式和弱指標仍然容易出錯。為了指導那些考慮使用 finalizer、清理函式和弱指標的人,我們最近更新了垃圾回收器指南,其中包含一些關於使用這些特性的建議。下次您打算使用它們時,請先閱讀該指南,同時也要仔細考慮您是否真的需要使用它們。這些是具有微妙語義的高階工具,正如指南所說,大多數 Go 程式碼透過間接方式從這些特性中受益,而不是直接使用它們。堅持在這些特性發揮作用的用例中使用它們,您就會沒問題。
目前,我們將指出一些您更可能遇到的問題。
首先,清理函式附加到的物件不能從清理函式(作為捕獲變數)或清理函式的引數中可達。這兩種情況都會導致清理函式永遠不會執行。(在清理函式引數恰好是傳遞給 runtime.AddCleanup
的指標的特殊情況下,runtime.AddCleanup
將會 panic,以此向呼叫者發出訊號,表示他們不應像使用 finalizer 那樣使用清理函式。)
其次,當弱指標用作 map 鍵時,弱引用的物件不能從對應的 map 值中可達,否則該物件將繼續保持存活。在深入閱讀弱指標博文時這可能看起來很明顯,但這是一個容易被忽視的微妙之處。這個問題啟發了短命物件(Ephemeron)的整個概念來解決它,這是未來的一個潛在方向。
第三,使用清理函式的一個常見模式是需要一個包裝物件,就像我們在 MemoryMappedFile
示例中看到的那樣。在這種特殊情況下,您可以想象垃圾回收器直接跟蹤對映的記憶體區域並傳遞內部的 []byte
。這樣的功能可能是未來的工作,並且最近已經提出了一個相關的 API。
最後,弱指標和清理函式本質上都是非確定性的,它們的行為與垃圾回收器的設計和動態密切相關。清理函式的文件甚至允許垃圾回收器根本不執行清理函式。有效測試使用它們的程式碼可能很棘手,但這是可能的。
為什麼是現在?
弱指標作為 Go 的一個特性幾乎從一開始就被提出,但多年來 Go 團隊並未優先考慮它們。一個原因是它們很微妙,弱指標的設計空間充滿了各種決策陷阱,可能使其更加難以使用。另一個原因是弱指標是一種小眾工具,同時會增加語言的複雜性。我們已經有使用 SetFinalizer
可能有多痛苦的經驗。但有一些有用的程式沒有弱指標就無法表達,而 unique
包及其存在的原因確實強調了這一點。
結合泛型、從 finalizer 中獲得的經驗以及其他語言(如 C# 和 Java)團隊所做的出色工作帶來的見解,弱指標和清理函式的設計很快就形成了。將弱指標與 finalizer 一起使用的願望提出了額外的問題,因此 runtime.AddCleanup
的設計也很快完善了。
致謝
我要感謝社群中所有對提案問題提供反饋並在特性可用時提交 bug 的人。我還要感謝 David Chase 與我一起徹底思考了弱指標的語義,並感謝他、Russ Cox 和 Austin Clements 在 runtime.AddCleanup
設計上的幫助。感謝 Carlos Amedee 為 runtime.AddCleanup
的實現、完善以及在 Go 1.24 中落地所做的工作。最後,我要感謝 Carlos Amedee 和 Ian Lance Taylor 在 Go 1.25 中用 runtime.AddCleanup
替換標準庫中所有 runtime.SetFinalizer
所做的工作。
下一篇文章:防遍歷檔案 API
上一篇文章:使用 Swiss Tables 加速 Go maps
部落格索引