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 开发者调查结果
博客索引