The 100 problems, solved / Schedulers, limiters and concurrency / 033 · Design a thread-safe singleton and justify the alternative

Design a thread-safe singleton and justify the alternative

Lazy, safe under races, testable, and why injecting one instance is usually better.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

Some object must exist exactly once per process: a configuration loaded from disk, a connection pool, a metrics registry. Any thread that asks gets the same instance; the instance is created on first use, not at import time, and two threads asking at the same instant must not create two. Tests must be able to run with a different instance. Then the second half of the question: explain when you would not use a singleton, and what you would do instead.

Out of scope: process-wide singletons across forks or interpreters, and serialisation of the instance.

Constraints: the fast path (instance already exists) should not take a lock, because it runs on every call; construction must be safe under a race; and the design must leave the code testable, which is exactly where singletons usually fail.

2. Core entities and relationships

  • Config: the class that offers instance(), with a class-level slot and lock.
  • ReportService: a consumer written the other way: it receives a Config and a Clock in its constructor and holds no global.
  • Clock, SystemClock, FixedClock: the protocol and two implementations that show why injection pays off in tests.
  • Container: the composition root. It is where "one of these per process" is decided, as a scope, rather than hard-wired into the class.
Global wayInjected wayCollaboratorscreatesConfig_instance: Config | None_lock: Lockvalues: dictinstance() -> Configreset_for_tests()ReportService_config: Config_clock: Clockbuild() -> strContainer_config, _clockreport_service()Clocknow() -> floatSystemClockFixedClock_at: float

Note what the diagram says: nothing points at Config.instance(). The service aggregates a Config it was given; the container decides that there is one. Singleton-ness has moved from the class to the wiring.

3. Interfaces and key methods

class Config:
    """Loaded once, read by everyone. Two ways to get it:"""
    @classmethod
    def instance(cls) -> "Config": ...       # lazy, thread-safe singleton
    @classmethod
    def reset_for_tests(cls) -> None: ...    # the hook a singleton needs, and a smell

class Clock(Protocol):
    def now(self) -> float: ...

class ReportService:
    """The alternative: receive collaborators, hold no global."""
    def __init__(self, config: Config, clock: Clock) -> None: ...
    def build(self) -> str: ...

Invariants: after the first successful instance() call, every call returns the same object until reset_for_tests; the constructor runs at most once between resets; ReportService never reads a global.

4. Design patterns and the choices behind them

Double-checked locking, and why the first check is safe here

The force is cost on the hot path. A lock on every instance() call serialises readers. The pattern reads the slot without the lock, and only if it is empty takes the lock and checks again, so construction happens once and later callers never lock. In Python the global interpreter lock makes the unsynchronised read safe; in Java the slot must be volatile or the pattern is broken, and saying that is the mark of understanding rather than recall (see locks and mutexes).

Why the alternative is injection

A singleton is a global variable with a nicer name. Every class that calls Config.instance() now has a hidden dependency: you cannot see it in the constructor, cannot substitute it in a test without a reset hook, and cannot run two configurations in one process (for example, a test suite that checks prod and staging behaviour). Dependency injection fixes all three: the dependency is visible, swappable and scoped by whoever builds the object graph. The singleton pattern was not rejected outright; it is kept for truly process-level resources that no test needs to vary, such as a logging sink.

Protocols for the collaborators

Clock is a protocol so FixedClock can stand in without inheritance; this is the same seam used by every time-dependent class in these notes, and the reason _demo can assert an exact string (see designing for unit tests).

5. Core flow and code

  1. Eight threads call Config.instance() at once. Each reads the empty slot, so each tries the lock.
  2. The first thread inside the lock sees the slot still empty, constructs Config and stores it.
  3. Each later thread enters the lock, re-reads the slot, finds it filled, and returns the same object; the assertion that only one id was seen passes.
  4. Separately, a test builds ReportService(Config(test values), FixedClock(fixed time)) and checks the exact output without touching any global or calling a reset.
  5. In production, Container builds one Config and one SystemClock and hands them to every service it creates: one instance, by construction, not by class decree.
import threading, time
from typing import Protocol

class Config:
    _instance: "Config | None" = None
    _lock = threading.Lock()

    def __init__(self, source: dict[str, str] | None = None) -> None:
        self.values = dict(source or {"region": "asia-south1", "tier": "prod"})

    @classmethod
    def instance(cls) -> "Config":
        # Double-checked locking: the fast path reads without the lock,
        # the slow path re-checks under the lock so only one thread constructs.
        inst = cls._instance
        if inst is None:
            with cls._lock:
                inst = cls._instance
                if inst is None:
                    inst = cls._instance = cls()
        return inst

    @classmethod
    def reset_for_tests(cls) -> None:
        with cls._lock:
            cls._instance = None


class Clock(Protocol):
    def now(self) -> float: ...

class SystemClock:
    def now(self) -> float:
        return time.time()

class FixedClock:
    def __init__(self, at: float) -> None:
        self._at = at
    def now(self) -> float:
        return self._at


class ReportService:
    """Dependency injection: the caller decides which Config and Clock to use."""

    def __init__(self, config: Config, clock: Clock) -> None:
        self._config = config
        self._clock = clock

    def build(self) -> str:
        return f"{self._config.values['region']} at {int(self._clock.now())}"


class Container:
    """A tiny composition root: one place that wires singletons by scope, not by class."""

    def __init__(self) -> None:
        self._config = Config()
        self._clock: Clock = SystemClock()

    def report_service(self) -> ReportService:
        return ReportService(self._config, self._clock)


def _demo() -> None:
    # Singleton: every thread sees the same object.
    seen: set[int] = set()
    def grab() -> None:
        seen.add(id(Config.instance()))
    threads = [threading.Thread(target=grab) for _ in range(8)]
    for t in threads: t.start()
    for t in threads: t.join()
    assert len(seen) == 1

    # Injection: a test swaps the clock without touching globals.
    svc = ReportService(Config({"region": "test"}), FixedClock(1_700_000_000))
    assert svc.build() == "test at 1700000000"

6. Extensions, concurrency and what interviewers probe

  • "Is the first unlocked read safe?": in CPython yes, because attribute reads are atomic under the GIL; in C++ or Java you need an atomic or volatile slot, otherwise a thread can see a non-null reference to a half-built object.
  • Module-level instance: Python's simplest singleton is a module global created at import; it is thread-safe because imports are locked, but eager, so a Config that reads disk at import slows every program that imports it.
  • Testing a singleton: the reset hook in the code is the admission that the design leaks; interviewers ask for it and then ask why the service version does not need one.
  • Scopes beyond process: per-request or per-tenant instances are impossible with a class singleton and trivial with a container that creates one per scope.
  • Test first: the eight-thread race in _demo; then that ReportService output changes when a different Config is injected, proving no hidden global.
  • Common mistake: taking the lock on every call, or making the constructor itself private and then being unable to build a test instance at all.

02Where this question was reported

  • Medium · Design a singleton; follow-ups on thread safety