Python > Advanced Topics and Specializations > Concurrency and Parallelism > Threads and the `threading` Module

Thread Synchronization using Locks

This example demonstrates how to use locks (threading.Lock) to protect shared resources from concurrent access by multiple threads, preventing race conditions. It simulates a bank account and multiple threads attempting to deposit and withdraw funds simultaneously. Without proper synchronization, the account balance could become inconsistent.

Core Concepts: Locks and Critical Sections

The BankAccount class represents a bank account with a balance and a threading.Lock. The deposit and withdraw methods are wrapped in a with self.lock: block. This creates a critical section, ensuring that only one thread can execute these methods at a time. The main function creates a BankAccount object and spawns multiple threads that randomly deposit and withdraw funds. The with statement automatically acquires and releases the lock, even if exceptions occur.

import threading
import time
import random

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
        self.lock = threading.Lock()

    def deposit(self, amount):
        with self.lock:
            print(f'Depositing {amount}')
            time.sleep(random.random())
            self.balance += amount
            print(f'New balance: {self.balance}')

    def withdraw(self, amount):
        with self.lock:
            print(f'Withdrawing {amount}')
            time.sleep(random.random())
            if self.balance >= amount:
                self.balance -= amount
                print(f'New balance: {self.balance}')
            else:
                print('Insufficient funds')

def main():
    account = BankAccount(1000)
    threads = []

    def transaction(account, op_type, amount):
        if op_type == 'deposit':
            account.deposit(amount)
        elif op_type == 'withdraw':
            account.withdraw(amount)

    for i in range(5):
        op_type = random.choice(['deposit', 'withdraw'])
        amount = random.randint(100, 300)
        t = threading.Thread(target=transaction, args=(account, op_type, amount))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

    print(f'Final balance: {account.balance}')

if __name__ == "__main__":
    main()

Real-Life Use Case: Shared Data Structures

Locks are crucial when multiple threads need to modify a shared data structure, such as a list, dictionary, or database. Without locks, concurrent modifications can lead to data corruption or inconsistent state. For example, consider a caching system where multiple threads read from and write to a shared cache. Locks ensure that the cache remains consistent and avoids race conditions.

Best Practices: Avoid Deadlocks

Deadlocks can occur when multiple threads are waiting for each other to release locks. To avoid deadlocks, follow these guidelines:

  • Acquire locks in a consistent order.
  • Use timeouts when acquiring locks (lock.acquire(timeout=...)).
  • Release locks as soon as possible.
  • Consider using higher-level synchronization primitives like condition variables, which can help avoid deadlocks in certain scenarios.

Interview Tip: Lock Granularity

Be prepared to discuss lock granularity – the scope of the resources protected by a lock. Coarse-grained locks (protecting large sections of code or data) can simplify synchronization but may reduce concurrency. Fine-grained locks (protecting smaller sections) can improve concurrency but increase complexity and the risk of deadlocks. The optimal lock granularity depends on the specific application and the frequency of concurrent access to shared resources.

When to Use Locks

Use locks whenever multiple threads need to access and modify shared resources concurrently. Specifically, use locks to protect critical sections of code where shared data is being read or written. Analyze your code to identify potential race conditions and use locks to prevent them.

Memory Footprint

The memory footprint of a lock object itself is relatively small. The main memory consideration comes from the potential for threads to block while waiting for a lock, which can impact overall application performance and resource utilization.

Alternatives: RLock and Semaphores

threading.RLock (reentrant lock) allows the same thread to acquire the lock multiple times without blocking, useful for recursive functions. threading.Semaphore manages a limited number of resources; threads can acquire a permit from the semaphore before accessing a resource, and release it when done. Semaphores are useful for limiting the number of concurrent accesses to a resource.

Pros of Using Locks

  • Prevents race conditions and data corruption.
  • Relatively simple to use.
  • Ensures mutual exclusion for critical sections.

Cons of Using Locks

  • Can introduce performance overhead due to blocking.
  • Increases code complexity.
  • Can lead to deadlocks if not used carefully.

FAQ

  • What happens if a thread tries to acquire a lock that is already held by another thread?

    The thread will block (suspend execution) until the lock is released by the thread that currently holds it.

  • What is a reentrant lock (RLock)?

    A reentrant lock (threading.RLock) allows the same thread to acquire the lock multiple times without blocking. It maintains an internal counter to track the number of acquisitions. The lock is only released when the acquiring thread releases it the same number of times it acquired it.

  • How do I handle exceptions within a critical section?

    Use a try...finally block within the with lock: block to ensure that the lock is always released, even if an exception occurs: with lock: try: # Code that might raise an exception finally: # Code to release the lock (not strictly needed with the 'with' statement). The with statement ensures the lock is released automatically on exiting the block, including exiting because of an exception.