在多執行緒程式中使用 Random 類別的注意事項

我看到 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!

沒有留言:

技術提供:Blogger.
回頂端⬆️