DEV Community

Cover image for Design Patterns #2: You don't know Singleton.
Serhii Korol
Serhii Korol

Posted on

Design Patterns #2: You don't know Singleton.

Every software developer encounters this pattern regularly, often working with it on a daily basis. In this article, I’ll explore the different types of Singleton patterns, their implementation nuances, and how they impact performance. Let’s dive in!

Classic implementation.

This implementation is widely regarded as an antipattern. While it may seem convenient at first glance, it often leads to issues. Let’s break down why this approach can be problematic and explore better alternatives.

public sealed class SingletonClassic
{
    private static SingletonClassic? _instance;

    public static SingletonClassic Instance
    {
        get
        {
            Logger.Info("Instance called.");
            return _instance ??= new SingletonClassic();
        }
    }

    private SingletonClassic()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Why is this considered an antipattern? The primary reason is that it’s not thread-safe. To demonstrate this, let’s put it to the test. I’ll run three tasks in parallel and show how the lack of thread safety can lead to unexpected behavior or even failures. Let’s verify this step by step.

public static void RunClassic()
    {
        var tasks = new List<string>() { "one", "two", "three" };
        Parallel.ForEach(tasks,_ =>
        {
            new List<SingletonClassic>().Add(SingletonClassic.Instance!);
        });
    }
Enter fullscreen mode Exit fullscreen mode

As you can see from the results, the Singleton instance was created twice, which defeats the entire purpose of the pattern. This clearly demonstrates the lack of thread safety in the implementation. When multiple threads access the Singleton simultaneously, they can bypass the instance check, leading to the creation of multiple instances. This not only violates the Singleton principle but can also cause inconsistent behavior in your application.

classic

If you examine the results in dotTrace, you’ll notice the timings and behavior of the SingletonClassic..ctor() constructor. Surprisingly, the constructor appears to have been called only once. But why does this happen? Here’s the explanation:

When multiple threads access the Singleton simultaneously, the first thread checks if the instance is NULL and proceeds to create a new instance. However, before the first thread completes and assigns the instance to the static field, a second thread also checks for NULL and finds it to be true, leading it to create another instance. Both threads then save their instances to the static field, but only the last one overwrites the previous value. As a result, when you retrieve the instance, you get the last one saved, even though two instances were actually created.

The profiler, in this case, only shows the timing for the last constructor call, masking the fact that multiple instances were created. This behavior highlights the critical thread-safety issue in the classic Singleton implementation. To address this, we need to implement proper synchronization mechanisms, which we’ll explore next.

classic trace

Thread-safe implementation with obsolete lock.

Let’s explore how to fix this and ensure true thread safety.

public sealed class SingletonOldLock
{
    private static SingletonOldLock? _instance;
    private static readonly object padlock = new ();

    public static SingletonOldLock? Instance
    {
        get
        {
            Logger.Info("Instance called.");
            lock (padlock)
            {
                return _instance ??= new SingletonOldLock();
            }
        }
    }

    private SingletonOldLock()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we’ve used the lock keyword to synchronize threads, ensuring that only one instance of the Singleton is created. While this approach produces the correct result, it’s far from ideal. Using a lock with an empty object is now considered outdated, and this method introduces performance overhead. Every time the instance is accessed, the lock is acquired, which can lead to unnecessary contention and slowdowns, especially in high-concurrency scenarios.

A more modern and efficient approach would be to leverage alternatives like Lazy or double-check locking with volatile to minimize performance penalties while maintaining thread safety. Let’s explore these better solutions to achieve a robust and performant Singleton implementation.

old lock

However, this method performs slightly slower compared to the previous implementation. While the lock ensures thread safety, it introduces additional overhead due to the synchronization mechanism. Every access to the Singleton instance requires acquiring and releasing the lock, which can create contention and slow down performance, especially in high-concurrency environments.

old trace

Enchenced thread-safe implementation with modern class.

The logic remains the same, but instead of using an empty object with the lock keyword, we’ve now adopted the Lock class introduced in .NET 9. This modern approach eliminates the need for the lock keyword entirely, providing a more robust and efficient way to handle synchronization. The Lock class is designed to offer better performance and scalability, making it a superior choice for managing thread safety in high-concurrency scenarios.

By leveraging this new feature, we can achieve the same thread-safe Singleton pattern without the drawbacks of the older lock-based approach. Let’s dive deeper into how the Lock class works and why it’s a better fit for modern applications.

public sealed class SingletonModernLock
{
    private static SingletonModernLock? _instance;
    private static readonly Lock Padlock = new ();

    public static SingletonModernLock Instance
    {
        get
        {
            Logger.Info("Instance called.");

            if (_instance is not null)
                return _instance;

            using (Padlock.EnterScope())
            {
                return _instance ??= new SingletonModernLock();
            }
        }
    }

    private SingletonModernLock()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The result is nearly identical but with one key difference: the constructor is called only at the end of the process.

modern

Despite the improved performance, the Lock class is unnecessary in this scenario. Since the Singleton instance is created only once and accessed in a thread-safe manner, the additional overhead of using the Lock class doesn’t provide any meaningful benefit. In fact, it introduces complexity without adding value.

modern trace

The double check implementation

In this approach, we verify whether instances are NULL before initiating synchronization.

public sealed class SingletonDoubleCheck
{
    private static SingletonDoubleCheck? _instance;
    private static readonly object Padlock = new();

    public static SingletonDoubleCheck? Instance
    {
        get
        {
            Logger.Info("Instance called.");
            if (_instance != null) return _instance;
            lock (Padlock)
            {
                _instance ??= new SingletonDoubleCheck();
            }
            return _instance;
        }
    }

    private SingletonDoubleCheck()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The output remains unchanged.

double

However, the performance is slightly degraded.

double

Eager Initialization

This is the simplest approach: initializing an instance in a static field. While it works reliably, it may lead to unnecessary memory consumption. A new instance is created as soon as the application accesses this type, even if it is not always needed.

public sealed class SingletonEager
{
    private static readonly SingletonEager _instance = new();

    public static SingletonEager Instance
    {
        get
        {
            Logger.Info("Instance called.");
            return _instance;
        }
    }

    private SingletonEager()
    {
        Logger.Info("Constructor invoked.");
    }
}

Enter fullscreen mode Exit fullscreen mode

The result is as expected.

eager

The performance is inferior to previous approaches.

eager trace

Lazy Initialization

This is the preferred approach because the instance is created only when the class is called.

public sealed class SingletonLazy
{
    private static readonly Lazy<SingletonLazy> Lazy = new(() => new SingletonLazy());
    public static SingletonLazy Instance
    {
        get
        {
            Logger.Info("Instance called.");
            return Lazy.Value;
        }
    }

    private SingletonLazy()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The output remains the same.

lazy

The performance is slightly lower than the Lock-based approach.

lazy trace

Volatile initialization

This approach is functional but not recommended due to two key issues. While volatile ensures reads from memory, it does not prevent a race condition where one thread starts creating an instance but has not completed, allowing another thread to see the partially created instance and attempt to use it. Additionally, using the class itself rather than an object for locking can result in a self-locking issue, potentially causing unintended synchronization problems.

public class SingletonVolatile
{
    private static volatile SingletonVolatile? _instance;

    public static SingletonVolatile Instance
    {
        get
        {
            Logger.Info("Instance called.");
            if (_instance != null) return _instance;
            lock (typeof(SingletonVolatile))
            {
                _instance ??= new SingletonVolatile();
            }
            return _instance;
        }
    }

    private SingletonVolatile()
    {
        Logger.Info("Constructor invoked.");
    }
}
Enter fullscreen mode Exit fullscreen mode

With simple logic, it will work effectively.

volatile

The performance is nearly identical to that of the previous approaches.

volatile trace

Lifetime scope

Lastly, this example does not represent a true Singleton.

public class SingletonInjection
{

    public SingletonInjection Instance
    {
        get
        {
            Logger.Info("Instance called.");
            return this;
        }
    }

    public SingletonInjection()
    {
        Logger.Info("Constructor invoked.");
    }
}

    public static void RunInjection()
    {
        var services = new ServiceCollection();
        services.AddSingleton<SingletonInjection>();
        var serviceProvider = services.BuildServiceProvider();
        var tasks = new List<string>() { "one", "two", "three" };
        var singleton = serviceProvider.GetRequiredService<SingletonInjection>();
        Parallel.ForEach(tasks,_ =>
        {
            new List<SingletonInjection>().Add(singleton?.Instance!);
        });
    }
Enter fullscreen mode Exit fullscreen mode

We registered the class as a Singleton within the lifetime scope. The IoC container ensures that only one instance is created. However, it doesn't allow the creation of new instances through the constructor. A public constructor is required for class registration. In this case, the instance will be created first.

di

This approach is more efficient.

di trace

Conclusions

If you need to ensure a single instance, opt for a modern approach with lazy initialization or synchronization. However, if a single instance is not a strict requirement, using IoC is the better approach.

Before we say goodbye, let me share the traditional benchmark.

benchmark

I hope you find this article helpful. Happy coding, and see you next time!

The source code you can find here.

Buy Me A Beer

Top comments (0)