(轉自:隨機數是騙人的,.Net、Java、C爲我做證 - 楊中科 原文日期:2014.05.12)html
幾乎全部編程語言中都提供了"生成一個隨機數"的方法,也就是調用這個方法會生成一個數,咱們事先也不知道它生成什麼數。好比在.Net中編寫下面的代碼:java
Random rand = newRandom();
Console.WriteLine(rand.Next());
|
運行後結果以下:算法
Next()方法用來返回一個隨機數。一樣的代碼你執行和個人結果極可能不同,並且我屢次運行的結果也極可能不同,這就是隨機數。編程
1、陷阱安全
看似很簡單的東西,使用的時候有陷阱。我編寫下面的代碼想生成100個隨機數:服務器
for
(
int
i=0;i<100;i++)
{
Random rand =
new
Random();
Console.WriteLine(rand.Next());
}
|
太奇怪了,居然生成的"隨機數"有好多連續同樣的,這算什麼"隨機數"呀。有人指點"把new Random()"放到for循環外面就能夠了:併發
Random rand = newRandom();
for
(
int
i=0;i<100;i++)
{
Console.WriteLine(rand.Next());
}
|
運行結果:dom
確實能夠了! 編程語言
2、這是爲何呢?ide
這要從計算機中"隨機數"產生的原理提及了。咱們知道,計算機是很嚴格的,在肯定的輸入條件下,產生的結果是惟一肯定的,不會每次執行的結果不同。那麼怎麼樣用軟件實現產生看似不肯定的隨機數呢?
生成隨機數的算法有不少種,最簡單也是最經常使用的就是 "線性同餘法": 第n+1個數=(第n個數*29+37) % 1000,其中%是"求餘數"運算符。不少像我同樣的人見了公式都頭疼,我用代碼解釋一下吧,MyRand是一個自定義的生成隨機數的類:
class
MyRand
{
private
int
seed;
public
MyRand(
int
seed)
{
this
.seed = seed;
}
public
int
Next()
{
int
next = (seed * 29 + 37) % 1000;
seed = next;
return
next;
}
}
|
以下調用:
MyRand rand = newMyRand(51);
for
(
int
i = 0; i < 10; i++)
{
Console.WriteLine(rand.Next());
}
|
執行結果以下:
生成的數據是否是看起來"隨機"了。簡單解釋一下這個代碼:咱們建立MyRand的一個對象,而後構造函數傳遞一個數51,這個數被賦值給seed,每次調用Next方法的時候根據(seed * 29 + 37) % 1000計算獲得一個隨機數,把這個隨機數賦值給seed,而後把生成的隨機數返回。這樣下次再調用Next()的時候seed就再也不是51,而是上次生成的隨機數了,這樣就看起來好像每一次生成的內容都很"隨機"了。注意"%1000"取餘預算的目的是保證生成的隨機數不超過1000。
固然不管是你運行仍是我每次運行,輸出結果都是同樣的隨機數,由於根據給定的初始數據51,咱們就能夠依次推斷下來下面生成的全部"隨機數"是什麼均可以算出來了。這個初始的數據51就被稱爲"隨機數種子",這一系列的51六、一、6六、95一、616……數字被稱爲"隨機數序列"。咱們把51改爲52,就會有這樣的結果:
3、樓主好人,跪求種子
那麼怎麼可使得每次運行程序的時候都生成不一樣的"隨機數序列"呢?由於咱們每次執行程序時候的時間極可能不同,所以咱們能夠用當前時間作"隨機數種子"
MyRand rand = newMyRand(Environment.TickCount);
for
(
int
i = 0; i < 10; i++)
{
Console.WriteLine(rand.Next());
}
|
Environment.TickCount爲"系統啓動後通過的微秒數"。這樣每次程序運行的時候Environment.TickCount都不大可能同樣(靠手動誰能一微秒內啓動兩次程序呢),因此每次生成的隨機數就不同了。
固然若是咱們把new MyRand(Environment.TickCount)放到for循環中:
for
(
int
i = 0; i < 100; i++)
{
MyRand rand = newMyRand(Environment.TickCount);
Console.WriteLine(rand.Next());
}
|
運行結果又變成"不少是連續"的了,原理很簡單:因爲for循環體執行很快,因此每次循環的時候Environment.TickCount極可能還和上次同樣(兩行簡單的代碼運行用不了一毫秒那麼長事件),因爲此次的"隨機數種子"和上次的"隨機數種子"同樣,這樣Next()生成的第一個"隨機數"就同樣了。從"-320"變成"-856"是由於運行到"-856"的時候時間過了一毫秒。
4、各語言的實現
咱們看到.Net的Random類有一個int類型參數的構造函數:
public Random(int Seed)
就是和咱們寫的MyRand同樣接受一個"隨機數種子"。而咱們以前調用的無參構造函數就是給Random(int Seed)傳遞Environment.TickCount類進行構造的,代碼以下:
public Random() : this(Environment.TickCount)
{
}
這下咱們終於明白最開始的疑惑了。
一樣道理,在C/C++中生成10個隨機數不該該以下調用:
int
i;
for
(i=0;i<10;i++)
{
srand
( (unsigned)
time
( NULL ) );
printf
(
"%d\n"
,
rand
());
}
|
而應該:
1
2
3
4
5
6
|
srand
( (unsigned)
time
( NULL ) );
//把當前時間設置爲"隨機數種子"
int
i;
for
(i=0;i<10;i++)
{
printf
(
"%d\n"
,
rand
());
}
|
5、"奇葩"的Java
Java學習者可能會提出問題了,在Java低版本中,以下使用會像.Net、C/C++中同樣產生相同的隨機數:
for
(
int
i=
0
;i<
100
;i++)
{
Random rand =
new
Random();
System.out.println(rand.nextInt());
}
|
由於低版本Java中Rand類的無參構造函數的實現一樣是用當前時間作種子:
public Random() { this(System.currentTimeMillis()); }
可是在高版本的Java中,好比Java1.8中,上面的"錯誤"代碼執行倒是沒問題的:
爲何呢?咱們來看一下這個Random無參構造函數的實現代碼:
public
Random()
{
this
(seedUniquifier() ^ System.nanoTime());
} <br>
private
static
long
seedUniquifier() {
for
(;;) {
long
current = seedUniquifier.
get
();
long
next = current * 181783497276652981L;
if
(seedUniquifier.compareAndSet(current, next))
return
next;
}
}
privatestaticfinal AtomicLong seedUniquifier =
new
AtomicLong(8682522807148012L);
|
這裏再也不是使用當前時間來作"隨機數種子",而是使用System.nanoTime()這個納秒級的時間量而且和採用原子量AtomicLong根據上次調用構造函數算出來的一個數作異或運算。關於這段代碼的解釋詳細參考這篇文章《解密隨機數生成器(2)——從java源碼看線性同餘算法》
最核心的地方就在於使用static變量AtomicLong來記錄每次調用Random構造函數時使用的種子,下次再調用Random構造函數的時候避免和上次同樣。
6、高併發系統中的問題
前面咱們分析了,對於使用系統時間作"隨機數種子"的隨機數生成器,若是要產生多個隨機數,那麼必定要共享一個"隨機數種子"纔會避免生成的隨機數短期以內生成重複的隨機數。可是在一些高併發的系統中一個不注意還會產生問題,好比一個網站在服務器端經過下面的方法生成驗證碼:
Random rand = new Random();
Int code = rand.Next();
當網站併發量很大的時候,可能一個毫秒內會有不少我的請求驗證碼,這就會形成這幾我的請求到的驗證碼是重複的,會給系統帶來潛在的漏洞。
再好比我今天看到的一篇文章《當隨機不夠隨機:一個在線撲克遊戲的教訓》裏面就提到了"因爲隨機數產生器的種子是基於服務器時鐘的,黑客們只要將他們的程序與服務器時鐘同步就可以將可能出現的亂序減小到只有 200,000 種。到那個時候一旦黑客知道 5 張牌,他就能夠實時的對 200,000 種可能的亂序進行快速搜索,找到遊戲中的那種。因此一旦黑客知道手中的兩張牌和 3 張公用牌,就能夠猜出轉牌和河牌時會來什麼牌,以及其餘玩家的牌。"
這種狀況有以下幾種解決方法:
7、真隨機數發生器
根據咱們以前的分析,咱們知道這些所謂的隨機數不是真的"隨機",只是看起來隨機,所以被稱爲"僞隨機算法"。在一些對隨機要求高的場合會使用一些物理硬件採集物理噪聲、宇宙射線、量子衰變等現實生活中的真正隨機的物理參數來產生真正的隨機數。
固然也有聰明的人想到了不借助增長"隨機數發生器"硬件的方法生成隨機數。咱們操做計算機時候鼠標的移動、敲擊鍵盤的行爲都是不可預測的,外界命令計算機何時要執行什麼進程、處理什麼文件、加載什麼數據等也是不可預測的,所以致使的CPU運算速度、硬盤讀寫行爲、內存佔用狀況的變化也是不可預測的。所以若是採集這些信息來做爲隨機數種子,那麼生成的隨機數就是不可預測的了。
在Linux/Unix下可使用"/dev/random"這個真隨機數發生器,它的數據主來來自於硬件中斷信息,不過產生隨機數的速度比較慢。
Windows下能夠調用系統的CryptGenRandom()函數,它主要依據當前進程Id、當前線程Id、系統啓動後的TickCount、當前時間、QueryPerformanceCounter返回的高性能計數器值、用戶名、計算機名、CPU計數器的值等等來計算。和"/dev/random"同樣CryptGenRandom()的生成速度也比較慢,並且消耗比較大的系統資源。
固然.Net下也可使用RNGCryptoServiceProvider 類(System.Security.Cryptography命名空間下)來生成真隨機數,根據StackOverflow上一篇帖子介紹RNGCryptoServiceProvider 並非對CryptGenRandom()函數的封裝,可是和CryptGenRandom()原理相似。
8、總結
有人可能會問:既然有"/dev/random" 、CryptGenRandom()這樣的"真隨機數發生器",爲何還要提供、使用僞隨機數這樣的"假貨"?由於前面提到了"/dev/random" 、CryptGenRandom()生成速度慢並且比較消耗性能。在對隨機數的不可預測性要求低的場合,使用僞隨機數算法便可,由於性能比較高。對於隨機數的不可預測性要求高的場合就要使用真隨機數發生器,真隨機數發生器硬件設備須要考慮成本問題,而"/dev/random"、CryptGenRandom()則性能較差。
萬事萬物都沒有完美的,沒有絕對的好,也沒有絕對的壞,這纔是多元世界美好的地方。