我看到 Nick Chapsas 近日發布的一個 Youtube 影片,裡面展示了 Random
類別在多執行緒應用程式中可能產生的問題,覺得有點意思,便照著他的範例做了一點測試和筆記。
匆忙的人可以先看以下重點:
- 在多執行緒環境中使用
Random
類別來產生隨機數字可能會出現非預期的(不正確的)結果。非預期的結果指的是:產生的隨機數字會有許多是 0(那就不隨機啦)。.NET 5 (以及更早期的 .NET Framework)的Random
類別皆有此缺陷。 - .NET 6 的
Random
類別已經有針對執行緒安全的問題做出改進,但直到 .NET 7 仍有一點小坑(主要還是為了跟舊版 API 相容,詳見內文)。 - 在 .NET 6 和 .NET 7,使用
Random
類別的預設建構子便可放心執行於多執行緒的應用程式;但若使用另一個帶參數的建構子,則必須注意執行緒安全的問題。
範例程式
使用以下範例程式來分別觀察執行於 .NET 5、6、7 的結果有何差異:
說明:
- 第 9 行:在進入平行處理的迴圈之前,建立一個共用的
Random
物件。注意這裡使用的是預設建構子,而不是使用其他版本(帶參數)的建構子。 - 第 10 行:使用
Parallel.For
來建立 16 個平行處理的工作。 - 第 12~16 行:使用先前建立的
Random
物件來產生 10,000 個隨機數字,並將數字保存於整數陣列numbers
中。 - 第 18~19 行:檢查
numbers
陣列中有幾個 0。正常情況下,隨機函數不應該產生一個以上的 0。這裡將 0 的個數顯示於 Console,以便觀察執行結果是否正常。
.NET 5(以及更早的版本)
我已經把範例程式放到 .NET Fiddle 網站上,你可以直接點以下連結來查看此範例程式執行於 .NET 4.7.2 的結果。
https://dotnetfiddle.net/iUqc7Z
執行結果如下圖:
.NET 6 與 .NET 7
如果將前面範例程式的 Target Framework 改成 .NET 6 或 .NET 7,則不會有剛才的問題。執行結果如下圖:
同樣的,你也可以點以下連結來查看 .NET Fiddle 的執行結果:
https://dotnetfiddle.net/QfPqtV
接著在上面的 .NET Fiddle 頁面中修改程式碼,原本建立 Random
物件時使用預設建構子,現在改用另一個版本,也就是需要傳入 seed
參數的建構子,例如:
var rng = new Random(123);
然後執行看看,結果隨機函數又產生一堆 0 了(跟剛才執行於 .NET 5 平台的結果類似)。
🤔為什麼在 .NET 6 和 .NET 7 平台上,使用 Random
的預設建構子就可以正常執行於多執行緒的情境,改用 Random(int seed)
就會產生一堆 0 呢?
把 Random
類別的原始碼找出來看,可以發現兩個版本的建構子使用了不同的實作:
若使用 Random
類別的預設建構子,其內部會透過 XoshiroImpl
來負責產生隨機數字;這個實作是具備執行緒安全的。
不過,我們可以從原始碼發現一個例外情形:如果使用繼承自 Random
的子類別來建立物件,那麼即便是預設建構子,也一樣會使用相容於 .NET 5 時代的舊版實作 Net5CompatSeedImpl
(亦即多執行緒環境下產生的隨機數字會有一堆都是 0)。
如果使用 Random(int seed)
來建立物件,則一律使用相容於舊版的實作。這就是為什麼剛才把範例程式改成使用帶參數版本的建構子之後,執行結果會跑出一堆 0 的原因。
其他解法
在 .NET 6 和 .NET 7,除了使用 Random
的預設建構子,還有一個寫法是把建立 Random
物件的工作移至 Parallel.For() 的委派方法中:
// 原本寫在這裡的 var rng = new Random();
Parallel.For(0, 16, _ =>
{
// 改移到這裡:
var rng = new Random();
var numbers = new int[10000];
...
}
原先範例的寫法只建立一個 Random
物件給多個執行緒共用,修改之後則變成每一個工作執行緒都有自己專用的 Random
物件。此解法雖然能夠解決多執行緒情境下產生一堆 0 的問題,卻會增加一些記憶體用量。微軟文件也有提到這點:
另一個簡單解法是 .NET 6 替 Random
類別新增的 Shared
靜態屬性:
Parallel.For(0, 16, _ =>
{
var numbers = new int[10000];
for (var i = 0; i < numbers.Length; i++)
{
numbers[i] = Random.Shared.Next();
}
...
}
如果你的程式是執行於 .NET 5 或更舊的 .NET Framework,或者你需要替 Random
物件明白指定一個種子,那就得另外想辦法了。一個通用的解決之道,是採用同步機制(synchronization)來確保每次(同一時間)只有一條執行緒能夠產生隨機數字。這部分的細節就不展開了,詳情可參考微軟文件:Random Class 或 Andrew Lock 的文章:Working with System.Random and threads safely in .NET Core and .NET Framework。
Happy coding!
沒有留言: