学无先后达者为师!
不忘初心,砥砺前行。

如何在 .NET Framework 中实现一个线程安全的 System.Random 对象?

System.Random 类表示伪随机数生成器,这是一种能够产生满足某些随机性统计要求的数字序列的算法。

var rand = new Random();
var i = rand.Next();
Console.WriteLine(i);

如果要在多线程环境下使用上述代码:

Parallel.For(0, 10, x =>
{
	var rand = new Random();
	var i = rand.Next();
	Console.WriteLine(i);
});

在 .NET Framework 平台上,会产生相同的输出(即所有的随机结果都是相同的):

如果目标平台是 .NET 6.0 及以上,可以用以下代码替代:

Parallel.For(0, 10, x =>
{
	var rand = Random.Shared;
	var i = rand.Next();
	Console.WriteLine(i);
});

此时,输出结果是符合预期的。

实际上,如果在 .NET 6.0 平台上即使每次都创建新的 Random 对象,输出结果也是符合预期的:

为了可以在 .NET Framework 及 .NET 6.0 之前的平台上获取正确的输出, 可以先尝试在多个线程中共用一个 Random 对象:

Console.WriteLine("Dotnet Version: " + Environment.Version);
var rand = new Random();
Parallel.For(0, 10, x =>
{
	var i = rand.Next();
	Console.WriteLine(i);
});

这种方式似乎可以,因为小规模测试获得了正确的结果:

以下代码创建了 10 个并发,并在每个并发中尝试获取了 10000 个随机数。然后将结果为零的数据统计出来:

Console.WriteLine("Dotnet Version: " + Environment.Version);
var rand = new Random();
Parallel.For(0, 10, _ =>
{
	var numbers = new int[10_000];
	for (int i = 0; i < numbers.Length; ++i)
	{
		numbers[i] = rand.Next(); //获取 10000 个随机数,引发线程安全问题。
	}

	var numZeros = numbers.Count(x => x == 0); // 统计异常数据
	Console.WriteLine($"得到 {numZeros} 个为零的结果");
});

运行结果如下:

这表明:System.Random 在高并发下确实出现了异常。如果使用了 Release 发布,则异常结果会更多。

如果我们愿意用自己的类型来包装 Random,我们可以创建一个更好的解决方案。在下面的示例中,我们使用 [ThreadStatic] 为每个线程创建一个 Random 实例。这意味着我们重复使用 Random 实例,但是所有的访问总是来自单线程,因此保证了线程安全。这样我们最多创建 n 个实例,其中 n 是线程数。

using System;
internal static class ThreadLocalRandom
{
    [ThreadStatic]
    private static Random _local; 

    public static Random Instance
    {
        get
        {
            if (_local is null)
            {
                _local = new Random();
            }

            return _local;
        }
    }
}

使用方式如下:

Console.WriteLine("Dotnet Version: " + Environment.Version);
Parallel.For(0, 10, _ =>
{
	var numbers = new int[10_000];
	for (int i = 0; i < numbers.Length; ++i)
	{
		numbers[i] = ThreadLocalRandom.Instance.Next(); //获取 10000 个随机数,引发线程安全问题。
	}

	var numZeros = numbers.Count(x => x == 0); // 统计异常数据
	Console.WriteLine($"得到 {numZeros} 个为零的结果");
});

请注意,在这个简单的示例中,如果在线程之间传递 ThreadLocalRandom.Instance,仍然有可能遇到线程安全问题。例如,下面显示了与之前相同的问题:

Console.WriteLine("Dotnet Version: " + Environment.Version);
var rand = ThreadLocalRandom.Instance;
Parallel.For(0, 10, _ =>
{
	var numbers = new int[10_000];
	for (int i = 0; i < numbers.Length; ++i)
	{
		numbers[i] = rand.Next(); //获取 10000 个随机数,引发线程安全问题。
	}

	var numZeros = numbers.Count(x => x == 0); // 统计异常数据
	Console.WriteLine($"得到 {numZeros} 个为零的结果");
});

一个简单的方案就是隐藏 Random 实例,仅暴露 Next 方法:

using System;

internal static class ThreadSafeRandom
{
    [ThreadStatic]
    private static Random _local;

    private static Random Instance
    {
        get
        {
            if (_local is null)
            {
                _local = new Random();
            }

            return _local;
        }
    }

    public static int Next() => Instance.Next();
}

但这仍然无法解决在 .NET Framework 上因为系统时钟的分辨率过低造成的重复值问题:

可以看到 1147840056 重复出现了很多次。

要解决该问题,只要在创建 Random 对象时,指定不同的随机种子即可:

using System;

internal static class ThreadSafeRandom
{
    [ThreadStatic]
    private static Random _local;
    private static readonly Random Global = new Random(); // 全局实例,用于生成随机种子

    private static Random Instance
    {
        get
        {
            if (_local is null)
            {
                int seed;
                lock (Global) // 确保 Global 不会被并发访问
                {
                    seed = Global.Next();
                }

                _local = new Random(seed);
            }

            return _local;
        }
    }

    public static int Next() => Instance.Next();
}

如果您使用的是 .NET 6+ ,我仍然建议您使用内置的 Random.Shared,但如果您没有那么幸运,您可以使用 ThreadSafeRandom 来解决您的问题。

如果您的目标是 .NET 6 和其他框架,您可以使用 #if 指令将您的 .NET 6 实现委托给 Random.Shared ,从而保持调用干净。

赞(0) 打赏
未经允许不得转载:码农很忙 » 如何在 .NET Framework 中实现一个线程安全的 System.Random 对象?

评论 抢沙发

给作者买杯咖啡

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫

登录

找回密码

注册