Skip to content
LLD Learn/Concurrency for LLD
Browsing as a guest. Sign in to save your progress and earn XP as you complete chapters.

Locks, Thread-Safe Singletons & Producer-Consumer

8 min read

You'll learn to

  • -Use locks and read-write locks correctly, and implement a thread-safe Singleton without over-synchronizing
  • -Implement a bounded blocking queue for a producer-consumer relationship

Locks are the most direct fix for the check-then-act race from the previous chapter: wrap the critical section - the check and the act together - in a lock so only one thread can execute it at a time.

Fixing the Parking Lot Race With a Lock

Making check-and-act one atomic critical section
import threading

class ParkingLot:
    def __init__(self, total_spots: int):
        self.available_spots = total_spots
        self._lock = threading.Lock()

    def assign_spot(self) -> bool:
        with self._lock:                # only one thread inside at a time
            if self.available_spots > 0:
                self.available_spots -= 1
                return True
            return False

Coarse-Grained vs. Fine-Grained Locking

A single lock around the entire ParkingLot (coarse-grained) is simple and definitely correct, but it serializes every spot assignment across the whole lot, even in different, unrelated sections. Fine-grained locking - a separate lock per level or per section of the lot - lets unrelated sections proceed in parallel, at the cost of more complexity and a new risk: if a request ever needs to hold two locks at once (moving a car between sections), inconsistent lock-acquisition order across different code paths can cause deadlock. The practical interview guidance: default to coarse-grained locking for correctness and simplicity, and only justify fine-grained locking with a concrete contention argument ("Level 1 and Level 2 assignment happening on separate locks matters if we expect thousands of simultaneous requests split across levels").

Read-Write Locks: Optimizing for the Read-Heavy Case

When a resource is read far more often than it is written (checking available_spots is far more common than actually assigning one), a plain lock still forces reads to serialize against each other unnecessarily, even though two simultaneous reads cannot conflict. A read-write lock allows any number of concurrent readers, but grants exclusive access to a single writer - so read-heavy workloads scale much better without sacrificing the writer's exclusivity guarantee.

Thread-Safe Singleton, Revisited

The double-checked locking Singleton from the Creational Patterns module is the textbook example of applying a lock surgically rather than everywhere: locking only the narrow window where the instance might not yet exist, and skipping the lock entirely (via the outer check) once initialization has already happened - avoiding the cost of a lock on every single subsequent access.

Producer-Consumer: A Bounded Blocking Queue

Producer-consumer is one of the most reusable concurrency shapes in LLD: one or more producer threads generate work items, one or more consumer threads process them, and a shared, bounded queue sits between them - blocking producers when the queue is full, and blocking consumers when the queue is empty, rather than spinning or dropping data.

A bounded blocking queue coordinating producers and consumers
import threading
from collections import deque

class BoundedBlockingQueue:
    def __init__(self, capacity: int):
        self._queue = deque()
        self._capacity = capacity
        self._lock = threading.Lock()
        self._not_full = threading.Condition(self._lock)
        self._not_empty = threading.Condition(self._lock)

    def put(self, item) -> None:
        with self._not_full:
            while len(self._queue) >= self._capacity:
                self._not_full.wait()        # block until there's room
            self._queue.append(item)
            self._not_empty.notify()

    def take(self):
        with self._not_empty:
            while not self._queue:
                self._not_empty.wait()       # block until there's an item
            item = self._queue.popleft()
            self._not_full.notify()
            return item

This exact bounded-queue shape is worth recognizing on sight: it is the same mechanism behind thread pools, message queues, and rate limiters with a "burst capacity" - producers and consumers proceeding at different speeds, coordinated through one shared, bounded buffer.

Interview Signal is part of Pro

See a real weak answer next to a real strong one for this exact topic.

Quiz is part of Pro

Test what you just read with a short quiz, and bank the XP.

ScaleDojo Logo
Initializing ScaleDojo