Go 部落格

使用 math/rand/v2 演進 Go 標準庫

Russ Cox
2024 年 5 月 1 日

2012 年 3 月釋出 Go 1 以來,標準庫的更改一直受到 Go 的 相容性承諾 的約束。總的來說,相容性對 Go 使用者來說是一大福音,它為生產系統、文件、教程、書籍等提供了穩定的基礎。然而,隨著時間的推移,我們意識到原始 API 中存在一些無法相容修復的錯誤;在其他情況下,最佳實踐和約定發生了變化。我們也需要一個對重要的、破壞性的更改進行處理的計劃。

這篇博文是關於 Go 1.22 的新 math/rand/v2 包,這是標準庫的第一個“v2”版本。它為 math/rand API 帶來了急需的改進,但更重要的是,它為我們在必要時如何修改其他標準庫包樹立了一個榜樣。

(在 Go 中,math/randmath/rand/v2 是兩個不同的包,具有不同的匯入路徑。Go 1 及之後的所有版本都包含 math/rand;Go 1.22 添加了 math/rand/v2。Go 程式可以匯入任一包,或兩者都匯入。)

本文討論了 math/rand/v2 中更改的具體理由,然後 反思了指導其他包新版本的通用原則

偽隨機數生成器

在深入探討 math/rand(一個偽隨機數生成器的 API)之前,讓我們花點時間來理解它的含義。

偽隨機數生成器是一個確定性程式,它從一個小的種子輸入生成一個長序列的看似隨機的數字,儘管這些數字實際上並非完全隨機。對於 math/rand 而言,種子是一個單獨的 int64,演算法透過一個線性反饋移位暫存器 (LFSR) 的變種生成一個 int64 序列。該演算法基於 George Marsaglia 的一個想法,由 Don Mitchell 和 Jim Reeds 進行了調整,並由 Ken Thompson 為 Plan 9 和 Go 進一步定製。它沒有官方名稱,因此本文將其稱為 Go 1 生成器。

目標是使這些生成器快速、可重複,並且足夠隨機,以支援模擬、洗牌和其他非加密用例。可重複性對於數值模擬或隨機測試等用途尤為重要。例如,一個隨機化測試者可能會選擇一個種子(可能基於當前時間),生成一個大的隨機測試輸入,然後重複。當測試者發現故障時,它只需要列印種子就可以用那個特定的、大的輸入重複測試。

跨時間的可重複性也很重要:給定一個特定的種子,新版本的 Go 需要生成與舊版本相同的數值序列。我們在釋出 Go 1 時並未意識到這一點;相反,我們在嘗試對 Go 1.2 進行更改時吃盡了苦頭,收到了我們破壞了某些測試和其他用例的報告。那時,我們決定 Go 1 的相容性包括給定種子的特定隨機輸出,並 添加了一個測試

這類生成器不以生成適合派生加密金鑰或其他重要秘密的隨機數為目標。由於種子只有 63 位,從生成器中提取的任何輸出,無論多長,也只有 63 位的熵。例如,使用 math/rand 生成 128 位或 256 位的 AES 金鑰將是一個嚴重的錯誤,因為金鑰更容易被暴力破解。對於這類用途,您需要一個加密強度高的隨機數生成器,就像 crypto/rand 提供的那樣。

有了足夠的背景知識,我們可以繼續探討 math/rand 包中需要修復的問題了。

math/rand 的問題

隨著時間的推移,我們注意到 math/rand 的問題越來越多。最嚴重的問題包括:

生成器演算法

生成器本身需要更換。

Go 的初始實現雖然生產就緒,但在許多方面都是整個系統的“草圖”,能夠很好地為未來的開發奠定基礎:編譯器和執行時是用 C 編寫的;垃圾收集器是一個保守的、單執行緒的、stop-the-world 的收集器;庫廣泛使用了基本實現。從 Go 1 到 Go 1.5 左右,我們重新繪製了這些元件的“全墨水”版本:我們將編譯器和執行時轉換為 Go;我們編寫了一個新的、精確的、並行的、併發的垃圾收集器,暫停時間僅為微秒;我們根據需要用更復雜的、最佳化的演算法替換了標準庫的實現。

不幸的是,math/rand 中的可重複性要求意味著我們無法在不破壞相容性的情況下替換其中的生成器。我們被困在了 Go 1 生成器上,它速度相當快(在我 M3 Mac 上每秒約 1.8 納秒),但內部狀態近 5 千位元組。相比之下,Melissa O’Neill 的 PCG 系列生成器以約 2.1 納秒每秒的速度生成更好的隨機數,內部狀態僅為 16 位元組。我們也想探索使用 Daniel J. Bernstein 的 ChaCha 流密碼作為生成器。一篇 後續博文 將專門討論該生成器。

Source 介面

rand.Source 介面是錯誤的。該介面定義了一個低階隨機數生成器的概念,該生成器生成非負 int64 值。

% go doc -src math/rand.Source
package rand // import "math/rand"

// A Source represents a source of uniformly-distributed
// pseudo-random int64 values in the range [0, 1<<63).
//
// A Source is not safe for concurrent use by multiple goroutines.
type Source interface {
    Int63() int64
    Seed(seed int64)
}

func NewSource(seed int64) Source
%

(在文件註釋中,“[0, N)” 表示一個 半開區間,表示範圍包含 0 但不包含 2⁶³。)

rand.Rand 型別包裝了一個 Source 來實現更豐富的操作集,例如生成 0 到 N 之間的整數,生成 浮點數 等等。

我們定義 Source 介面返回一個縮短的 63 位值而不是 uint64,因為這是 Go 1 生成器和其他廣泛使用的生成器所產生的,並且它與 C 標準庫的約定相匹配。但這卻是一個錯誤:更現代的生成器會產生全寬度 uint64,這是一個更方便的介面。

另一個問題是 Seed 方法硬編碼了一個 int64 種子:有些生成器由更大的值進行種子設定,而介面沒有提供處理這種情況的方法。

種子設定責任

Seed 的一個更大的問題是全域性生成器的種子設定責任不明確。大多數使用者不直接使用 SourceRand。相反,math/rand 包提供了一個全域性生成器,可以透過 Intn 等頂層函式訪問。遵循 C 標準庫,全域性生成器預設表現為在啟動時呼叫了 Seed(1)。這對於可重複性來說很好,但對於希望其隨機輸出每次執行都不同的程式來說卻很糟糕。該包文件建議在這種情況下使用 rand.Seed(time.Now().UnixNano()),以使生成器的輸出與時間相關,但應該由哪個程式碼來執行呢?

主包應該負責 math/rand 的種子設定:匯入的庫自行配置全域性狀態是不幸的,因為它們的選擇可能與其他庫或主包衝突。但如果一個庫需要一些隨機資料並想使用 math/rand 呢?如果主包甚至不知道 math/rand 正在被使用呢?我們發現實際上許多庫會新增 init 函式,用當前時間來為全域性生成器設定種子,“以防萬一”。

庫包自行設定全域性生成器的種子會產生新的問題。假設 main 包匯入了兩個都使用 math/rand 的包:包 A 假設全域性生成器將由 main 包設定種子,但包 B 在 init 函式中設定了它。並且假設 main 包本身沒有設定生成器。現在包 A 的正確操作將取決於包 B 也被匯入到程式中的巧合。如果 main 包停止匯入包 B,包 A 將不再接收隨機值。我們在大型程式碼庫中觀察到了這種情況。

回顧過去,遵循 C 標準庫的做法顯然是一個錯誤:自動設定全域性生成器的種子將消除關於誰來設定種子的困惑,使用者將不再對他們不想要的重複輸出感到驚訝。

可擴充套件性

全域性生成器在可擴充套件性方面也不佳。由於 rand.Intn 等頂層函式可以同時從多個 goroutine 呼叫,因此實現需要一個鎖來保護共享生成器狀態。在並行使用中,獲取和釋放此鎖比實際生成更昂貴。更有意義的是擁有一個每個執行緒的生成器狀態,但這樣做會破壞沒有併發使用 math/rand 的程式的重複性。

Rand 實現缺少重要的最佳化

rand.Rand 型別包裝了一個 Source 來實現更豐富的操作集。例如,這是 Int63n 的 Go 1 實現,它返回 [0, n) 範圍內的隨機整數。

func (r *Rand) Int63n(n int64) int64 {
    if n <= 0 {
        panic("invalid argument to Int63n")
    }
    max := int64((1<<63 - 1)  - (1<<63)%uint64(n))
    v := r.Int63()
    for v > max {
        v = r.Int63()
    }
    return v % n
}

實際轉換很簡單:v % n。然而,除非 2⁶³ 是 n 的倍數,否則任何演算法都無法將 2⁶³ 個等機率值轉換為 n 個等機率值:否則某些輸出必然會比其他輸出發生得更頻繁。(作為更簡單的例子,嘗試將 4 個等機率值轉換為 3 個。)程式碼計算 max,使得 max+1 是小於或等於 2⁶³ 的 n 的最大倍數,然後迴圈拒絕大於或等於 max+1 的隨機值。拒絕這些過大的值可確保所有 n 個輸出都是等機率的。對於小的 n,需要拒絕任何值的情況很少見;對於較大的值,拒絕變得更常見且更重要。即使沒有拒絕迴圈,兩個(慢的)模運算也可能使轉換比生成隨機值 v 本身更昂貴。

在 2018 年,Daniel Lemire 找到了一種演算法,幾乎可以避免除法(另請參閱他的 2019 年博文)。在 math/rand 中,採用 Lemire 的演算法可以將 Intn(1000) 的速度提高 20-30%,但我們做不到:更快的演算法生成的與標準轉換不同的值,破壞了可重複性。

其他方法也比它們本應有的慢,受到可重複性的限制。例如,如果我們可以更改生成的值流,Float64 方法可以輕鬆地提速約 10%。(這就是我們在 Go 1.2 中嘗試更改但又回滾的更改,如前所述。)

Read 的錯誤

如前所述,math/rand 不打算也不適合用於生成加密金鑰。crypto/rand 包負責此項,其基本原語是其 Read 函式Reader 變數。

2015 年,我們接受了一項提議,讓 rand.Rand 也實現 io.Reader,並 添加了一個頂層 Read 函式。當時這似乎是合理的,但事後看來,我們沒有足夠重視這次更改的軟體工程方面。現在,如果您想讀取隨機資料,您有兩種選擇:math/rand.Readcrypto/rand.Read。如果資料將用於金鑰材料,使用 crypto/rand 非常重要,但現在有可能使用 math/rand,這可能會導致災難性的後果。

goimportsgopls 等工具有一個特殊情況,以確保它們優先使用 crypto/randrand.Read 而不是 math/rand,但這並非完全的解決方案。最好完全移除 Read

直接修復 math/rand

建立包的不相容的新主版本從來不是我們的首選:該新版本僅使切換到它的程式受益,而將所有舊主版本的現有用法拋在後面。相比之下,修復現有包中的問題會產生更大的影響,因為它修復了所有現有用法。我們永遠不應該在盡一切可能修復 v1 之前建立 v2。對於 math/rand,我們設法部分解決了上述一些問題。

  • Go 1.8 引入了一個可選的 Source64 介面,帶有一個 Uint64 方法。如果一個 Source 也實現了 Source64,那麼 Rand 在適當的時候會使用該方法。這種“擴充套件介面”模式提供了一種在事後相容(儘管有些笨拙)的方式來修改介面。

  • Go 1.20 自動為頂層生成器設定了種子,並棄用了 rand.Seed。儘管考慮到我們對輸出流可重複性的關注,這似乎是一個不相容的更改,但 我們認為,任何在 init 時間或任何計算內部呼叫 rand.Int 的匯入包也會明顯改變輸出流,並且新增或刪除此類呼叫絕不能被視為破壞性更改。如果那是真的,那麼自動設定種子也無妨,並且它將消除未來程式的這種脆弱性來源。我們還添加了一個 GODEBUG 設定來選擇舊行為。然後我們將頂層的 rand.Seed 標記為 已棄用。(需要設定種子可重複性的程式仍然可以使用 rand.New(rand.NewSource(seed)) 來獲取本地生成器,而不是使用全域性生成器。)

  • 消除了全域性輸出流的可重複性後,Go 1.20 也使得全域性生成器在不呼叫 rand.Seed 的程式中更具可擴充套件性,用 Go 執行時內部已使用的非常廉價的每個執行緒的 wyrand 生成器替換了 Go 1 生成器。這消除了全域性互斥鎖,並大大提高了頂層函式的擴充套件性。呼叫 rand.Seed 的程式將回退到使用互斥鎖保護的 Go 1 生成器。

  • 我們能夠將 Lemire 的最佳化應用到 Go 執行時,並且還在 rand.Shuffle 中使用了它,該函式是在 Lemire 的論文發表後實現的。

  • 儘管我們無法完全移除 rand.Read,但 Go 1.20 將其標記為 已棄用,轉而推薦使用 crypto/rand。此後,我們收到了使用者反饋,他們發現自己無意中在加密上下文中使用 math/rand.Read,而他們的編輯器會標記已棄用函式的使用。

這些修復是不完美和不完整的,但也是真正的改進,惠及了所有現有 math/rand 包的使用者。為了更完整的修復,我們需要將注意力轉向 math/rand/v2

修復 math/rand/v2 中的其餘問題

定義 math/rand/v2 經過了大量的計劃,然後是 GitHub 討論,接著是 提案討論。它與 math/rand 相同,但進行了以下破壞性更改,以解決上述問題:

  • 我們完全移除了 Go 1 生成器,並用兩個新的生成器:PCGChaCha8 來代替。新型別以其演算法命名(避免使用通用的 NewSource 名稱),這樣如果需要新增另一個重要的演算法,它就能很好地適應命名方案。

    採納了提案討論中的一項建議,新型別實現了 encoding.BinaryMarshalerencoding.BinaryUnmarshaler 介面。

  • 我們更改了 Source 介面,用 Uint64 方法替換了 Int63 方法,並刪除了 Seed 方法。支援種子設定的實現可以提供自己的具體方法,例如 PCG.SeedChaCha8.Seed。請注意,這兩個方法接受不同的種子型別,都不是單個 int64

  • 我們移除了頂層的 Seed 函式:像 Int 這樣的全域性函式現在只能以自動設定種子的形式使用。

  • 移除頂層 Seed 函式也讓我們能夠硬編碼使用頂層方法的可擴充套件、每個執行緒的生成器,從而避免每次使用時進行 GODEBUG 檢查。

  • 我們實現了 Lemire 最佳化版 Intn 及相關函式。具體的 rand.Rand API 現在鎖定在該值流上,因此我們無法利用尚未發現的任何最佳化,但至少我們又一次保持了最新。我們還實現了 Go 1.2 中我們想要使用的 Float32Float64 最佳化。

  • 在提案討論期間,一位貢獻者指出了 ExpFloat64NormFloat64 實現中可檢測到的偏差。我們修復了該偏差並鎖定了新的值流。

  • PermShuffle 使用了不同的洗牌演算法並生成了不同的值流,因為 Shuffle 在後執行,使用了更快的演算法。完全刪除 Perm 會增加使用者遷移的難度。相反,我們實現了 Perm,使其基於 Shuffle,這仍然允許我們刪除一個實現。

  • 我們將 Int31Int63IntnInt31nInt63n 重新命名為 Int32Int64IntNInt32NInt64N。名稱中的 31 和 63 是不必要的迂腐和令人困惑的,而大寫的 N 在 Go 中更符合名稱中第二個“單詞”的習慣。

  • 我們添加了 UintUint32Uint64UintNUint32NUint64N 頂層函式和方法。我們需要新增 Uint64 來提供對核心 Source 功能的直接訪問,並且不新增其他函式似乎不一致。

  • 採納了提案討論中的另一項建議,我們添加了一個新的頂層通用函式 N,它類似於 Int64NUint64N,但適用於任何整數型別。在舊 API 中,要建立最多 5 秒的隨機持續時間,需要編寫:

    d := time.Duration(rand.Int63n(int64(5*time.Second)))
    

    使用 N,等效的程式碼是:

    d := rand.N(5 * time.Second)
    

    N 只是一個頂層函式;rand.Rand 上沒有 N 方法,因為 Go 中沒有通用方法。(通用方法在未來也不太可能出現;它們與介面衝突嚴重,並且完整的實現需要執行時程式碼生成或慢速執行。)

  • 為了減輕 math/rand 在加密上下文中的誤用,我們將 ChaCha8 設定為全域性函式使用的預設生成器,並將 Go 執行時也改為使用它(替換了 wyrand)。仍然強烈建議程式使用 crypto/rand 來生成加密金鑰,但意外使用 math/rand/v2 不如使用 math/rand 那樣災難性。即使在 math/rand 中,全域性函式在未顯式設定種子時也使用 ChaCha8 生成器。

演進 Go 標準庫的原則

正如本文開頭提到的,這項工作的一個目標是為我們如何處理標準庫中的所有 v2 包建立原則和模式。在接下來的幾個 Go 版本中,不會湧現大量 v2 包。相反,我們將一次處理一個包,確保我們設定的質量標準能夠持續十年。許多包根本不需要 v2。但對於那些需要的包,我們的方法歸結為三個原則。

首先,包的不相容的新版本將使用 that/package/v2 作為其匯入路徑,遵循 語義匯入版本控制,就像標準庫外面的 v2 模組一樣。這允許原始包和 v2 包的使用在單個程式中共存,這對於 逐步轉換到新 API 至關重要。

其次,所有更改都必須以尊重現有用法和使用者為根基:我們不應引入不必要的變動,無論是對現有包進行不必要的更改,還是引入一個全新的、必須學習的包。在實踐中,這意味著我們將現有包作為起點,只進行有充分理由的更改,並提供足以證明使用者更新成本的價值。

第三,v2 包不應拋棄 v1 使用者。理想情況下,v2 包應該能夠完成 v1 包能做的一切,並且在 v2 釋出時,v1 包應被重寫為 v2 的一個薄包裝。這將確保 v1 的現有使用能夠繼續受益於 v2 中的錯誤修復和效能最佳化。當然,鑑於 v2 引入了破壞性更改,這並非總是可能的,但始終是需要仔細考慮的事情。對於 math/rand/v2,我們安排了自動設定種子的 v1 函式呼叫 v2 生成器,但由於可重複性問題,我們無法共享其他程式碼。最終 math/rand 程式碼量不大,不需要常規維護,因此程式碼重複是可控的。在其他情況下,更多地避免程式碼重複可能會有價值。例如,在 encoding/json/v2 設計(仍在進行中)中,儘管預設語義和 API 已更改,但該包提供了配置選項,使得實現 v1 API 成為可能。當我們最終釋出 encoding/json/v2 時,encoding/json (v1) 將成為其薄包裝,確保那些不從 v1 遷移的使用者仍然能從 v2 的最佳化和安全修復中受益。

一篇 後續博文 將更詳細地介紹 ChaCha8 生成器。

下一篇文章: Go 1.22 中的安全隨機性
上一篇文章: 2024 年上半年 Go 開發者調查結果
部落格索引