Go 部落格

從 unique 到 cleanup 再到 weak:提升效率的新底層工具

Michael Knyszek
2025 年 3 月 6 日

去年關於 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.MapCompareAndDelete 是安全的,該方法會比較 weak.Pointer,這也是這段程式碼能夠工作的關鍵原因。

其次,觀察到我們可以向單個 MemoryMappedFile 物件新增多個獨立的清理函式。這使我們可以以可組合的方式使用清理函式,並利用它們構建通用資料結構。在這個特定的例子中,將 NewCachedMemoryMappedFileNewMemoryMappedFile 結合起來並讓它們共享一個清理函式可能更有效。然而,我們上面編寫的程式碼的優勢在於它可以以泛型的方式重寫!

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
部落格索引