实际上,计算机几乎不可能实现真正的随机性,因为这些随机数字是由于确定的算法产生的,计算机生成的是伪随机数。 只不过这些看起来像随机的数,满足随机数的有许多已知的统计特性,在程序中被当作随机数使用。

随机数生成的基本原理

无论是 Go 标准库里面的 math/rand,还是 C++ 标准库中的 std::default_random_engine,其基本原理都相同, 都是使用 Lehmer 在 1951 年提出的线性同余(linear congruential)的算法。 随机数序列 x(1), x(2), ... 由如下线性同余递推式子确定:

x(i+1) = A * x(i) % M

我们需要给定递推的初始值 x(0), 这个值又叫做随机种子(seed)。 很显然,x(0)不能为 0,否找递推式会一直会生成 0。 观察上述式子,由于最后需要进行取模运算,生成过程中肯定会出现重复数字,存在某个固定周期。 有趣的是,当我们选取 M 为素数时,可以验证,总是存在某个 A 的取值使得周期为 M-1,即可以生成 [1, M) 中的每一个整数。 我们可以让 M 为一个比较大的素数 2^31-1 = 2,147,483,647,使得生成的序列的周期最大,而此时对应的 A 可以为 16807 或 48271。 例如,在 go 的 math/rand 中选取的是数字 48271

避免频繁初始化随机数种子。

随机数种子一般只需要在 init()main() 中初始化一次。

// Good:
func init() {
    rand.Seed(time.Now().UnixNano())
}

func isEven() bool {
    return rand.Intn(2) == 0
}

不要使用当前系统时间频繁初始化随机数种子,因为根据随机数生成的基本原理,这样做没有必要。

// Bad:
func isEven() bool {
    rand.Seed(time.Now().UnixNano())
    return rand.Intn(2) == 0
}
  • time.Now() 并不廉价。频繁获取时间是无谓的性能损耗。
  • 如果在同一纳秒内调用这个方法,会产生完全一样的随机数。

从 go1.20 开始,弃用了 rand.Seed 函数,会自动初始化全局随机源,具体的讨论见 golang 官方 issue。因此可以编写更简洁的代码:

// Good: go1.20 and later versions
import math/rand
func isEven() bool {
    return rand.Intn(2) == 0
}

避免非均匀分布

直接使用 rand.Intn(n), rand.Int31n(n)rand.Int61n(), 获取 [0, n) 的随机数。

// Good:
randNum := rand.Intn(n)

不要使用 rand.Int(), rand.Int31(n)rand.Int61() 对 n 取模来获取 [0, n) 的随机数。 因为如果 n 不能被 m 除尽,会导致分布偏移,无法形成均匀分布。

// Bad:
randNum := rand.Int() % n

避免出现差一错误

例如抛掷一枚不均匀的硬币,有 80% 的概率得到正面, 有 20% 的概率得到反面。

// Good:
type side bool

const (
	head = side(true)
	tail = side(false)
)

func tossCoin() side {
	return rand.Intn(100) < 80
}

rand.Intn(100) 返回的是 0,1,..., 99 之间的随机数, 如果将 tossCoin() 里面的表达式修改为 rand.Intn(100) <= 80,则会以 81% 的概率返回正面,导致经典的差一错误Off-By-One Error

// Bad:
type side bool

const (
	head = side(true)
	tail = side(false)
)

func tossCoin() side {
	return rand.Intn(100) <= 80
}

注意线程安全和性能影响

rand.Seed 和取随机数的方法是线程安全的,但是这可能会导致性能问题。当需要高频大量生成随机数时,可能会造成大量的锁竞争。 因此,可以考虑使用新的 rand.Source 来创建新的随机数生成器。

r := rand.New(rand.NewSource(seed)) // Caution: not goroutine-safe!

但是要注意, rand.New() 生成的随机数生成器不是并发安全的。 如果要实现并发安全,可以考虑在生成随机数的时候加锁保护。

type SafeRand struct {
	r  *rand.Rand
	mu sync.Mutex
}

func NewSafeRand(seed int64) *SafeRand {
	return &SafeRand{
		r: rand.New(rand.NewSource(seed)),
	}
}

func (c *SafeRand) Intn(n int) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.r.Intn(n)
}

避免在安全领域,使用常规的非安全随机数生成器

math/rand 包不是密码学安全的,容易遭受攻击, 攻击者可能会根据生成的随机数的特征预测随机数。 因此永远不要使用 math/rand 生成对安全有敏感的随机数。 例如在某电商平台用户可以将商品链接分享给朋友,帮助其砍价,每为朋友可以砍价 0.00元 到 0.99 元。

// Bad:
import "math/rand"
func genRandomMoneyFromMassBargin() int {
    return rand.Intn(100)
}

建议使用 crypto/rand 包中安全的随机数发生器来生成随机数。

// Good:
import(
    "crypto/rand"
    "math/big"
)

func genRandomMoneyFromMassBargin() int {
	n, _ := crand.Int(crand.Reader, big.NewInt(100))
	return int(n.Int64())
}

参考