Skip to Content
Course content

243: Whiteboard Practice: Writing Thread-Safe Singleton Implementations

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you're prepping for a technical interview, the Singleton pattern is a classic "whiteboard" request. But here is where most candidates trip up: they assume that as long as they have a lock statement somewhere in their GetInstance method, they've solved the thread-safety problem. I've seen senior devs make this mistake. They think "safe" equals "optimal," but in a high-throughput system, a naive lock is a performance killer.

Thinking a Simple Lock is Enough

Let's look at a common implementation for a GlobalCacheManager. The goal is to ensure we only ever have one cache instance across the entire application to save memory and keep data consistent.

public sealed class GlobalCacheManager
{
    private static GlobalCacheManager _instance;
    private static readonly object _lock = new object();

    private GlobalCacheManager() { }

    public static GlobalCacheManager Instance
    {
        get
        {
            lock (_lock) // The "Naive Lock"
            {
                if (_instance == null)
                {
                    _instance = new GlobalCacheManager();
                }
                return _instance;
            }
        }
    }
}

Technically, this is thread-safe. You won't end up with two caches. But look at the cost. Every single time your application needs to access the cache—which could be thousands of times per second in a web API—it has to acquire a lock. Once the instance is created, there is absolutely no reason to keep locking. You've effectively turned your high-performance cache into a bottleneck where every thread has to wait in line just to read a reference that never changes again.

Optimizing with Double-Checked Locking

To fix this, we use "Double-Checked Locking." The idea is to check if the instance is null before we enter the lock. If it's already initialized, we skip the lock entirely. If it is null, we enter the lock and check again (because another thread might have initialized it in the millisecond it took us to acquire the lock).

public sealed class GlobalCacheManager
{
    private static GlobalCacheManager _instance;
    private static readonly object _lock = new object();

    private GlobalCacheManager() { }

    public static GlobalCacheManager Instance
    {
        get
        {
            if (_instance == null) // First check (no lock)
            {
                lock (_lock)
                {
                    if (_instance == null) // Second check (locked)
                    {
                        _instance = new GlobalCacheManager();
                    }
                }
            }
            return _instance;
        }
    }
}

This is significantly faster. Now, 99.9% of calls bypass the lock entirely. If you're in a whiteboard interview, this is usually the "gold standard" answer they are looking for because it proves you understand concurrency and contention. However, in actual production C# code, I rarely write this by hand anymore.

Letting the Framework Handle It with Lazy<T>

C# provides a type called Lazy<T> that handles all of this boilerplate for you. It's not just cleaner; it's guaranteed to be thread-safe by default and is highly optimized by the .NET team. I always prefer this approach because it removes the possibility of human error (like forgetting the second null check).

public sealed class GlobalCacheManager
{
    // Lazy handles the thread-safety and lazy initialization internally
    private static readonly Lazy<GlobalCacheManager> _lazy = 
        new Lazy<GlobalCacheManager>(() => new GlobalCacheManager());

    public static GlobalCacheManager Instance => _lazy.Value;

    private GlobalCacheManager() 
    {
        // Initialize your cache here
    }
}

When you call _lazy.Value, the runtime checks if the object exists. If it doesn't, it runs the factory method we provided. If multiple threads hit that line at once, Lazy<T> ensures only one thread creates the object while the others wait. It's elegant, concise, and less prone to bugs.




📋 Practical Task

Fixing the Thread-Safety of the AppSettingsRegistry

You have inherited a codebase where a AppSettingsRegistry is used to hold global configuration. The current implementation is not thread-safe, and the team is reporting intermittent NullReferenceExceptions and duplicate initialization logs during application startup.

Your Task: Rewrite the AppSettingsRegistry class to be thread-safe. You may choose either the Double-Checked Locking pattern or the Lazy<T> pattern, but the final implementation must ensure that the constructor is called exactly once, even when accessed by multiple concurrent threads.

public sealed class AppSettingsRegistry
{
    private static AppSettingsRegistry _instance;

    // The constructor currently logs a message when it runs
    private AppSettingsRegistry()
    {
        Console.WriteLine("Initializing AppSettingsRegistry...");
    }

    public static AppSettingsRegistry Instance
    {
        get
        {
            // TODO: Implement thread-safe singleton logic here
            if (_instance == null)
            {
                _instance = new AppSettingsRegistry();
            }
            return _instance;
        }
    }

    public string GetSetting(string key) => $"Value for {key}";
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.