C#
.NET
.NETFramework
.NETCore

Randomクラスの仕様が.NET Core 2.0で変わっていた話

C#で乱数を取得するのによく使用するRandomクラスについての話です。

これまでのRandomクラス

試しに以下のコードを.NET Frameworkや.NET Core 1.x系、Monoで実行すると(タイミングにもよりますが)全部同じ値が出力されると思います。

for (var i = 0; i < 10; i ++)
{
    Console.WriteLine(new Random().Next());
}

これは結構有名な話でRandomクラスは引数なしのコンストラクタでインスタンス化すると、内部で使用する乱数を生成するためのシード値にEnvironment.TickCount(OSが起動してからの時間)を使用するので、ミリ秒未満でインスタンスが複数生成されると同一のシード値になって生成される乱数も同じになってしまうという現象です。

シングルスレッドで動くアプリケーションなら同時にRandomクラスがほぼ同時に複数インスタンス生成されることがないため問題はないかもしれませんが、Webアプリケーションのようにマルチスレッドで動くアプリケーションの場合はRandomクラスを使っているロジックに同時にアクセスされてしまうとランダム性が保証されなくなってしまいます。

Webアプリケーションを複数台で運用していて、サーバーのプロセスが同時に再起動が走ったときにEnvironment.TickCountがサーバー間で同じ値になってしまうため、各サーバーに同時にアクセスが来た時も同じ乱数を返してしまうためランダム性がなくなってしまいます。

また、引数なしのコンストラクタを使わずにシード値を渡すコンストラクタで常に同じ値を渡すようにしても、インスタンスが生成される度に同じ乱数生成の元の値になってしまうため、これでもランダム性の問題は解決できません。
(試しに以下のコードを実行してみるとループの数だけ同じ値が出力されます。)

for (var i = 0; i < 10; i++)
{
    var random = new Random(0);

    Console.WriteLine(random.Next());
    Console.WriteLine(random.Next());
}

これらのことからRandomクラスをより高いランダム性を確保しつつ使用するには、インスタンスを生成する度にシード値に別の乱数を使用する必要があります。

.NET Core 2.0ではこの問題が解決されているようです。

.NET Core 2.0でのRandom

.NET Core 2.0では引数なしのコンストラクタの仕様が変わっていて、シード値にEnvironment.TickCountではなく、内部で別に乱数を生成してその値をシード値にするようになっています。

そのためさっきのコードを.NET Core 2.0で実行すると全て違う値が出力されるようになると思います。

ちょっとDeep Diveしてみると以下のようなロジックでシード値を生成しています。

[ThreadStatic]
private static Random t_threadRandom;
private static readonly Random s_globalRandom = new Random(GenerateGlobalSeed());

private static int GenerateSeed()
{
    Random rnd = t_threadRandom;
    if (rnd == null)
    {
        int seed;
        lock (s_globalRandom)
        {
            seed = s_globalRandom.Next();
        }
        rnd = new Random(seed);
        t_threadRandom = rnd;
    }
    return rnd.Next();
}

private static unsafe int GenerateGlobalSeed()
{
    int result;
    Interop.GetRandomBytes((byte*)&result, sizeof(int));
    return result;
}

まずプロセスで唯一のRandomインスタンスを乱数を用いたシード値で生成し、スレッドごとにプロセス唯一Randomインスタンスから生成した乱数をシード値したRandomインスタンスを生成し、そのスレッドごとに生成されたRandomインスタンスから生成した乱数をシード値をするようになっています。
(ちなみにプロセス唯一のRandomインスタンスをlockしながら乱数を生成しているのはNext()メソッドがスレッドセーフではないためです。)

ちょっとわかりにくいですが、Randomを生成するためのRandomを生成するためのRandomがあるといった感じになっているようです。

.NET Core 2.0以前では以下のような感じでシード値に使用する乱数を生成していました。(@neueccさんのブログより拝借。)

public int GenerateSeed()
{
    using (var rng = new RNGCryptoServiceProvider())
    {
        var buffer = new byte[sizeof(int)];
        rng.GetBytes(buffer);

        return BitConverter.ToInt32(buffer, 0);
    }
}

ってことでどっちが速いの、というのをBenchmarkDotNetを使って以下のロジックで計測してみました。

public class RandomProvider
{
    [Benchmark]
    public void Default()
    {
        var r = new Random();
    }

    [Benchmark]
    public void Custom()
    {
        using (var rng = new RNGCryptoServiceProvider())
        {
            var buffer = new byte[sizeof(int)];
            rng.GetBytes(buffer);
            var seed = BitConverter.ToInt32(buffer, 0);

            var r = new Random(seed);
        }
    }
}

計測結果は以下の通り。

image.png

まぁ、誤差っちゃ誤差ですけど自前でシード値を生成するよりかはちょっとだけ速いようです。

まとめ

プログラミングにおいてランダム性の確保って結構難しいって言われてるようですが、フレームワークとかライブラリに乱数生成の機能がある場合、それをそのまま使ってる方はいるんじゃないかと思います。

ただその乱数生成が何時如何なるときも本当にランダム性が確保できているかの確認はした方が良さそうですね。

ちなみに.NETのRandomクラスの厄介なこととしてシード値以外にもう一つあって、本文中でもちょっと書いたのですが乱数を取得するメソッドがスレッドセーフじゃないんですね。

この問題は.NET Core 2.0でも変わってないようなのでマルチスレッドで乱数を生成するロジックを書く場合は、RandomクラスをThreadLocalなどでラップして使う必要はあるので注意です。

ちなみに各フレームワークのRandomクラスのソースは以下の通りです。
(.NET Coreは2.0になってmscorlibの関係かcorefxからcoreclrに移ったのが結構あるようで。)
* .NET Framework
* .NET Core 1.0
* .NET Core 1.1
* .NET Core 2.0
* Mono