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 forRandom
) thread-safe. However, if you do provide a seed, e.g. usingRandom 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 multipleRandom
instances on different threads, which the previousThreadSafeRandomNetCore
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.