Go 部落格

新專屬套件

Michael Knyszek
2024 年 8 月 27 日

Go 1.23 的標準函式庫現包含 新套件 unique。這個套件的目的是啟用可比值正規化。換言之,此套件讓您重複使用值,以便指向單一 正規的唯一副本,並在後端有效管理正規副本。您可能已熟悉這個稱為 “實習” 的概念。讓我們深入瞭解其運作方式以及其用處。

實作實習的簡易方法

在高層面上,實習非常簡單。請參閱下方的程式碼範例,其中只使用一般地圖重複使用字串。

var internPool map[string]string

// Intern returns a string that is equal to s but that may share storage with
// a string previously passed to Intern.
func Intern(s string) string {
    pooled, ok := internPool[s]
    if !ok {
        // Clone the string in case it's part of some much bigger string.
        // This should be rare, if interning is being used well.
        pooled = strings.Clone(s)
        internPool[pooled] = pooled
    }
    return pooled
}

當您建構可能會重複出現大量字串時,此方法很有用,例如分析文字格式時。

實作非常簡單,同時在某些案例中也相當好用,但有幾個問題

  • 它永遠不會從池中移除字串。
  • 無法由多個執行緒同時安全使用它。
  • 儘管這個概念很一般,但它只適用於字串。

此實作中還有一項錯失的機會,而且很微妙。在底層,字串是不可變結構,由指標和長度組成。在比較兩個字串時,如果指標不相等,則必須比較其內容才能決定相等性。但是,如果我們知道兩個字串已經規範化,那麼只檢查其指標足夠了。

輸入unique套件

新的unique套件引進了一個類似於Intern的名為Make的函數。

它的作用方式與Intern大致相同。在內部,還有一個全域地圖(一個快速的通用並發地圖),而Make會在該地圖中查詢所提供的值。但它也以兩種重要的方式與Intern不同。首先,它接受任何可比較類型的值。其次,它回傳一個包裝值,一個Handle[T],可以從中擷取規範化的值。

Handle[T]是設計的關鍵。Handle[T]具有一個特性,即兩個Handle[T]值僅當用於建立它們的值相等時才相等。此外,兩個Handle[T]值的比較很便宜:它歸結為指標比較。與比較兩個長字串相比,便宜一整個數量級!

到目前為止,在一般 Go 程式碼中,你無法執行這些操作。

但是Handle[T]還有第二個用途:只要Handle[T]對於某個值存在,地圖就會保留該值的規範化副本。一旦所有對應於特定值Handle[T]的值消失,套件會將該內部地圖條目標記為可刪除,以便在不久的將來回收。這設定了一個明確的原則,說明何時從地圖中移除條目:當規範化條目不再被使用時,垃圾收集器就可以清理它們了。

如果你之前使用過 Lisp,所有這些聽起來或許很熟悉。Lisp 符號是規範化字串,但不是字串本身,且所有符號的字串值都保證在同一個池中。符號和字串之間的這種關係與Handle[string]string之間的關係平行。

現實世界的範例

那麼,要如何使用unique.Make呢?不妨看看標準函式庫中的net/netip套件,它會規範化addrDetail類型的值,這是netip.Addr結構的一部分。

以下為使用 uniquenet/netip 實際程式碼的簡略版本。

// Addr represents an IPv4 or IPv6 address (with or without a scoped
// addressing zone), similar to net.IP or net.IPAddr.
type Addr struct {
    // Other irrelevant unexported fields...

    // Details about the address, wrapped up together and canonicalized.
    z unique.Handle[addrDetail]
}

// addrDetail indicates whether the address is IPv4 or IPv6, and if IPv6,
// specifies the zone name for the address.
type addrDetail struct {
    isV6   bool   // IPv4 is false, IPv6 is true.
    zoneV6 string // May be != "" if IsV6 is true.
}

var z6noz = unique.Make(addrDetail{isV6: true})

// WithZone returns an IP that's the same as ip but with the provided
// zone. If zone is empty, the zone is removed. If ip is an IPv4
// address, WithZone is a no-op and returns ip unchanged.
func (ip Addr) WithZone(zone string) Addr {
    if !ip.Is6() {
        return ip
    }
    if zone == "" {
        ip.z = z6noz
        return ip
    }
    ip.z = unique.Make(addrDetail{isV6: true, zoneV6: zone})
    return ip
}

由於很多 IP 位址可能使用相同區域,且此區域為其身分的一部分,因此將其標準化非常有意義。區域的重複資料刪除可減少每個 netip.Addr 的平均記憶體使用量,而標準化的結果表示 netip.Addr 值更容易比較,因為比較區域名稱變成一個簡單的指標比較。

關於字串內部的註解

unique 套件雖然有用,但必須承認 Make 不太像字串的 Intern,因為 Handle[T] 務必要防止字串從內部映射中刪除。這表示您需要修改程式碼,以保留處理函式和字串。

但是字串很特殊,在於即使它們表現得像值,但它們實際上在背景中包含指標,如我們先前所提。這表示我們有可能僅標準化字串的基本儲存,將 Handle[T] 的詳細資料隱藏在字串本身內。因此,在未來仍然有一處可供我稱之為「透明字串內部」,在其中字串可以不用 Handle[T] 類型就可以內部化,類似於 Intern 函式,但語義更類似於 Make

在此同時,unique.Make("my string").Value() 是一種可能解決方法。即使未能保留處理函式,將允許將字串從 unique 的內部映射中刪除,但映射的條目並不會立即被刪除。在實務上,條目至少會等到下一個垃圾回收集結完成後才刪除,因此這種解決方法仍然允許在收集之間某種程度的重複資料刪除。

一些歷史和展望未來

事實上,net/netip 套件自首次推出以來,就實際上將區域字串內部化了。它所使用的內部化套件是 go4.org/intern 套件的內部複本。它和 unique 套件一樣,有一個 Value 類型(看起來很像早於泛型的 Handle[T]),內部映射中的條目在它們的處理函式不再被參照時就會被移除,這是一個值得注意的特性。

但是,為了達到此行為,它必須做一些不安全的動作。特別是,它對垃圾回收器的行為做了一些假設,以便在執行時間之外實作 弱指標。弱指標是一種指標,它不會阻止垃圾回收器回收變數;當這發生時,指標會自動變成 nil。而弱指標也是 unique 套件的基本抽象化。

沒錯:在實作中我們新增了正確的弱參考功能,將其支援至資源回收器中。在釐清了弱參考會造成設計上的遺憾決策雷區後(例如,弱參考是否應該追蹤物件復活?不要!),我們驚訝地發現其過程是如此簡單明瞭。我們驚訝的不僅如此,弱參考功能現在已經成為公開提案

這些工作也讓我們重新審視了完成器,這也導致了另一個提案,一個使用上更便利且更有效率的完成器替代方案。同時,由於我們也正在進行可比較值雜湊函數,Go 中建立記憶體使用量低下的快取的未來顯得光明!

下一篇: Go 1.23 及後續版本中的遠距監控
上一篇: 函數類型的範圍
網誌索引