Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Working with System.Random and threads safely in .NET Core and .NET Framework

$
0
0

In this post I look at some of the ways you can misuse System.Random for generating random numbers, specifically around thread safety. I start by showing how to use the built-in thread-safe Random generator in .NET 6. I then step back to previous .NET Core implementations, before the thread-safe Random generator was added, and show how to add your own. Finally, we take one more step back to .NET Framework, and look at he issues that arise there.

tl;dr; If you're using .NET 6, then always use the static property Random.Shared if possible. If (like me) you need to support older version of .NET Core and .NET Framework, then read on!

Generating random numbers from multiple threads in .NET 6+

It's a common requirement to be able to generate some sort of random number in .NET. If you don't need this to be a cryptographically secure random number then System.Random is the class you want. This can generate random bytes, integers, doubles, depending on what you require.

If you do need a cryptographically secure random number then look at the System.Security.Cryptography.RandomNumberGenerator class.

The humble Random class has been around since .NET Framework 1.0, but an important thing to bear in mind is that it's not thread-safe by default. We'll explore this in more detail throughout this post, and look at the various ways you can incorrectly use Random

In .NET 6, however, a new static property was added to Random, called Shared, which avoids all the pitfalls I'm going to describe. If you're generating random numbers in .NET 6+, you don't need to control the "seed" value, and you need thread-safe access, then Random.Shared is the way to go.

The following .NET 6 example is a simple demonstration of how to use Random.Shared. It doesn't rigorously put the thread-safety to the test, it just demonstrates that it's safe to use in parallel scenarios, where thread-safety is a requirement:

using System;
using System.Threading.Tasks;

// Run the lambda in parallel
Parallel.For(0, 10, x =>
{
    // use the shared instance to generate a random number
    var value = Random.Shared.Next(); 
    Console.WriteLine(value);
});

When you run this, as expected, you get a bunch of random numbers printed to the console as you might expect.

345470185
1790891750
1620523217
786846218
2095595672
961899483
1987563145
1728945026
813751074
1542500379

All very standard, but the important point here is that you must use the Random.Shared instance. But what if you're not using .NET 6+? Then you don't have a shared instance. And what about if you need to be able to control the seed used? Random.Shared doesn't let you do this, so you need to take a different approach.

The thread-safety problem in .NET Core / .NET 5

The naïve user of Random that needs a single shared random number generator might be tempted to do something like the following:

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe, don't do it ⚠
Random rng = new(); // create a shared Random instance

Parallel.For(0, 10, x => // run in parallel
{
    var value = rng.Next(); // grab the next random number
    Console.WriteLine(value);
});

If you test this, you likely won't find any obvious problems. Even though this code is not thread-safe, it likely won't show any issues. Until later…

The following sample (based on this SO post) uses brute-force to trigger a thread-safety issue, where multiple threads are running rng.Next() at once. The defined behaviour of Random is to return 0 if a thread-safety issue is detected, so the following code prints out how many thread-safety issues were detected:

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe, don't do it ⚠
Random rng = new(); // create a shared Random instance
Parallel.For(0, 10, x =>  // run in parallel
{
    var numbers = new int[10_000];
    for (int i = 0; i < numbers.Length; ++i)
    {
        numbers[i] = rng.Next(); // Fetch 10,000 random numbers, to trigger the thread-safety issues
    }

    var numZeros = numbers.Count(x => x == 0); // how many issues were there?
    Console.WriteLine($"Received {numZeros} zeroes");
});

If you run this code with dotnet run -c Release (you're less likely to see issues in Debug mode) you'll get output that looks something like this:

Received 8383 zeroes
Received 7851 zeroes
Received 9044 zeroes
Received 8828 zeroes
Received 10000 zeroes
Received 10000 zeroes
Received 4261 zeroes
Received 10000 zeroes
Received 10000 zeroes
Received 10000 zeroes

Yikes! This shows the scale of the problem—in some of those loops, every call to Next() returned 0. When Random says it's not thread-safe, it's really not lying!

As an aside, if you run the above code in .NET 6_, then you will see there aren't any thread safety issues, even though you're not using Random.Shared. This PR made the default constructor case (where you don't specify a seed number for Random) thread-safe. However, if you do provide a seed, e.g. using Random rng = new(seed: 123), you'll see the exact same behaviour!

Ok, so we've established you can't just use a shared Random instance, so what's the solution?

Using Random in a thread-safe manner in .NET Core / .NET 5

One naïve solution is to simply not share any Random instances. For example, you could rewrite the above example to create a new Random instance inside the loop:

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe in .NET Framework, don't do it ⚠
Parallel.For(0, 10, x =>  // run in parallel
{
    Random rng = new(); // 👈 create a private Random instance
    var numbers = new int[10_000];
    for (int i = 0; i < numbers.Length; ++i)
    {
        numbers[i] = rng.Next(); // Fetch 10,000 random numbers
    }

    var numZeros = numbers.Count(x => x == 0); // how many issues were there?
    Console.WriteLine($"Received {numZeros} zeroes"); // always 0 issues
});

As mentioned in the code sample, this isn't safe in .NET Framework, though it is in .NET Core. You'll see why it's not safe later.

The Random instance is created inside the loop, so there are no shared instances and no thread-safety issues:

Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes
Received 0 zeroes

This solution is "fine", but if you're going to be calling this code a lot, you'll be creating a lot of Random instances which need to be garbage collected etc.

We can create a better solution if we're willing to wrap Random in our own type. In the following example, we use [ThreadStatic] to create one instance of Random per thread. That means we do reuse Random instances, but all access is always from a single thread, so it's guaranteed to be thread safe. This way we create at most n instances, where n is the number of threads.

using System;
internal static class ThreadLocalRandom
{
    [ThreadStatic]
    private static Random? _local; // only accessed 

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

            return _local;
        }
    }
}

You could then update your usages as follows:

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe in .NET Framework, don't do it ⚠
Parallel.For(0, 10, x =>  // run in parallel
{
    Random rng = ThreadLocalRandom.Instance; // 👈 Use the shared instance for the tread
    var numbers = new int[10_000];
    for (int i = 0; i < numbers.Length; ++i)
    {
        numbers[i] = rng.Next(); // Fetch 10,000 random numbers
    }

    var numZeros = numbers.Count(x => x == 0); // how many issues were there?
    Console.WriteLine($"Received {numZeros} zeroes"); // always 0 issues
});

Note that with this simple example it's still possible to run into thread-safety issues if you pass the ThreadLocalRandom.Instance between threads. For example, the following shows the same issue as before:

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe any time! Don't do it ⚠
Random rng = ThreadLocalRandom.Instance; // 👈 Using the shared instance for ALL threads ⚠
Parallel.For(0, 10, x =>  // run in parallel
{
    var numbers = new int[10_000];
    for (int i = 0; i < numbers.Length; ++i)
    {
        numbers[i] = rng.Next(); // 👈 Every loop uses the same Random instance
    }

    var numZeros = numbers.Count(x => x == 0); // how many issues were there?
    Console.WriteLine($"Received {numZeros} zeroes"); // Lots and lots of issues!
});

The above example is why I prefer to provide an implementation that completely wraps Random. The following ThreadSafeRandomNetCore wraps the Random methods we need, never exposing the Random instance to callees, so avoids any issues like the ones above. For example, the ThreadSafeRandomNetCore below, doesn't expose Random, it wraps the call to Next() instead:

using System;
// ⚠ This isn't safe in .NET Framework, so don't use it ⚠
internal static class ThreadSafeRandomNetCore
{
    [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();
}

To use this class, simply replace the instance calls to rng.Next() with calls to the static ThreadSafeRandomNetCore.Next()

using System;
using System.Linq;
using System.Threading.Tasks;

// ⚠ This isn't safe in .NET Framework, don't do it ⚠
Parallel.For(0, 10, x =>  // run in parallel
{
    var numbers = new int[10_000];
    for (int i = 0; i < numbers.Length; ++i)
    {
        numbers[i] = ThreadSafeRandomNetCore.Next(); // 👈 Call the static helper instead
    }

    var numZeros = numbers.Count(x => x == 0); // how many issues were there?
    Console.WriteLine($"Received {numZeros} zeroes"); // always 0 issues
});

Note that this is pretty much exactly how Random.Shared is implemented!

We've now tackled all the thread-safety issues, but the elephant in the room that I keep having to mention is that this isn't yet safe on .NET Framework. In the next section we look at why, and how to solve the issue.

The trouble with Random on .NET Framework

The main problem with Random on .NET Framework is well documented, and comes down to two facts:

  • When you call new Random() on. NET Framework, it uses the system-clock to generate a seed value.
  • The system-clock has finite resolution.

These two facts combine to mean that if you call new Random() in quick succession on .NET Framework, you can end up with two Random instances with the same seed value, which means the Random instances will return identical sequence of numbers 😱

You can reproduce this easily by creating a .NET Framework console app:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net461</TargetFramework>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

</Project>

And generating Random instances in quick succession:

using System;
using System.Threading.Tasks;

Parallel.For(0, 10, x =>
{
    Random rnd = new(); // Creating a new instance
    var value = rnd.Next();
    Console.WriteLine(value);
});

When run (dotnet run -c Release), this prints something like the following, with lots of duplicate values:

839229964   #🟩
839229964   #🟩
1258386001  #🟥
2028913929  #🟦
300586319   #⬜
300586319   #⬜
1258386001  #🟥
839229964   #🟩
1258386001  #🟥
2028913929  #🟦

Even though we've created 10 instances, there's only 4 different seed values here. That's Not Good™.

This problem is not solved by the previous ThreadSafeRandomNetCore implementation, as the problem is creating multiple Random instances on different threads, which the previous ThreadSafeRandomNetCore does not guard against.

The solution to this is well known: you create a single Random instance which is solely used to provide seed values for the remaining Random instances. As the seed values are random, the Random instances are uncorrelated, and you can avoid the above issue entirely.

This is the initialization approach used by default in .NET Core, which is why you only see the issue in .NET Framework

We can implement the same approach in the ThreadSafeRandom wrapper class, shown below. This class uses the above approach to create the [ThreadStatic] instances, avoiding the initialization problem in .NET Framework.

#nullable enable
using System;
using System.Threading.Tasks;

internal static class ThreadSafeRandom
{
    [ThreadStatic]
    private static Random? _local;
    private static readonly Random Global = new(); // 👈 Global instance used to generate seeds

    private static Random Instance
    {
        get
        {
            if (_local is null)
            {
                int seed;
                lock (Global) // 👈 Ensure no concurrent access to Global
                {
                    seed = Global.Next();
                }

                _local = new Random(seed); // 👈 Create [ThreadStatic] instance with specific seed
            }

            return _local;
        }
    }

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

With the above approach, you now have a thread-safe implementation that can be used in all versions of the framework. You can update your usages to use ThreadSafeRandom:

using System;
using System.Threading.Tasks;

Parallel.For(0, 10, x =>
{
    var value = ThreadSafeRandom.Next(); // 👈 Use ThreadSafeRandom directly
    Console.WriteLine(value);
});

and all your thread-safety Random issues will melt away, even on .NET Framework!

If you're using .NET 6+, I still suggest you use the built-in Random.Shared, but if you're not so lucky, you can use ThreadSafeRandom to solve your issues. If you're targeting both .NET 6 and other frameworks you could always use #if directives to delegate your .NET 6 implementation to Random.Shared, keeping the call-site cleaner.

Summary

In this post I described some of the thread-safety concerns and issues around the Random type, in various versions of .NET Framework and .NET Core. If you need thread-safe access to a Random instance in .NET 6, then you can use the built-in Random.Shared instance. However, if you're using earlier versions of .NET Core, you need to make sure you don't access the instance concurrently. And if you're using .NET Framework, you need to ensure you don't initialize the Random instances concurrently, but instead use a correctly random seed value.


Viewing all articles
Browse latest Browse all 743

Trending Articles