Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Thread-Safe Counters with Locks

Hello! Welcome back to our module on Python Concurrency and Parallelism.

In our previous lesson, we established a clear framework for Python concurrency. We demystified the Global Interpreter Lock (GIL), concluding that multiprocessing is the tool for CPU-bound parallelism, while threading and asyncio are used for I/O-bound concurrency. Your experience with Tokio in Rust gives you a solid foundation in asynchronous, non-blocking I/O, which maps well to asyncio.

Today, we'll focus on the threading model. Even though the GIL prevents true parallelism, threads are fundamental for many I/O-bound tasks and are essential to understand for building robust systems. When multiple threads operate within the same process, they share memory, which introduces the risk of data corruption.

Our learning outcome for this lesson is to implement thread-safe counters and data structures using threading.Lock and threading.RLock. We'll move from the "why" of concurrency to the "how" of making it safe, exploring the Python equivalents of the mutexes you're familiar with from Rust.

1. The Core Problem: Race Conditions

When multiple threads access and modify the same piece of data (a "shared resource"), the final state of that data can depend on the unpredictable order in which the threads are scheduled by the operating system. This situation is called a race condition.

A classic example is the "read-modify-write" sequence. Imagine two threads trying to increment a shared counter.

  1. Thread A reads the current value (e.g., 0).
  2. The OS pauses Thread A and switches to Thread B.
  3. Thread B reads the same value (still 0), increments it to 1, and writes it back. The shared value is now 1.
  4. The OS switches back to Thread A. It has already read 0, so it increments its local copy to 1 and writes it back.
  5. The final value is 1, even though two increment operations occurred. One update was lost.
Race Condition Illustration: Shared Variable Update
This diagram shows a race condition in action. Thread 1 reads `database.value` (0) but is paused before it can write its incremented value back. In the meantime, another thread could operate on the stale data, leading to an incorrect final result.

Let's see a practical demonstration of this.

Thread Safety in Python: Locks and Other Techniques -- Solving Races With Locks

The video "Thread Safety in Python" by Real Python provides an excellent demonstration of a race condition using a bank account example. It clearly shows how concurrent operations can lead to incorrect results.

Please watch from 03:15 to 10:13. Pay close attention to how the intentionally placed sleep call makes the race condition more likely, and observe how the final account balances are incorrect.

You might wonder if the GIL protects against this. As the next short segment explains, while the GIL simplifies memory management, it does not protect against race conditions at the level of your Python code. A single Python statement like self.balance += amount is not atomic; it consists of multiple bytecode instructions, and a thread switch can happen between them.

Thread Safety in Python: Locks and Other Techniques -- Solving Races With Locks

Let's continue with the same video to understand why we cannot rely on the GIL for thread safety.

Watch from 10:31 to 12:21. The key takeaway is that the GIL's behavior can change between Python versions, making it an unreliable safeguard. The only robust solution is to use explicit locks.

2. The Solution: Mutual Exclusion with threading.Lock

To prevent race conditions, we must ensure that the read-modify-write sequence on a shared resource is atomic—meaning it happens as a single, indivisible operation. We achieve this by using a lock to create a critical section, a block of code that only one thread can execute at a time. This principle is called mutual exclusion.

In Python, the primary tool for this is threading.Lock.

Python Threads with Lock
This diagram illustrates how a lock ensures thread safety. Thread t1 acquires the lock and can safely modify the shared list. Thread t2 is blocked, waiting for the lock to be released, preventing concurrent modification.

A Lock has two main methods:

  • acquire(): A thread calls this to request ownership of the lock. If the lock is available, the thread acquires it and continues. If another thread already holds the lock, the calling thread will block (wait) until the lock is released.
  • release(): The thread that holds the lock calls this to release it, allowing another waiting thread to acquire it.

Forgetting to call release() can lead to a deadlock, where all other threads wait forever for a lock that will never be freed. To avoid this, the best practice is to use the lock as a context manager with a with statement. This guarantees that release() is called automatically when the block is exited, even if an error occurs.

Incorrect (manual acquire/release):

lock.acquire()
try:



    # do something with shared resource
finally:
    lock.release() # Must be in a finally block!

Correct (using a context manager):

with lock:



    # do something with shared resource
    # Lock is automatically acquired on entry
    # and released on exit.

Let's see how a lock fixes the bank account problem.

Thread Safety in Python: Locks and Other Techniques -- Solving Races With Locks

The final segment of the video shows how to refactor the bank account class to use a threading.Lock, making the deposit and withdrawal operations thread-safe.

Watch from 12:21 to 15:06. Notice how the with self.lock: statement is used to protect the critical sections in both the withdraw and deposit methods.

3. Implementing a Thread-Safe Data Structure

The same principle applies to more complex data structures. Let's consider making an LRU Cache thread-safe. A cache's get and put methods modify internal state (a dictionary and a linked list). If used concurrently without protection, the cache can become corrupted.

The strategy is to use a single lock to protect all public methods that modify the shared internal state. This is known as coarse-grained locking.

Thread-safe LRU & MRU cache in Python

The article "Thread-safe LRU & MRU cache in Python" by Raghav Sikaria provides a clear, production-quality example. We'll focus on how the lock is integrated.

First, read the section that introduces thread-safety, starting from the code block with import threading. Pay attention to the important note about initializing the lock in the __init__ method. \nThen, review the full LRUCache implementation under the "LRU Cache" heading. You don't need to re-learn the LRU logic, but focus on how with self.lock: is used in both the get and put methods to wrap all internal operations.

As the article highlights, it's crucial that the lock object is created once per cache instance (in __init__) and shared by all methods. Creating a new lock inside get or put would be useless, as each thread would be acquiring its own separate lock.

4. A Complication: Re-entrant Locks (threading.RLock)

What happens if a thread that already holds a lock tries to acquire it again? With a standard threading.Lock, the thread will deadlock—it will wait forever for itself to release the lock.

This can happen unintentionally in complex code where one locked method calls another method that also tries to acquire the same lock.

Consider this scenario:

import threading

class MyObject:
    def __init__(self):
        self.lock = threading.Lock()
        self.value = 0

    def _internal_update(self):
        print("Thread trying to acquire lock in _internal_update...")
        with self.lock: # This will cause a deadlock!
            print("Acquired lock in _internal_update.")
            self.value += 1

    def process(self):
        print("Thread trying to acquire lock in process...")
        with self.lock:
            print("Acquired lock in process.")



            # This method now calls another locked method
            self._internal_update()

If a thread calls process(), it acquires the lock and then calls _internal_update(), which tries to acquire the same lock. The program hangs.

The solution is a re-entrant lock: threading.RLock. An RLock can be acquired multiple times by the same thread. It maintains a counter that is incremented on each acquire() and decremented on each release(). The lock is only truly released for other threads when the counter returns to zero.

Python Thread Safety: Using a Lock and Other Techniques

The Real Python article "Python Thread Safety" explains this deadlock scenario perfectly and shows how RLock resolves it.

Please read the section titled "threading.RLock for Reentrant Locking." It first demonstrates the deadlock with a Lock and then shows the corrected code using an RLock. Focus on understanding the scenario where this is needed.

Trade-off: Lock vs. RLock

RLock solves a specific problem but comes with a slight performance overhead because it needs to track the owning thread and the recursion level.

Guideline: Always default to threading.Lock. Only use threading.RLock if you have a specific need for re-entrant locking, such as in recursive functions or complex methods that call other locked methods on the same object.

Conclusion

You now have the fundamental tools to write correct, thread-safe Python code. Protecting shared state is non-negotiable in concurrent programming.

Key Takeaways:

  • Race conditions occur when the outcome of a program depends on the non-deterministic scheduling of threads accessing shared mutable data.
  • threading.Lock is the primary mechanism for mutual exclusion, creating critical sections where only one thread can operate at a time.
  • Always use the with statement (with lock:) for managing locks to ensure they are automatically and safely released.
  • threading.RLock is a re-entrant lock that allows the same thread to acquire it multiple times, preventing deadlocks in nested or recursive-locking scenarios. Use it judiciously, as it has a small performance cost compared to Lock.

Next Lesson Preview

We've learned how to apply a lock to protect a data structure. In our LRU cache example, we used a single lock to protect the entire object. This is called coarse-grained locking. It's simple and safe, but can it become a performance bottleneck? What if we could lock smaller parts of the data structure to allow for more concurrency?

In our next lesson, we will analyze and implement both coarse-grained and fine-grained locking strategies, discussing their trade-offs in terms of performance, complexity, and deadlock risk.

Can't find a good explanation? Sign up and we'll make it for you

Sign up