Go 部落格

新通用包

Michael Knyszek
2024 年 8 月 27 日

Go 1.23 的標準庫現已包含 新的 unique。該包的目的是實現可比較值的規範化。換句話說,此包允許您對值進行去重,使它們指向單個、規範的、唯一的副本,同時在後臺高效地管理這些規範副本。您可能已經熟悉這個概念,稱為 “字串駐留”(interning)。讓我們深入瞭解一下它的工作原理以及為什麼它很有用。

字串駐留的簡單實現

從高層來看,字串駐留非常簡單。看看下面的程式碼示例,它僅使用普通的 map 來對字串進行去重。

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
}

這在您構建大量可能重複的字串時非常有用,例如在解析文字格式時。

這種實現非常簡單,在某些情況下效果足夠好,但它存在一些問題:

  • 它從不從池中移除字串。
  • 它不能被多個 goroutine 安全地併發使用。
  • 它只適用於字串,儘管這個概念相當通用。

此實現還存在一個被忽略的機會,而且很微妙。在後臺,字串是不可變的結構,由指標和長度組成。在比較兩個字串時,如果指標不相等,那麼我們必須比較它們的內容來確定相等性。但如果我們知道兩個字串已經規範化,那麼僅檢查它們的指標就足夠了。

引入 unique

新的 unique 包引入了一個類似於 Intern 的函式,名為 Make

它的工作方式與 Intern 大致相同。內部也有一個全域性 map(一個快速的通用併發 map),Make 在該 map 中查詢提供的值。但它與 Intern 的區別在於兩個重要方面。首先,它接受任何可比型別的值。其次,它返回一個包裝值,即 Handle[T],可以從中檢索到規範值。

這個 Handle[T] 是設計的關鍵。一個 Handle[T] 具有這樣的屬性:兩個 Handle[T] 值相等當且僅當用於建立它們的值相等。更重要的是,比較兩個 Handle[T] 值是廉價的:它歸結為指標比較。與比較兩個長字串相比,這要便宜一個數量級!

到目前為止,這並不是你在普通 Go 程式碼中做不到的。

但是 Handle[T] 還有第二個用途:只要存在某個值的 Handle[T],該 map 就會保留該值的規範副本。一旦所有對映到特定值的 Handle[T] 值都消失了,該包就會將該內部 map 條目標記為可刪除,以便在不久的將來進行回收。這為何時從 map 中刪除條目設定了一個明確的策略:當規範條目不再被使用時,垃圾回收器就可以自由地清理它們。

如果您以前使用過 Lisp,這一切聽起來可能都有些熟悉。Lisp 的符號是駐留的字串,但不是字串本身,並且保證所有符號的字串值都在同一個池中。符號和字串之間的這種關係類似於 Handle[string]string 之間的關係。

真實世界示例

那麼,如何使用 unique.Make 呢?看看標準庫中的 net/netip 包,它駐留了 addrDetail 型別的值,這是 netip.Addr 結構的一部分。

下面是 net/netip 中使用 unique 的實際程式碼的節選。

// 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] 來防止字串從內部 map 中刪除。這意味著您需要修改程式碼以同時保留控制代碼和字串。

但字串很特別,因為儘管它們表現得像值,但實際上它們在底層包含指標,正如我們之前提到的。這意味著我們有可能僅規範化字串的底層儲存,將 Handle[T] 的細節隱藏在字串本身中。因此,未來仍然存在我稱之為*透明字串駐留*的空間,其中字串可以在沒有 Handle[T] 型別的情況下進行駐留,類似於 Intern 函式,但語義更接近 Make

在此期間,unique.Make("my string").Value() 是一種可能的解決方法。即使未能保留控制代碼會導致字串從 unique 的內部 map 中刪除,但 map 條目不會立即刪除。實際上,條目至少在下一次垃圾回收完成之前不會被刪除,因此這種解決方法在兩次回收之間的週期內仍然允許一定程度的去重。

一些歷史,以及對未來的展望

事實是,net/netip 包自從首次引入以來實際上一直在駐留區域字串。它使用的駐留包是 go4.org/intern 包的內部副本。與 unique 包類似,它有一個 Value 型別(看起來很像 Handle[T],在泛型之前),它有一個顯著的特性,即一旦其控制代碼不再被引用,內部 map 中的條目就會被移除。

但為了實現這種行為,它必須做一些不安全的事情。特別是,它對垃圾回收器的行為做了一些假設來實現弱指標(weak pointers),這些弱指標獨立於執行時。弱指標是一種不會阻止垃圾回收器回收變數的指標;當這種情況發生時,指標會自動變為 nil。恰好,弱指標*也是* unique 包底層的主要抽象。

沒錯:在實現 unique 包的過程中,我們為垃圾回收器添加了對弱指標的正式支援。在經歷(與弱指標相關的)一系列令人遺憾的設計決策(例如,弱指標是否應跟蹤物件復活?不行!)之後,我們對這一切竟然如此簡單和直接感到驚訝。驚訝到足以使弱指標成為一項公開提案

這項工作還促使我們重新審視 finalizers,從而提出了另一項關於更易於使用和更高效的finalizers 替代方案的提案。隨著用於可比值的雜湊函式也即將到來,在 Go 中構建記憶體高效快取的未來是光明的!

下一篇文章:Go 1.23 及更高版本中的遙測
上一篇文章:函式型別的範圍迭代
部落格索引