真随机数和伪随机数
在计算机领域里,随机数分为真随机数和伪随机数。
计算机中大部分的随机数都是伪随机数。那什么是伪随机数呢?
就是看上去是随机的,但它的随机具有规则。
一般来说,通过一个随机种子加上一套算法就可以得到一个随机数。这样会有一个问题,只要你可以得到随机种子和算法,就可以始终得到相同的随机数。换句话说,伪随机数也是通过某种映射的产物。
对计算机知识稍微了解的人应该都知道,单靠计算机本身无论如何都是无法生成绝对意义上的真随机数。
真随机有一个前提,就是随机样本不可重现,即随机种子和随机算法都不可重现,才能产生真随机。
计算机具有基本的秩序和规则,在一个计算机系统中,一切都可预测。系统自身不会产生变量,能够产生真随机通常需要计算机系统之外的变量,比如以硬件噪音作为随机种子,或者网络 IO、键鼠敲击速度、鼠标移动速度等等。
有一个通过大气噪声来产生随机数的网站,random。
在「时间不可回溯」的前提下,这样可以符合真随机数的基本原则:「随机样本不可重现」。
哲学中有句命题,「世上没有两片形状完全相同的树叶」,可以将其代入到随机数的概念中来。这句话可以认为世界上不存在真随机数。即使 DNA 完全相等,但是在时间、空间等纬度上还是会存有差异。而且这个观察视角仅仅是在三维空间下。在更高维度的视角下,差异会更大。
其实这个问题可以被推翻,我认为「世上有两片形状完全相同的树叶」。宇宙本身也是一个系统,只是这个系统的复杂度太高,人类无法预测和干涉。至于宇宙的运行到底是有序还是无序,没人能说得清楚。无论从哲学角度还是物理角度,我都认为宇宙都是有序的,熵总有尽头。
但这两个观点并不矛盾。我也可以认为,世界上没有绝对的真随机数,只有相对的真随机数。
如果科技达到了可以操纵时间、空间,甚至上升到可以操纵更高维度的地步,我们目前所认为的这些「不可重现的随机样本」都变为可重现的了。
就像密码学中所谈及的,世界上没有绝对安全的密码。 安全的密码,只是计算机的运算速度不够快而已,如果一个密码需要花 100 年才能破解,那么我们就可以认为这个密码是相对安全的。
通常情况下,我们所谓的真随机数指的是符合密码学要求的随机数,而非绝对意义上的随机数。
实际上假随机数可以胜任大部分场景,那么真随机数有什么意义呢?
除了密码学之外,彩票、抽奖、游戏等领域,都是真随机数的主要应用场景。
我记得在 08 年左右时玩过一款游戏,游戏中有锻造装备的设定,一个装备分为 0-10 星,随着星级的提高,锻造的成功率会降低。当时在游戏论坛上经常有锻造攻略,比如在明天的几点几分几秒,连续点击可以提高锻造率。后来还产生了一批依靠锻造装备来获取收益的游戏商人。那时并不了解其中的内幕。后来才知道,那群人估计已经得到了游戏锻造装备的源码,确定了程序中使用的随机种子和算法。随机种子应该是通过时间戳来设置的,所以他们可以通过测试未来的时间来预知在未来某时某刻锻造必定会成功。
除了锻造率以外,游戏中有很多宝箱,可以开出不同的道具。其中也是有一套随机机制的,可能更为复杂,操作的成功率也不高,所以教程也相对较少。但仍然有很多游戏商人通过脚本的方式以较高的概率通过开宝箱刷道具来盈利。比如花 100 块钱买 100 个宝箱,利用脚本在特定时间自动开箱,虽然不能保证每次开出价值最高的道具,但也能在很大概率上开出价值 500-800 块钱的道具。然后再去购买宝箱,重复这种行为。
导致的结果就是游戏装备和道具贬值,贫富差距变低,人人可以用很低的成本买到顶级装备。游戏生态被严重破坏,恶性循环,最终倒闭。
我和一个前辈讨论过一个问题,就是关于技术对商业的价值和影响。
他认为绝大多数情况下,一个产品的成功并不依赖多么厉害的技术。我非常认可,但游戏绝对是个例外,外挂可以让一个游戏破产。
游戏里面的随机种子可以设置的更加复杂一些,多获取一些随机种子的合成参数,比如角色当前坐标,角色 ID 等,可以有效防止外挂,提升被破解的门槛。
生产随机数
一般的编程语言通常都会提供一个 Random 函数和一个 Crypto 函数,前者用于生成伪随机数,后者用于生成真随机数。
JavaScript 中的随机数
在 JavaScript 中,提供了 Math.random 用于生成伪随机数,它返回一个范围 0-1 的浮点型数据(包括 0,不包括 1)。
console.log(Math.random());
为了方便生成某个范围的整数,可以稍微封装。
function getRandomInt(min, max) { return Math.floor(Math.random() * Math.floor(max - min)) + min; }
除了 Math.random,JavaScript 还提供了 crypto.getRandomValues 来生成真随机数。
const arr = new Uint32Array(2020); crypto.getRandomValues(arr);
JavaScript 在语言层面没有提供设置随机种子的能力。
在 v8 的随机数源码中可以看出,随机数是通过 MathRandom 方法生成的,而随机种子是通过 MathImul 方法生成的,但是没有预留设置 seed 值的参数。
var rngstate; // Initialized to a Uint32Array during genesis. function MathRandom() { var r0 = (MathImul(18030, rngstate[0] & 0xffff) + (rngstate[0] >>> 16)) | 0; rngstate[0] = r0; var r1 = (MathImul(36969, rngstate[1] & 0xffff) + (rngstate[1] >>> 16)) | 0; rngstate[1] = r1; var x = ((r0 << 16) + (r1 & 0xffff)) | 0; // Division by 0x100000000 through multiplication by reciprocal. return (x < 0 ? x + 0x100000000 : x) * 2.3283064365386962890625e-10; }
不能设置随机种子在某些场景下是比较难受的,比如想通过设置随机种子恢复随机数据的生成规律。
解决办法是重写 Math.random,自己定义生成随机数的算法。
下面是通过取正弦值的绝对值来生成随机数的例子。
Math.random = function () { return +Math.abs(Math.sin(Math.random_seed++)).toFixed(8); }; Math.random.seed = function (seed) { Math.random_seed = seed; };
设置随机种子后,每次随机操作后都会进行 ++ 操作改变随机种子。
Math.random.seed(2020); for (let i = 0; i < 5; i++) { console.log(Math.random()); } // 0.04406199 // 0.81684695 // 0.92675057 // 0.18460399 // 0.72726665
可以在每次进行 random 前重新设置随机种子,使用相同随机种子获取的随机值始终是相同的。
for (let i = 0; i < 5; i++) { Math.random.seed(2020); console.log(Math.random()); } // 0.04406199 // 0.04406199 // 0.04406199 // 0.04406199 // 0.04406199
除此之外,还可以使用 seedrandom 库。
Golang 中的随机数
在 golang 中,提供了 math/rand 和 crypto/rand 两个包。
使用 math/rand 来生成伪随机数。
package main import ( "fmt" "math/rand" ) func main() { for i := 0; i < 5; i++ { fmt.Println(rand.Int()) } }
每次重新运行 go 程序,输出结果总是一样的。
5577006791947779410 8674665223082153551 6129484611666145821 4037200794235010051 3916589616287113937
rand.Int 默认设置了随机种子为 default Source,也就是 1。
为了让伪随机数更加随机,可以在每次生成伪随机数的时候,将随机种子设置为一些变量,比如将 unix 纳秒数的时间戳作为种子变量是一个不错的选择。除此之外,uuid 也是一个不错的选择,但性能会低一些。实际上 uuid 也是一种随机数。
package main import ( "fmt" "math/rand" ) func main() { for i := 0; i < 5; i++ { rand.Seed(time.Now().UnixNano()) fmt.Println(rand.Int()) } }
只要保证随机种子的变化速度大于 CPU 的运行速度,就可以实现随机的伪随机。虽然目前 CPU 的时钟周期是可以达到 1 纳秒以内的,但代码不是指令。
如果换成秒的话结果则是不一样的。
func main() { for i := 0; i < 5; i++ { rand.Seed(time.Now().Unix()) fmt.Println(rand.Int()) } }
上面这段代码生成的数据始终都相同,原因就是 CPU 运行代码的速度远高于秒级别,time.Now().Unix() 的值几乎都是相同的,只有极小极小的概率会碰到刚好是秒切换的情况。因为 1 秒等于 10 亿纳秒。
crypto/rand 用来生成真随机数,每次运行的结果都不相同。
package main import ( "crypto/rand" "fmt" "math/big" ) func main() { for i := 0; i < 5; i++ { result, _ := rand.Int(rand.Reader, big.NewInt(100)) fmt.Println(result) } }
第一次运行结果:
75 52 19 73 65
第二次运行结果:
82 70 58 81 7
crypto 的做法是获取硬件信息来生成安全性很高的随机数。
但代价就是性能,下面是一组性能测试:
package main import ( crand "crypto/rand" "math/big" "math/rand" "testing" ) func BenchmarkRand1(b *testing.B) { for i := 0; i < b.N; i++ { rand.Intn(1000) } } func BenchmarkRand2(b *testing.B) { for i := 0; i < b.N; i++ { crand.Int(crand.Reader, big.NewInt(1000)) } }
这是测试结果:
BenchmarkRand1-4 55989594 28.3 ns/op 0 B/op 0 allocs/op BenchmarkRand2-4 4134075 354 ns/op 56 B/op 4 allocs/op
可以看到,使用 crypto 生成随机数的性能大概比伪随机慢 100 多倍。
在软件开发过程中,大多数的业务场景下对安全性的要求都不高。即使有安全性的要求,大多数场景下对性能的要求也不高。
当然也有例外,一些特殊场景下在对安全性和性能都有较高的要求,这时的解决方案通常是会采用一种随机数生成硬件,这类硬件中会有针对随机数生成作出优化的芯片,可以兼顾安全和性能。伪随机数生成器通常基于元胞自动机和 LFSR 开发,真随机数生成器通常基于 D 触发器开发。