Go 部落格

從唯一到清理和弱引用:提升效率的新底層工具

Michael Knyszek
2025 年 3 月 6 日

去年關於 unique 包的部落格文章中,我們曾暗示了一些當時尚處於評審階段的新功能,我們很高興地與大家分享,從 Go 1.24 開始,它們現在已對所有 Go 開發者可用。這些新功能是runtime.AddCleanup 函式,它會排隊等待一個函式在物件不再可達時執行,以及weak.Pointer 型別,它安全地指向一個物件而不阻止其被垃圾回收。這兩個功能結合起來,足以讓您構建自己的 unique 包!讓我們深入探討這些功能為何有用,以及何時使用它們。

注意:這些新功能是垃圾回收器的先進功能。如果您還不熟悉基本的垃圾回收概念,我們強烈建議您閱讀我們的垃圾回收器指南的引言。

清理

如果您曾經使用過 finalizer,那麼清理的概念就會很熟悉。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 也永遠不會執行,導致記憶體洩漏。Finalizers 還會顯著延遲記憶體回收。回收已 finalizer 物件的記憶體至少需要兩次完整的垃圾回收週期:一次確定它不可達,另一次確定在 finalizer 執行後它仍然不可達。

問題在於,finalizer 會復活它們所附加的物件。Finalizer 在物件不可達時執行,此時它被認為是“死亡”的。但是,由於 finalizer 是透過指向該物件的指標呼叫的,垃圾回收器必須阻止回收該物件的記憶體,而是必須為 finalizer 生成新的引用,使其再次變得可達,“存活”。這個引用甚至可能在 finalizer 返回後仍然存在,例如,如果 finalizer 將其寫入全域性變數或透過通道傳送。物件復活是有問題的,因為它意味著該物件及其指向的所有內容,以及那些內容指向的所有內容,依此類推,都是可達的,即使它們本可以被垃圾回收。

我們透過不將原始物件傳遞給清理函式來解決這兩個問題。首先,物件引用的值不需要由垃圾回收器特別保持可達,因此即使物件參與了迴圈,也可以被回收。其次,由於清理不需要該物件,因此可以立即回收其記憶體。

弱指標

回到我們的記憶體對映檔案示例,假設我們注意到我們的程式經常一遍又一遍地對映相同的檔案,來自不同的、互不瞭解的 goroutine。從記憶體角度來看,這沒關係,因為所有這些對映都會共享物理記憶體,但會導致大量不必要的系統呼叫來對映和取消對映檔案。如果每個 goroutine 只讀取檔案的一小部分,這尤其糟糕。

因此,讓我們透過檔名對對映進行去重。(假設我們的程式僅讀取對映,並且在建立後文件本身從未被修改或重新命名。例如,對於系統字型檔案,此類假設是合理的。)

我們可以維護一個從檔名到記憶體對映的對映,但隨後就無法確定何時可以安全地從該對映中刪除條目。如果我們不是因為對映條目本身會使記憶體對映檔案物件保持活動狀態,那麼我們*幾乎*可以使用清理功能。

弱指標解決了這個問題。弱指標是一種特殊的指標,垃圾回收器在決定物件是否可達時會忽略它。Go 1.24 的新弱指標型別 weak.Pointer 有一個 Value 方法,如果物件仍然可達,則返回一個真實指標,如果不可達,則返回 nil

如果我們改為維護一個*僅弱地*指向記憶體對映檔案的對映,那麼當沒有人再使用該對映條目時,我們就可以清理它!讓我們看看它是什麼樣的。

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)
    }
}

這個例子有點複雜,但核心很簡單。我們從一個全域性併發對映開始,記錄我們建立的所有對映檔案。NewCachedMemoryMappedFile 在此對映中查詢現有的對映檔案,如果找不到,則建立並嘗試插入新的對映檔案。由於我們正在與其他插入操作競爭,這當然也可能失敗,所以我們必須小心這一點,並重試。(此設計有一個缺陷,即我們可能會在競爭中浪費性地多次對映同一個檔案,然後我們必須透過 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 的設計也很快就成形了。

致謝

我要感謝社群中所有在提案 issue 中貢獻反饋並在功能可用時報告 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 map 的速度
部落格索引