def is_singleton(cls: type) -> None:
print(f"{cls.__name__} {'is' if cls() is cls() else 'is not'} a Singleton")
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:
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:
= None
_instance
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):
= super().__new__(cls, *args, **kwargs)
cls._instance 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)
- Python’s Method Resolution Order operates from left to right, Child classes have to first inherit from Singleton to benefit from its
- Risk 2: Overriding
__new__
- If a child class overrides its
__new__
method, it redefines instanciation and may produce non-singleton objects
- If a child class overrides its
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 ifTest.__new__
has previously returned an object of typeTest
andTest.__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:
= super().__call__(*args, **kwargs)
cls._instances[cls] 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
= super().__call__(*args, **kwargs)
cls._instances[cls] 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
= super().__call__(*args, **kwargs)
cls._instances[cls] # 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:
= super().__call__(*args, **kwargs)
cls._instances[cls] return cls._instances[cls]
class SingletonThreadSafe(type):
= {}
_instances = Lock()
_lock
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
= super().__call__(*args, **kwargs)
cls._instances[cls] 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:
for _ in range(100)]
[pool.apply_async(LoggerClass)
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
= super().__call__(*args, **kwargs)
cls._instances[cls] 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"""
= super().__new__(metacls, cls, bases, clsdict)
cls_with_lock = Lock()
cls_with_lock._lock return cls_with_lock
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
= super().__call__(*args, **kwargs)
cls._instances[cls] 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
@online{x0s2023,
author = {x0s},
title = {Dive into the {Singleton} as an Excuse to Study
{Instantiation} in {Python}},
date = {2023-08-11},
langid = {en}
}