In .NET development, generating random numbers is a fundamental task, encountered in scenarios ranging from simple dice-rolling games to complex scientific simulations. Historically, the Random class has been the go-to solution, but its use in multi-threaded environments posed challenges. With the advent of .NET 6, a new feature was introduced to address these challenges: Random.Shared. This blog post dives deep into the Random class, emphasizing the thread-safe Random.Shared instance, its significance, and best practices for its use.

The Basics of the Random Class

The Random class in .NET serves as a pseudo-random number generator (PRNG), a tool indispensable in software development. Before .NET 6, using Random in a concurrent application required developers to either instantiate a new Random object frequently or manage a single instance across multiple threads, both of which had their pitfalls. Frequent instantiations could lead to performance degradation, while a shared instance risked producing repeatable patterns or errors in a multi-threaded context due to race conditions.

The Random class in .NET provides a variety of methods for generating random numbers and sequences. Here are some of the commonly used methods provided by the Random class:

Generating Integer Values

  • Next(): Returns a non-negative random integer.
int randomNumber = Random.Shared.Next();

Next(int maxValue): Returns a non-negative random integer that is less than the specified maximum value.

int randomNumber = Random.Shared.Next(100); // Random number between 0 and 99.

Next(int minValue, int maxValue): Returns a random integer that is within a specified range.

int randomNumber = Random.Shared.Next(1, 101); // Random number between 1 and 100.

Generating Floating-Point Numbers

  • NextDouble(): Returns a random floating-point number that is greater than or equal to 0.0, and less than 1.0.
double randomDouble = Random.Shared.NextDouble();

NextSingle(): Returns a random floating-point number that is greater than or equal to 0.0f, and less than 1.0f. This method is similar to NextDouble(), but returns a float instead of a double.

float randomFloat = Random.Shared.NextSingle();

Generating Bytes

  • NextBytes(byte[] buffer): Fills the elements of a specified array of bytes with random numbers.
byte[] buffer = new byte[10];
Random.Shared.NextBytes(buffer); // Fills the array with random bytes.

NextBytes(Span<byte> buffer): Fills the elements of a specified span of bytes with random numbers. This overload is useful for working with spans in more performance-critical scenarios.

Span<byte> bufferSpan = new Span<byte>(new byte[10]);
Random.Shared.NextBytes(bufferSpan); // Fills the span with random bytes.

Generating 64-bit Numbers

  • NextInt64(): Returns a non-negative random 64-bit integer.
long randomLong = Random.Shared.NextInt64();

These methods make it easy to generate random numbers and sequences for various purposes, from simple randomization tasks to more complex simulations and algorithms. With Random.Shared, accessing these methods in a thread-safe manner across multiple threads becomes straightforward, enhancing both the reliability and performance of concurrent .NET applications.

The Need for Thread Safety

Thread safety is paramount in concurrent applications to prevent unexpected behavior or data corruption. In the context of random number generation, thread safety ensures that concurrent operations do not lead to duplicate numbers or diminish the randomness due to simultaneous access to shared resources. Prior to .NET 6, achieving thread safety with Random necessitated complex management strategies, such as using thread-local storage or locking mechanisms, which added to the development overhead.

Introducing Random.Shared

.NET 6 brought a significant enhancement to the Random class with the introduction of the Random.Shared property. This static property provides a thread-safe, shared instance of Random that can be used across multiple threads without the need for explicit synchronization. This advancement simplifies the generation of random numbers in multi-threaded applications, reducing the boilerplate code and the risk of errors.

How Does Random.Shared Ensure Thread Safety?

Random.Shared in .NET 6 and later versions achieves thread safety through an ingenious implementation involving the ThreadSafeRandom class, a private sealed class that extends Random. This specialized class is designed to be thread-safe by leveraging thread-local storage, ensuring that each thread accesses its unique instance of the underlying random number generator, thus preventing race conditions without the need for explicit locking mechanisms.

Here’s a closer look at the ThreadSafeRandom implementation strategy:

  • Thread-Local Storage: The ThreadSafeRandom class uses a [ThreadStatic] attribute to declare a thread-local instance of the XoshiroImpl random number generator. This means that each thread has its own separate instance of this generator, avoiding interference between threads. (We will talk about the ThreadStatic Attribute in a future post)
  • Design Choice: The choice to implement Random.Shared using a Random derived class (ThreadSafeRandom) over other methods is intentional. This approach, although requiring some code duplication, allows for virtual method dispatch to be devirtualized and potentially inlined. Consequently, calls to Random.Shared.Next(), NextDouble(), and other methods become faster because they directly access the thread-local XoshiroImpl instance without the overhead of locking.
  • Overridden Methods: ThreadSafeRandom overrides all the generation methods (Next(), NextDouble(), NextBytes(), etc.) to use the thread-local XoshiroImpl. This ensures that each method call is inherently thread-safe, as it operates on a thread-specific instance of the random number generator.
  • Performance Considerations: By avoiding global locks and instead utilizing thread-local storage for each thread’s instance of the random number generator, Random.Shared provides a highly efficient and scalable solution for generating random numbers in multi-threaded environments. This design minimizes contention and maximizes performance, especially in applications with a high degree of parallelism.

Performance Considerations

While Random.Shared offers a convenient and thread-safe way to generate random numbers, it’s essential to consider its performance implications. In scenarios with high concurrency or performance-critical applications, the overhead associated with ensuring thread safety might impact overall performance. Developers should evaluate whether the convenience of Random.Shared aligns with their application’s performance requirements or if alternative strategies, such as managing dedicated Random instances per thread, would be more appropriate.

Let’s see a benchmark:

[MemoryDiagnoser]
public class RandomShared
{
    private static readonly Random singleRandom = new();
    private const int NumberOfIterations = 1000;

    [Benchmark]
    public void SingleRandomInstance()
    {
        Parallel.For(0, NumberOfIterations, _ =>
        {
            int value = singleRandom.Next();
        });
    }

    [Benchmark]
    public void ThreadLocalRandomInstances()
    {
        Parallel.For(0, NumberOfIterations, _ =>
        {
            int value = new Random().Next();
        });
    }

    [Benchmark]
    public void RandomSharedInstance()
    {
        Parallel.For(0, NumberOfIterations, _ =>
        {
            int value = Random.Shared.Next();
        });
    }
}

The results:

MethodMeanErrorStdDevGen0Gen1Allocated
SingleRandomInstance15.199 us0.0625 us0.0584 us0.53414.29 KB
ThreadLocalRandomInstances75.629 us0.8006 us0.7489 us9.15530.122174.61 KB
RandomSharedInstance9.510 us0.0987 us0.0875 us0.54934.51 KB
Benchmarked on .NET 8

In the results above you have to keep in mind that the RandomSharedInstance will create an instance of the Random class for each thread but there will never be any issues with concurrency in contrast to the SingleRandomInstance.

Best Practices and Recommendations

When deciding between Random.Shared and other approaches, consider the application’s concurrency level and performance needs. For general-purpose use and lower-concurrency scenarios, Random.Shared provides a simple and safe way to generate random numbers. In performance-critical applications with high concurrency, consider using dedicated Random instances per thread or exploring other PRNG libraries that offer finer control over performance and thread safety.

Conclusion

The introduction of Random.Shared and its underlying ThreadSafeRandom class in .NET 6 represents a significant advancement in simplifying the generation of random numbers in a thread-safe manner. By understanding the implementation details and the design choices made to achieve thread safety, developers can more effectively leverage this feature in their multi-threaded applications. Whether for simple use cases or high-performance concurrent scenarios, Random.Shared offers a robust and efficient solution for random number generation in .NET.

If you love learning new stuff and want to support me, consider buying a course like Getting Started with C# or by using this link

Leave a comment

Trending