隨機(jī)數(shù)是騙人的,.Net、Java、C為我作證
幾乎所有編程語言中都提供了"生成一個隨機(jī)數(shù)"的方法,也就是調(diào)用這個方法會生成一個數(shù),我們事先也不知道它生成什么數(shù)。比如在.Net中編寫下面的代碼:
- Random rand = newRandom();
- Console.WriteLine(rand.Next());
運(yùn)行后結(jié)果如下:
Next()方法用來返回一個隨機(jī)數(shù)。同樣的代碼你執(zhí)行和我的結(jié)果很可能不一樣,而且我多次運(yùn)行的結(jié)果也很可能不一樣,這就是隨機(jī)數(shù)。
一、陷阱
看似很簡單的東西,使用的時候有陷阱。我編寫下面的代碼想生成100個隨機(jī)數(shù):
- for(int i=0;i<100;i++)
- {
- Random rand = new Random();
- Console.WriteLine(rand.Next());
- }
太奇怪了,竟然生成的"隨機(jī)數(shù)"有好多連續(xù)一樣的,這算什么"隨機(jī)數(shù)"呀。有人指點(diǎn)"把new Random()"放到for循環(huán)外面就可以了:
- Random rand = newRandom();
- for(int i=0;i<100;i++)
- {
- Console.WriteLine(rand.Next());
- }
運(yùn)行結(jié)果:
確實(shí)可以了!
二、這是為什么呢?
這要從計(jì)算機(jī)中"隨機(jī)數(shù)"產(chǎn)生的原理說起了。我們知道,計(jì)算機(jī)是很嚴(yán)格的,在確定的輸入條件下,產(chǎn)生的結(jié)果是唯一確定的,不會每次執(zhí)行的結(jié)果不一樣。那么怎么樣用軟件實(shí)現(xiàn)產(chǎn)生看似不確定的隨機(jī)數(shù)呢?
生成隨機(jī)數(shù)的算法有很多種,最簡單也是最常用的就是 "線性同余法": 第n+1個數(shù)=(第n個數(shù)*29+37) % 1000,其中%是"求余數(shù)"運(yùn)算符。很多像我一樣的人見了公式都頭疼,我用代碼解釋一下吧,MyRand是一個自定義的生成隨機(jī)數(shù)的類:
- 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;
- }
- }
如下調(diào)用:
- MyRand rand = newMyRand(51);
- for (int i = 0; i < 10; i++)
- {
- Console.WriteLine(rand.Next());
- }
執(zhí)行結(jié)果如下:
生成的數(shù)據(jù)是不是看起來"隨機(jī)"了。簡單解釋一下這個代碼:我們創(chuàng)建MyRand的一個對象,然后構(gòu)造函數(shù)傳遞一個數(shù)51,這個數(shù)被賦值給seed,每次調(diào)用Next方法的時候根據(jù)(seed * 29 + 37) % 1000計(jì)算得到一個隨機(jī)數(shù),把這個隨機(jī)數(shù)賦值給seed,然后把生成的隨機(jī)數(shù)返回。這樣下次再調(diào)用Next()的時候seed就不再是51,而是上次生成的隨機(jī)數(shù)了,這樣就看起來好像每一次生成的內(nèi)容都很"隨機(jī)"了。注意"%1000"取余預(yù)算的目的是保證生成的隨機(jī)數(shù)不超過1000。
當(dāng)然無論是你運(yùn)行還是我每次運(yùn)行,輸出結(jié)果都是一樣的隨機(jī)數(shù),因?yàn)楦鶕?jù)給定的初始數(shù)據(jù)51,我們就可以依次推斷下來下面生成的所有"隨機(jī)數(shù)"是什么都可以算出來了。這個初始的數(shù)據(jù)51就被稱為"隨機(jī)數(shù)種子",這一系列的516、1、66、951、616……數(shù)字被稱為"隨機(jī)數(shù)序列"。我們把51改成52,就會有這樣的結(jié)果:
三、樓主好人,跪求種子
那么怎么可以使得每次運(yùn)行程序的時候都生成不同的"隨機(jī)數(shù)序列"呢?因?yàn)槲覀兠看螆?zhí)行程序時候的時間很可能不一樣,因此我們可以用當(dāng)前時間做"隨機(jī)數(shù)種子"
- MyRand rand = newMyRand(Environment.TickCount);
- for (int i = 0; i < 10; i++)
- {
- Console.WriteLine(rand.Next());
- }
Environment.TickCount為"系統(tǒng)啟動后經(jīng)過的微秒數(shù)"。這樣每次程序運(yùn)行的時候Environment.TickCount都不大可能一樣(靠手動誰能一微秒內(nèi)啟動兩次程序呢),所以每次生成的隨機(jī)數(shù)就不一樣了。
當(dāng)然如果我們把new MyRand(Environment.TickCount)放到for循環(huán)中:
- for (int i = 0; i < 100; i++)
- {
- MyRand rand = newMyRand(Environment.TickCount);
- Console.WriteLine(rand.Next());
- }
運(yùn)行結(jié)果又變成"很多是連續(xù)"的了,原理很簡單:由于for循環(huán)體執(zhí)行很快,所以每次循環(huán)的時候Environment.TickCount很可能還和上次一樣(兩行簡單的代碼運(yùn)行用不了一毫秒那么長事件),由于這次的"隨機(jī)數(shù)種子"和上次的"隨機(jī)數(shù)種子"一樣,這樣Next()生成的第一個"隨機(jī)數(shù)"就一樣了。從"-320"變成"-856"是因?yàn)檫\(yùn)行到"-856"的時候時間過了一毫秒。
#p#
四、各語言的實(shí)現(xiàn)
我們看到.Net的Random類有一個int類型參數(shù)的構(gòu)造函數(shù):
public Random(int Seed)
就是和我們寫的MyRand一樣接受一個"隨機(jī)數(shù)種子"。而我們之前調(diào)用的無參構(gòu)造函數(shù)就是給Random(int Seed)傳遞Environment.TickCount類進(jìn)行構(gòu)造的,代碼如下:
public Random() : this(Environment.TickCount)
{
}
這下我們終于明白最開始的疑惑了。
同樣道理,在C/C++中生成10個隨機(jī)數(shù)不應(yīng)該如下調(diào)用:
- int i;
- for(i=0;i<10;i++)
- {
- srand( (unsigned)time( NULL ) );
- printf("%d\n",rand());
- }
而應(yīng)該:
- srand( (unsigned)time( NULL ) ); //把當(dāng)前時間設(shè)置為"隨機(jī)數(shù)種子"
- int i;
- for(i=0;i<10;i++)
- {
- printf("%d\n",rand());
- }
五、"奇葩"的Java
Java學(xué)習(xí)者可能會提出問題了,在Java低版本中,如下使用會像.Net、C/C++中一樣產(chǎn)生相同的隨機(jī)數(shù):
- for(int i=0;i<100;i++)
- {
- Random rand = new Random();
- System.out.println(rand.nextInt());
- }
因?yàn)榈桶姹綣ava中Rand類的無參構(gòu)造函數(shù)的實(shí)現(xiàn)同樣是用當(dāng)前時間做種子:
public Random() { this(System.currentTimeMillis()); }
但是在高版本的Java中,比如Java1.8中,上面的"錯誤"代碼執(zhí)行卻是沒問題的:
為什么呢?我們來看一下這個Random無參構(gòu)造函數(shù)的實(shí)現(xiàn)代碼:
- 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);
這里不再是使用當(dāng)前時間來做"隨機(jī)數(shù)種子",而是使用System.nanoTime()這個納秒級的時間量并且和采用原子量AtomicLong根據(jù)上次調(diào)用構(gòu)造函數(shù)算出來的一個數(shù)做異或運(yùn)算。關(guān)于這段代碼的解釋詳細(xì)參考這篇文章《解密隨機(jī)數(shù)生成器(2)——從java源碼看線性同余算法》
最核心的地方就在于使用static變量AtomicLong來記錄每次調(diào)用Random構(gòu)造函數(shù)時使用的種子,下次再調(diào)用Random構(gòu)造函數(shù)的時候避免和上次一樣。
六、高并發(fā)系統(tǒng)中的問題
前面我們分析了,對于使用系統(tǒng)時間做"隨機(jī)數(shù)種子"的隨機(jī)數(shù)生成器,如果要產(chǎn)生多個隨機(jī)數(shù),那么一定要共享一個"隨機(jī)數(shù)種子"才會避免生成的隨機(jī)數(shù)短時間之內(nèi)生成重復(fù)的隨機(jī)數(shù)。但是在一些高并發(fā)的系統(tǒng)中一個不注意還會產(chǎn)生問題,比如一個網(wǎng)站在服務(wù)器端通過下面的方法生成驗(yàn)證碼:
Random rand = new Random();
Int code = rand.Next();
當(dāng)網(wǎng)站并發(fā)量很大的時候,可能一個毫秒內(nèi)會有很多個人請求驗(yàn)證碼,這就會造成這幾個人請求到的驗(yàn)證碼是重復(fù)的,會給系統(tǒng)帶來潛在的漏洞。
再比如我今天看到的一篇文章《當(dāng)隨機(jī)不夠隨機(jī):一個在線撲克游戲的教訓(xùn)》里面就提到了"由于隨機(jī)數(shù)產(chǎn)生器的種子是基于服務(wù)器時鐘的,黑客們只要將他們的程序與服務(wù)器時鐘同步就能夠?qū)⒖赡艹霈F(xiàn)的亂序減少到只有 200,000 種。到那個時候一旦黑客知道 5 張牌,他就可以實(shí)時的對 200,000 種可能的亂序進(jìn)行快速搜索,找到游戲中的那種。所以一旦黑客知道手中的兩張牌和 3 張公用牌,就可以猜出轉(zhuǎn)牌和河牌時會來什么牌,以及其他玩家的牌。"
這種情況有如下幾種解決方法:
-
把Random對象作為一個全局實(shí)例(static)來使用。Java中Random是線程安全的(內(nèi)部進(jìn)行了加鎖處理);.Net中Random不是線程安全的,需要加鎖處理。不過加鎖會存在會造成處理速度慢的問題。而且由于初始的種子是確定的,所以攻擊者存在著根據(jù)得到的若干隨機(jī)數(shù)序列推測出"隨機(jī)數(shù)種子"的可能性。
-
因?yàn)槊看紊蒅uid的值都不樣,網(wǎng)上有的文章說可以創(chuàng)建一個Guid計(jì)算它的HashCode或者M(jìn)D5值的方式來做種子: new Random(Guid.NewGuid().GetHashCode()) 。但是我認(rèn)為Guid的生成算法是確定的,在條件充足的情況下也是可以預(yù)測的,這樣生成的隨機(jī)數(shù)也有可預(yù)測的可能性。當(dāng)然只是我的猜測,沒經(jīng)過理論的證明。
-
采用"真隨機(jī)數(shù)發(fā)生器",快看下一節(jié)分解!
七、真隨機(jī)數(shù)發(fā)生器
根據(jù)我們之前的分析,我們知道這些所謂的隨機(jī)數(shù)不是真的"隨機(jī)",只是看起來隨機(jī),因此被稱為"偽隨機(jī)算法"。在一些對隨機(jī)要求高的場合會使用一些物理硬件采集物理噪聲、宇宙射線、量子衰變等現(xiàn)實(shí)生活中的真正隨機(jī)的物理參數(shù)來產(chǎn)生真正的隨機(jī)數(shù)。
當(dāng)然也有聰明的人想到了不借助增加"隨機(jī)數(shù)發(fā)生器"硬件的方法生成隨機(jī)數(shù)。我們操作計(jì)算機(jī)時候鼠標(biāo)的移動、敲擊鍵盤的行為都是不可預(yù)測的,外界命令計(jì)算機(jī)什么時候要執(zhí)行什么進(jìn)程、處理什么文件、加載什么數(shù)據(jù)等也是不可預(yù)測的,因此導(dǎo)致的CPU運(yùn)算速度、硬盤讀寫行為、內(nèi)存占用情況的變化也是不可預(yù)測的。因此如果采集這些信息來作為隨機(jī)數(shù)種子,那么生成的隨機(jī)數(shù)就是不可預(yù)測的了。
在Linux/Unix下可以使用"/dev/random"這個真隨機(jī)數(shù)發(fā)生器,它的數(shù)據(jù)主來來自于硬件中斷信息,不過產(chǎn)生隨機(jī)數(shù)的速度比較慢。
Windows下可以調(diào)用系統(tǒng)的CryptGenRandom()函數(shù),它主要依據(jù)當(dāng)前進(jìn)程Id、當(dāng)前線程Id、系統(tǒng)啟動后的TickCount、當(dāng)前時間、QueryPerformanceCounter返回的高性能計(jì)數(shù)器值、用戶名、計(jì)算機(jī)名、CPU計(jì)數(shù)器的值等等來計(jì)算。和"/dev/random"一樣CryptGenRandom()的生成速度也比較慢,而且消耗比較大的系統(tǒng)資源。
當(dāng)然.Net下也可以使用RNGCryptoServiceProvider 類(System.Security.Cryptography命名空間下)來生成真隨機(jī)數(shù),根據(jù)StackOverflow上一篇帖子介紹RNGCryptoServiceProvider 并不是對CryptGenRandom()函數(shù)的封裝,但是和CryptGenRandom()原理類似。
八、總結(jié)
有人可能會問:既然有"/dev/random" 、CryptGenRandom()這樣的"真隨機(jī)數(shù)發(fā)生器",為什么還要提供、使用偽隨機(jī)數(shù)這樣的"假貨"?因?yàn)榍懊嫣岬搅?quot;/dev/random" 、CryptGenRandom()生成速度慢而且比較消耗性能。在對隨機(jī)數(shù)的不可預(yù)測性要求低的場合,使用偽隨機(jī)數(shù)算法即可,因?yàn)樾阅鼙容^高。對于隨機(jī)數(shù)的不可預(yù)測性要求高的場合就要使用真隨機(jī)數(shù)發(fā)生器,真隨機(jī)數(shù)發(fā)生器硬件設(shè)備需要考慮成本問題,而"/dev/random"、CryptGenRandom()則性能較差。
萬事萬物都沒有完美的,沒有絕對的好,也沒有絕對的壞,這才是多元世界美好的地方。


2011-07-08 15:11:03




