真隨機數和偽隨機數
在計算機領域裡,隨機數也分為真隨機數和偽隨機數。
計算機中大部分的隨機數都是偽隨機數。什麼是偽隨機數呢?
就是看上去是隨機的,但它的隨機是具有規則的。
一般來說,通過一個隨機種子加上一套演算法就可以得到一個隨機數。這樣會有一個問題,只要你可以得到隨機種子和演算法,就可以始終得到相同的隨機數。換句話說,偽隨機數也是通過某種對映的產物。
對計算機知識稍微瞭解的人應該都知道,單靠計算機本身無論如何都是無法生成絕對意義上的真隨機數。
真隨機有一個前提,就是隨機樣本不可重現,即隨機種子和隨機演算法都不可重現,才能產生真隨機。
計算機具有基本的秩序和規則,在一個計算機系統中,一切都可預測。系統自身不會產生變數,能夠產生真隨機通常需要計算機系統之外的變數,比如以硬體噪音作為隨機種子,或者網路 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 觸發器開發。