Dive into the Singleton as an excuse to study Instantiation in Python

python
design pattern
metaclasses
multithreading
Author

x0s

Published

August 11, 2023

Dive into the Singleton as an excuse to study Instantiation in Python

What is it about ?

In this post, we will construct and deconstruct the Singleton Pattern in order to hone our understanding of object instantiation in Python (through Inheritance, Metaprogramming and Multithreading).

From Wikipedia: In software engineering, the singleton pattern is a software design pattern that restricts the instantiation of a class to a singular instance.

As an example, we will try to define as a singleton a class named Logger representing a logging component for an application. As we usually want a logger to be a global ressource for documenting what an application is actually processing.

We will make successive attempts to build such a singleton. On this journey, we will encounter(and tackle) various problems, giving us an opportunity to review instantiation mechanics in Python. Here is a summary on the methods/challenges we will cover :

  • Attempt 1: Inheritance Overriding
  • Attempt 2: Metaclass Calling
  • Attempt 3: Multithreads Racing
  • Attempt 4: Multithreads Deadlocking

Before attempting, let’s write a helper function to check if an object is a singleton or not:

def is_singleton(cls: type) -> None:
    print(f"{cls.__name__} {'is' if cls() is cls() else 'is not'} a Singleton")

Attempt 1: Inheritance Overriding

We define the Singleton as a base class and control instanciation through its __new__ method. The classes we want to make unique will then inherit from it.

In Python, the static __new__ method is responsible for object creation. It seems a good place to force any instanciation to return a unique object. Note that the object returned by __new__ is the one passed to __init__ as self. Let’s try:

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        # If the instance being created(cls) is not already created and cached (cls._instance)
        if not isinstance(cls._instance, cls):
            cls._instance = super().__new__(cls, *args, **kwargs)
        return cls._instance        

class Logger(Singleton): pass

# Let's verify a singleton is generated
is_singleton(Logger)
Logger is a Singleton

It seems to work, but we may encounter some difficulties that may break the singleton:

  • Risk 1: Reversing Bases
    • Python’s Method Resolution Order operates from left to right, Child classes have to first inherit from Singleton to benefit from its __new__ method: class Logger(Singleton, LoggerBase). Reversing order may neutralize the Singleton (if LoggerBase defines its own __new__ method)
  • Risk 2: Overriding __new__
    • If a child class overrides its __new__ method, it redefines instanciation and may produce non-singleton objects

Let’s see:

class LoggerBase: 
    def __new__(cls, *args, **kwargs):
        # Overriding instance creation method __new__
        return object.__new__(cls, *args, **kwargs)

class Logger(Singleton, LoggerBase): pass

# Risk 1: Reversing Bases
class LoggerReversed(LoggerBase, Singleton): pass

# Risk 2: Overriding __new__
class LoggerChild(Logger):
    def __new__(cls, *args, **kwargs):
        return object.__new__(cls, *args, **kwargs)

for LoggerClass in (Logger, LoggerReversed, LoggerChild):
    is_singleton(LoggerClass)
Logger is a Singleton
LoggerReversed is not a Singleton
LoggerChild is not a Singleton

It is a bit annoying that our Singleton can be broken so easily. But in Python, Metaclasses are responsible for Class creation(like class of a class). We will see in next attempt how to use metaclasses to control instantiation upstream to inheritance.

Attempt 2: Metaclass Calling

By default the type Metaclass is responsible for class creation. When we instantiate a new object, the Metaclass’ __call__ method is in fact called before the Class’ __new__ method ! Here is the proof:

class MetaTest(type):
    def __call__(cls, *args, **kwargs):
        print("In MetaTest.__call__")
        return super().__call__(*args, **kwargs)

class Test(metaclass=MetaTest):
    def __new__(cls, *args, **kwargs):
        print("In Test.__new__")
        return super().__new__(cls, *args, **kwargs)
    
    def __init__(self, *args, **kwargs):
        print("In Test.__init__")

_ = Test()
In MetaTest.__call__
In Test.__new__
In Test.__init__

The built-in type.__call__ (line 4), is in charge of:

  • calling Test.__new__ to create a Test object (allocating memory for it)
  • calling Test.__init__ to initialize the newly created object only if
    • Test.__new__ has previously returned an object of type Test and
    • Test.__init__ is defined

Then, it seems a good choice to control instantiation from within the Metaclass __call__ to enforce the singleton and neutralize any child overriding. Let’s try:

class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class LoggerBase: 
    def __new__(cls, *args, **kwargs):
        # Overriding instance creation method __new__
        return object.__new__(cls, *args, **kwargs)

class Logger(LoggerBase, metaclass=Singleton): pass

# Overriding __new__ as before
class LoggerChild(Logger):
    def __new__(cls, *args, **kwargs):
        return object.__new__(cls, *args, **kwargs)

is_singleton(Logger)
is_singleton(LoggerChild)
Logger is a Singleton
LoggerChild is a Singleton

So far, so good. Finally, we found a solution that works on a single thread, but what would happen if we instantiate the Logger on multiple Threads ?

Attempt 3: Multithreads Racing

From Wikipedia: “Thread safe: Implementation is guaranteed to be free of race conditions when accessed by multiple threads simultaneously”

In Python, there is a Global Interpreter Lock(GIL) that gives a lot of stability in Python programs by allowing only one thread at a time to operate on the interpreter. Still the GIL can be passed between threads if no other lock is held. (More on GIL & Multithreading)

Note: Multiprocessing is not limited by the GIL, because different interpreters are spawned, but we won’t talk about this in this post. because it is only possible to share states and not instances of any class between processes, it becomes really convoluted trying to enforce the singleton pattern.

So, If we instantiate the same Class on multiple Threads, it is possible that one or more threads reads nearly the same time that no instance has been created:

class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        # At the same moment, Thread B is checking the control-flow
        # Since cls._instances is still empty, it will pass too
        # and instantiate a second occurence of the class
        # breaking the singleton
        if cls not in cls._instances:
            # Thread A just passed the control flow and starts
            # instantiating but cls._instances is still empty
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

To mitigate this, we introduce a mutex, called Lock that will be aquired by only one thread at a time. The Lock will be released after the instance creation and registration in cls._instances. This way another Thread could acquire it but surely with the register up-to-date:

class SingletonThreadSafe(type):
    _instances = {}
    _lock = Lock()

    def __call__(cls, *args, **kwargs):
        # Thread B is waiting here
        with cls._lock:
            if cls not in cls._instances:
                # Thread A acquired the Lock, passed the control-flow
                # and now is instantiating the object and registering it
                # to cls._instances
                cls._instances[cls] = super().__call__(*args, **kwargs)
        # Thread A releases the Lock. Then Thread B acquires it
        # but won't pass the control-flow, since an instance
        # has already been registered. It will return the existing one
        return cls._instances[cls]

Let’s check with the following script our claims:

%%writefile thread_safety_test.py

from multiprocessing.dummy import Pool as ThreadPool
from threading import get_ident, Lock


class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]


class SingletonThreadSafe(type):
    _instances = {}
    _lock = Lock()

    def __call__(cls, *args, **kwargs):
        with cls._lock:
            if cls not in cls._instances:
                cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]


class LoggerBase: 
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        print(f"\nInstantiated {self.__class__.__name__} in thread {get_ident()}")

class Logger(LoggerBase, metaclass=Singleton): pass
class LoggerThreadSafe(LoggerBase, metaclass=SingletonThreadSafe): pass


def execute(LoggerClass: LoggerBase) -> None:
    """Try to instantiate LoggerClass on multiple Threads multiple times"""
    with ThreadPool(7) as pool:
        [pool.apply_async(LoggerClass) for _ in range(100)]
        pool.close()
        pool.join()

if __name__ == "__main__":
    execute(Logger)
    print("-" * 30)
    execute(LoggerThreadSafe)
Overwriting thread_safety_test.py
!python thread_safety_test.py

Instantiated Logger in thread 22300
Instantiated Logger in thread 8128
Instantiated Logger in thread 23812


------------------------------

Instantiated LoggerThreadSafe in thread 16288

Yes, we found a version for the singleton that is Thread-safe.

In multithreading, most of the time, the instance will already be registered. For efficiency and minimizing race condition, instead of Looking Before You Leap(LBYL), we may find it Easier to Ask for Forgiveness than Permission (EAFP). Trying to directly return the instance should be quicker than checking first if it is available:

class SingletonThreadSafe(type):
    _instances = {}
    _lock = Lock()

    def __call__(cls, *args, **kwargs):
        with cls._lock:
            # Let's try to return the instance
            try:
                return cls._instances[cls]
            except KeyError:
                # if not available, ok, let's create it
                cls._instances[cls] = super().__call__(*args, **kwargs)
                return cls._instances[cls]

Attempt 4: Multithreads Deadlocking

From Wikipedia: In concurrent computing, deadlock is any situation in which no member of some group of entities can proceed because each waits for another member, including itself, to take action, such as sending a message or, more commonly, releasing a lock.

What would happen if we define two singletons that are connected together ?

So far, we instantiated the Lock within the metaclass:

class SingletonThreadSafe(type):
    _instances = {}
    _lock = Lock()
    ...

Then the same Lock is shared between classes. If at least two of these classes are also coupled in their initlization, we would have a deadlock, and the program will loop undefinitely, like in the following example:

class Logger(metaclass=SingletonThreadSafe): pass
        
class DBConnection(metaclass=SingletonThreadSafe):
    def __init__(self):
        # When we initialize DBConnection, we also instantiate
        # a logger, but the lock will never be released since
        # DBConnection already aqcuired it
        self.logger = Logger()

is_singleton(Logger)
# is_singleton(DBConnection) # will deadlock 
Logger is a Singleton

To cope with the deadlock, we can define the Lock at class definition. Metaclass make classes through their __new__ method. Let’s bind one and only one Lock to each class, when they are defined. This way the Lock will only be shared between all instantiations of a given class:

class SingletonDeadlockFree(type):
    """Each class using this Singleton metaclass has its own Lock,
    preventing them from deadlock"""

    def __new__(metacls, cls, bases, clsdict, *args, **kwargs):
        """The lock is set at class definition to avoid deadlocking between coupled classes"""
        cls_with_lock = super().__new__(metacls, cls, bases, clsdict)
        cls_with_lock._lock = Lock()
        return cls_with_lock

    def __call__(cls, *args, **kwargs):
        with cls._lock:
            if cls not in cls._instances:
                cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

Let’s check if that works:

class Logger(metaclass=SingletonDeadlockFree): pass
        
class DBConnection(metaclass=SingletonDeadlockFree):
    def __init__(self):
        self.logger = Logger()

is_singleton(Logger)
is_singleton(DBConnection)
Logger is a Singleton
DBConnection is a Singleton

Conclusion

On the path to designing the purest singleton we could, we reviewed how Python handles instantiation in its world of “everything is object”. We saw how inheritance could perturb instantiation and how metaclasses helped taking back control.

We challenged our solution in multithreaded environments and learnt how to cope with race conditions and deadlocks. It pushed us to dive a little further into metaclasses. After every attempt, our Singleton got more robust, our understanding of Instantiation got sharper.

Reuse

Citation

BibTeX citation:
@online{x0s2023,
  author = {x0s},
  title = {Dive into the {Singleton} as an Excuse to Study
    {Instantiation} in {Python}},
  date = {2023-08-11},
  langid = {en}
}
For attribution, please cite this work as:
x0s. 2023. “Dive into the Singleton as an Excuse to Study Instantiation in Python.” August 11, 2023.