DEV Community

Cover image for Understanding Singleton Design Pattern with a Logger Example - Part 2
Bhavuk kalra
Bhavuk kalra

Posted on • Edited on

Understanding Singleton Design Pattern with a Logger Example - Part 2

Continuing from where we left off: We successfully created a singleton class for Logger, exposing a static method getLogger for all users interacting with the class.

To quickly recap, our Logger class includes the following key features:

๐Ÿ”น Direct instantiation of the Logger class is not allowed.

๐Ÿ”น Custom error handling is implemented to prevent direct instantiation.

๐Ÿ”น All users of the class use a common static getLogger method to obtain an instance.

However, the current implementation is not suitable for production usage, as it does not account for scenarios involving multithreadingโ€”i.e., it is not thread-safe. Thatโ€™s exactly what weโ€™ll address in this article.

Pre-Requisites

threading vs multiprocessing in python

๐Ÿงต Threading in Python

What is it?

Threading lets you run multiple threads (tiny workers) inside a single process ๐Ÿง . They all share the same memory, like roommates sharing a house ๐Ÿ .

๐Ÿ’ก How it works:

๐Ÿ”น All threads share the same memory space ๐Ÿง 

๐Ÿ”น Great for I/O-bound tasks ๐Ÿ“ก (e.g., web requests, file reading/writing).

๐Ÿ”น Controlled by the threading module ๐Ÿงต

๐Ÿง  Key Concepts:

๐Ÿ”น Threads = lightweight ๐Ÿšดโ€โ™‚๏ธ

๐Ÿ”น Memory is shared across threads ๐Ÿ 

๐Ÿ”น Butโ€ฆ thereโ€™s a pesky thing called the GIL (Global Interpreter Lock) โ›” that means only one thread runs Python code at a time, so no true parallelism ๐Ÿ˜ข (for CPU-heavy stuff).

โœ… Pros:

๐Ÿ”น Super fast to start โšก

๐Ÿ”น Low memory usage ๐Ÿชถ

๐Ÿ”น Threads can easily share data ๐Ÿง 

โŒ Cons:

๐Ÿ”น Canโ€™t utilize multiple CPU cores because of the GIL ๐Ÿšซ๐Ÿง 

๐Ÿ”น Risk of bugs like race conditions and deadlocks ๐Ÿ•ณ๏ธ

๐Ÿ”ฅ Multiprocessing in Python

What is it?

Multiprocessing creates separate processes, each with its own memory, and they run truly in parallel ๐Ÿš€ โ€” like different computers working together ๐Ÿค

๐Ÿ’ก How it works:

๐Ÿ”น Uses the multiprocessing module ๐Ÿ› ๏ธ.

๐Ÿ”น Every process has its own memory space ๐Ÿ“ฆ.

๐Ÿ”น No GIL here โ€” each process gets a core! ๐Ÿง ๐Ÿง ๐Ÿง 

๐Ÿง  Best for:

๐Ÿ”น CPU-bound tasks ๐Ÿงฎ โ€“ number crunching, data processing, machine learning, etc.

๐Ÿ”น When you need real parallelism โš™๏ธโš™๏ธโš™๏ธ

โœ… Pros

๐Ÿ”นTrue parallelism ๐Ÿ’ฅ

๐Ÿ”นPerfect for CPU-heavy tasks ๐Ÿง ๐Ÿ’ช

๐Ÿ”นNo GIL = no problem ๐Ÿšซ๐Ÿ”’

โŒ Cons

๐Ÿ”น More memory usage ๐Ÿง ๐Ÿ’ธ

๐Ÿ”น Slower to spin up โš™๏ธ

๐Ÿ”น Sharing data between processes is trickier ๐Ÿงตโžก๏ธ๐Ÿ“ฆ

Which one we would be using?

Looking at different options that we have available for us in python ofcourse we are gonna go with threading module as this a I/O usecase and we are not doing any heavy computation as well here.

Why is it not thread safe?

Race condition example

Referring to the diagram above. Imagine a scenario where there are two threads Thread 1 and Thread 2 both trying to access the getLogger in the intial phases of running an application i.e __loggerInstance is None

๐Ÿ”น Thread 1 checks if cls.__loggerInstance is None โ€” it is, so it proceeds.

๐Ÿ”น Thread 2 runs at the exact same time, checks cls.__loggerInstance โ€” still None, so it proceeds too.

Both threads call __new__() and __init__() โ†’ ๐Ÿงจ Boom! Two instances created!

Thatโ€™s called a race condition, and it can definitely happen in multithreaded environments.

๐Ÿงช How to Reproduce It

You could artificially slow down the instantiation to provoke the issue: For Re-Producing the issue we'll make these changes to our already existing codebase

๐Ÿ“„ Logger.py

import time

class Logger:

    # private Static variable to track num of instances created
    __numInstances = 0

    # private static variable to denote if instance was created
    __loggerInstance = None

    def __new__(cls):
        # Private constructor using __new__ to prevent direct instantiation
        raise Exception("Use getLlogger() to create an instance.")


    def __init__(self):
        Logger.__numInstances = Logger.__numInstances + 1
        print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)

    def log(self, message: str):
        print(message)

    @classmethod
    def getLogger(cls):
        # Returns the singleton instance, creates one if it doesn't exist
        if cls.__loggerInstance is None:

            time.sleep(0.1)  # Simulate delay

            # Bypass __new__ and directly instantiate the class
            cls.__loggerInstance = super(Logger, cls).__new__(cls)

             # Trigger __init__ manually on first creation
            cls.__loggerInstance.__init__()

        return cls.__loggerInstance
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“„ main.py

from user1 import doProcessingUser1
from user2 import doProcessingUser2
import threading
import multiprocessing


if __name__ == "__main__":

    t1 = threading.Thread(target=doProcessingUser1)
    t2 = threading.Thread(target=doProcessingUser2)

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print("All threads finished")

Enter fullscreen mode Exit fullscreen mode

Added time.sleep(0.1) to simulate delay while executing the getLogger function.

Output (and we got a race condition)

Logger Instantiated, Total number of instances -  1
Log from the second user
Logger Instantiated, Total number of instances -  2
Log from the first user
All threads finished
Enter fullscreen mode Exit fullscreen mode

โœ… How to Make It Thread-Safe

Use a threading.Lock to synchronize access to the singleton logic. Let's make these changes then we'll discuss on what we have edited so far.

๐Ÿ“„ logger.py

import time
import threading

class Logger:

    # private Static variable to track num of instances created
    __numInstances = 0

    # private static variable to denote if instance was created
    __loggerInstance = None

    __mutexLock = threading.Lock()  # For thread-safe singleton creation (private static)

    def __new__(cls):
        # Private constructor using __new__ to prevent direct instantiation
        raise Exception("Use getLlogger() to create an instance.")


    def __init__(self):
        Logger.__numInstances = Logger.__numInstances + 1
        print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)

    def log(self, message: str):
        print(message)

    @classmethod
    def getLogger(cls):
        # Returns the singleton instance, creates one if it doesn't exist

        with cls.__mutexLock:

            if cls.__loggerInstance is None:

                time.sleep(0.1)  # Simulate delay

                # Bypass __new__ and directly instantiate the class
                cls.__loggerInstance = super(Logger, cls).__new__(cls)

                # Trigger __init__ manually on first creation
                cls.__loggerInstance.__init__()

        return cls.__loggerInstanc
Enter fullscreen mode Exit fullscreen mode

Here we have added these things

๐Ÿ”น private static variable __mutexLock for letting only one thread i.e Thread 1 or Thread 2 to access the getLogger function.

๐Ÿ”น with cls.__mutexLock:

๐Ÿ‘‰ What this does:
The with statement automatically acquires the lock when the block starts โœ…

And then it automatically releases the lock once the block is exited (even if an exception happens!) ๐Ÿ”“

So technically, the lock is being released as soon as the indented block under with is done.

โœ… So no need to manually call .acquire() or .release() โ€” the with block takes care of it cleanly and safely ๐Ÿ™Œ

Here is a diagramatical representation of how this helps us in avoiding the original race condition that we faced earlier

Race condition avoiding and colution

๐Ÿงต Thread 1 and Thread 2 Execution Flow

๐ŸŸฉ Thread 1 (Left side of the diagram):
Calls getLogger()
โ†’ Thread 1 wants the logger instance.

Acquires the lock ๐Ÿ”
โ†’ Since it's the first thread in, it grabs the lock on __mutexLock.

Executes getLogger()
โ†’ Sees __loggerInstance is None, creates the singleton logger instance.

Releases the lock ๐Ÿ”“
โ†’ Done with critical section, allows other threads to enter.

๐ŸŸฆThread 2 (Right side of the diagram):
Also calls getLogger() at (roughly) the same time

Waits for the lock โณ
โ†’ It hits the lock, but Thread 1 already has it.

Still waiting... ๐Ÿ˜ฌ
โ†’ Thread 1 is doing its thing. Thread 2 just chills here.

Acquires lock (after Thread 1 releases) โœ…
โ†’ Now Thread 2 gets access to the critical section.

Executes getLogger()
โ†’ But this time, it sees __loggerInstance is NOT None, so it just returns the existing logger!

โœ… How This Prevents a Race Condition:

Without the lock:

Both threads could simultaneously check __loggerInstance is None, and both might try to create it at the same time โ†’ โŒ Multiple instances (race condition!).

With the lock:

Only one thread enters the critical section at a time, ensuring only one logger instance is ever created.

Final output

Logger Instantiated, Total number of instances -  1
Log from the first user
Log from the second user
All threads finished
Enter fullscreen mode Exit fullscreen mode

Now as expected we only see one instance of the class gets instantiated, even in multi threaded environments

One last optimization

This optimization is based on the fact that the usage of locks is expensive on the process. So we are gonna use it smartly.

Notice one little small detail about our current getLogger function

def getLogger(cls):
        # Returns the singleton instance, creates one if it doesn't exist

        with cls.__mutexLock:

            if cls.__loggerInstance is None:

                time.sleep(0.1)  # Simulate delay

                # Bypass __new__ and directly instantiate the class
                cls.__loggerInstance = super(Logger, cls).__new__(cls)

                # Trigger __init__ manually on first creation
                cls.__loggerInstance.__init__()

        return cls.__loggerInstance
Enter fullscreen mode Exit fullscreen mode

In the current implementation, we're acquiring a lock on every call to the getLogger function, which is inefficient.

However, locking is only necessary at the start of the program. Once the __loggerInstance is set, any subsequent threads that access the getLogger function won't create new instancesโ€”they'll simply receive the existing one.

To optimize this, we'll add a small check before acquiring the lock to ensure it's only used during the initial instantiation.

def getLogger(cls):
        # Returns the singleton instance, creates one if it doesn't exist
        if cls.__loggerInstance is None:  # ๐Ÿš€ Quick no-lock check

            with cls.__mutexLock:

                if cls.__loggerInstance is None:

                    time.sleep(0.1)  # Simulate delay

                    # Bypass __new__ and directly instantiate the class
                    cls.__loggerInstance = super(Logger, cls).__new__(cls)

                    # Trigger __init__ manually on first creation
                    cls.__loggerInstance.__init__()

        return cls.__loggerInstance
Enter fullscreen mode Exit fullscreen mode

We added the above condition if cls.__loggerInstance is None: to check if we are at the starting stages of the execution and then only acquire the lock to instantiate the class.

This type of locking is called Double-Checked Locking Pattern

๐Ÿช„ Why this is better:

Most of the time (after the logger is created), the method will skip the lock completely ๐Ÿ™Œ

Locking only happens once, during the first initialization

Still 100% thread-safe โœ…

as tested by the below response from main.py

Logger Instantiated, Total number of instances -  1
Log from the first user
Log from the second user
All threads finished

Enter fullscreen mode Exit fullscreen mode

Which is exactly the same that we got earlier

Closing ๐Ÿš€

This is the ending of a short two part series on single ton design pattern. Here is the summary of what we covered in these two parts

๐Ÿ”น ๐Ÿงฑ Base Logger Setup & Instance Tracking: Implemented a basic Logger class, observed that multiple instances were created when used in different files, and added a static variable to track instance count.

๐Ÿ”น ๐Ÿ” Converted Logger to Singleton: Restricted direct instantiation using __new__, implemented a getLogger() method to return a shared instance, ensuring only one Logger is created and reused across the application.

๐Ÿ”น ๐Ÿงต (Thread-Safety): Highlighted the potential issue of multiple instances being created in a multithreaded environment and introduced the need for locks to make the Singleton thread-safe.

๐Ÿ”น Built a Thread-Safe Singleton Logger Class: Enhanced the original singleton pattern using threading.Lock to ensure only one instance is created even in multithreaded environments, preventing race conditions.

๐Ÿ”น ๐Ÿ” Explained Threading vs Multiprocessing in Python: Covered key differences, pros/cons, and why threading is the right choice here (I/O-bound task, not CPU-heavy).

๐Ÿ”น ๐Ÿง  Applied Double-Checked Locking Optimization: Improved performance by avoiding unnecessary locking after the logger instance is initialized, maintaining thread safety with better efficiency.

About me โœŒ๏ธ

Socials ๐Ÿ†”

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.