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

📄 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")

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

✅ 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

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

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

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

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

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 🆔