bigonotes
DSA: Patterns & PythonDesign Problems

Concurrency Primitives: Locks, Conditions and Semaphores

Build a bounded blocking queue with a condition variable or semaphores, and spot check-then-act races in design follow-ups.

6 min readLast updated

Almost every design problem ends with "what if two threads call this at once?" A few problems make concurrency the whole task, like a bounded blocking queue where producers must wait when it is full and consumers must wait when it is empty. Both come down to a small toolkit: a lock to make a check and an update one atomic step, and a condition variable or semaphore to sleep until the state you need appears.

The idea#

A condition variable lets a thread sleep until another thread signals that the state has changed. The recipe fits in four steps:

  1. Create a threading.Lock.
  2. Build a threading.Condition on that lock.
  3. Inside with condition:, wait() in a while loop until the state you need holds.
  4. Change the state, then notify() the threads waiting for the opposite state.

Why while and not if: a thread woken from wait() must re-acquire the lock before it runs, and another thread may get there first and take the item. Wakeups can also happen spuriously, with no notify() at all. Re-checking the condition after every wakeup is the only safe rule.

Why two conditions: with one shared condition, notify() might wake another producer when you meant to wake a consumer, and everyone keeps waiting. One shared condition works if you use notify_all(), at the cost of waking threads that go straight back to sleep. Two conditions on the same lock (not_full, not_empty) let each side wake only the other side.

Put together, the lock makes "check the state, then change it" one atomic step, so no other thread can slip in between. The while loop guarantees that a thread only acts when the state it needs holds right now.

How to spot it#

These cues point to a lock plus a condition or semaphore.

  • "blocks until", "wait if the queue is full / empty"
  • "multiple producer and consumer threads"
  • "print in order", "alternate between two threads"
  • in any design follow-up: a read, a decision, then a write on shared state (check-then-act)

Template#

Here is the bounded blocking queue from the opening, built with two conditions on one lock. Each operation follows the same four lines: lock, wait in a while, change the state, notify the other side.

init(capacity):
    items = empty deque
    lock = one Lock
    not_full, not_empty = two Conditions on lock
enqueue(x):
    with not_full:
        while the queue is full: not_full.wait()   # re-check after waking
        append x
        not_empty.notify()                         # wake one consumer
dequeue():
    with not_empty:
        while the queue is empty: not_empty.wait()
        remove the front item
        not_full.notify()                          # wake one producer
        return the item
size():
    with the same lock: return the length          # consistent read

The semaphore version#

A semaphore is a counter that blocks on acquire at zero, so it can count free and filled slots directly. empty_slots starts at capacity and full_slots at 0. A producer takes an empty slot (blocking at zero), appends under a mutex, then releases a full slot. A consumer does the mirror image.

init(capacity):
    empty_slots = Semaphore(capacity)    # room for producers
    full_slots = Semaphore(0)            # items for consumers
    lock = Lock                          # guards the deque
enqueue(x):
    empty_slots.acquire()                # blocks while full
    with lock: append x
    full_slots.release()                 # wakes one consumer
dequeue():
    full_slots.acquire()                 # blocks while empty
    with lock: remove the front item
    empty_slots.release()                # wakes one producer
    return the item

Acquire the semaphore before the mutex. If a producer held the mutex while blocked on a full queue, no consumer could get in to make room: a deadlock. The theory behind this design is in the Producer-Consumer Problem note.

Check-then-act races in design follow-ups#

Locks matter well beyond pure concurrency problems. Most "make it thread-safe" follow-ups in other designs are the same bug: two threads read the same state, both decide, both write.

Problem The race Local fix Across many servers
Parking System two cars both see 1 free spot; the count goes to -1 lock around check and decrement atomic decrement in a shared store (Redis DECR)
Hit Counter, Underground two updates to one total; one is lost lock, or atomic counters atomic increments in a shared store
Logger Rate Limiter two threads both see the message as printable lock around the dict read and write shared store with key expiry
File System two creates under the same folder a lock per node, not one global lock
Design Twitter a reader merges while a tweet is deleted append-only lists plus a tombstone set: readers need no lock

The last row is the alternative to locking: if data is never modified in place (only appended, with deletes recorded separately), readers can't see a half-done change.

Pitfalls#

Check your code against these before you call it thread-safe.

  • if instead of while around wait(). A woken thread may find the state already changed.
  • notify() on one shared condition. It can wake the wrong side; use two conditions or notify_all().
  • Mutex before semaphore. Holding the lock while blocked on a semaphore deadlocks the queue.
  • Calling wait() or notify() without holding the lock. Python raises RuntimeError.
  • Reading shared state without the lock. len(deque) happens to be safe in CPython, but holding the lock makes the intent clear and keeps it safe on other runtimes.
  • A local lock for a multi-server system. It only protects one process; across servers use an atomic operation in a shared store.

Problems in this pattern#

These problems use this pattern. Solve them before you open the hints below. Start with the bounded blocking queue.

Hints#

Open a hint only after you have tried the problem for a while.

Hint: Design Bounded Blocking Queue

Use either version of the template. With conditions, keep two of them on one lock so each side wakes only the other. With semaphores, take the semaphore before the mutex.

Hint: Design Parking System

Keep a dict of car type to free slots, instead of three separate variables, so a new type (electric, motorcycle) needs no new branches. addCar checks the count and decrements it: a check-then-act step that needs a lock once threads share it.

Hint: Design Parking System: follow-ups

A removeCar must not raise the count above the starting capacity, so store the capacities too. Reject unknown car types, and persist state outside the process if a restart must not lose it. The fuller design (size-matched spots, tickets, pricing) is in the Parking Lot LLD.

Hint: Print in Order

Each method must wait until the one before it has run. Give each handoff its own signal that starts closed, such as a threading.Semaphore(0) or a threading.Event. The earlier method opens the signal for the next one.

Hint: Print FooBar Alternately

Two threads take turns, so use two semaphores. The foo one starts at 1 and the bar one at 0. Each thread acquires its own semaphore, prints, then releases the other's.

Key takeaways#

  • Lock, condition on the lock, wait() in a while, change state, notify() the other side.
  • Two conditions on one lock avoid waking the wrong side; one condition needs notify_all().
  • Semaphores count free slots and filled slots; take the semaphore before the mutex.
  • Most design races are check-then-act: make the check and the write one atomic step.
  • Append-only data plus tombstones lets readers skip locks entirely.
Revision notes · 2 min

Core idea#

A lock makes a check and an update one atomic step. A condition variable or a semaphore lets a thread sleep until the state it needs appears.

Key points#

  • Recipe: create a threading.Lock, build a threading.Condition on it, wait() in a while loop, change the state, then notify() the other side.
  • Use while, not if. Another thread may take the item first, and wakeups can be spurious.
  • Use two conditions on one lock (not_full, not_empty) so each side wakes only the other. One shared condition needs notify_all().
  • Semaphore version: empty_slots starts at capacity, full_slots at 0, and a mutex guards the deque.
  • Acquire the semaphore before the mutex. The reverse order deadlocks on a full queue.
  • Most "make it thread-safe" follow-ups are check-then-act races: two threads read, both decide, both write.
  • A local lock protects one process only. Across servers, use an atomic operation in a shared store.

Races in design follow-ups#

Problem Race Fix
Parking System two cars take the last spot lock around check and decrement; Redis DECR across servers
Hit Counter two updates; one is lost lock or atomic counters
Logger Rate Limiter both threads see "printable" lock around dict read and write
File System two creates in one folder a lock per node, not one global lock
Design Twitter read during a delete append-only lists plus a tombstone set

Template#

init: lock; not_full, not_empty = two Conditions on lock
enqueue(x):
    with not_full:
        while the queue is full: not_full.wait()
        append x
        not_empty.notify()
dequeue():
    with not_empty:
        while the queue is empty: not_empty.wait()
        remove the front item
        not_full.notify()
        return the item

Pitfalls#

  • if instead of while around wait().
  • notify() on one shared condition. It can wake the wrong side.
  • Calling wait() or notify() without the lock. Python raises RuntimeError.
  • Reading shared state without the lock.

Say it in an interview#

  • "Wait in a while loop, change the state, then notify the other side."
  • "This is check-then-act. Make the check and the write one atomic step."

Found a mistake? Email ask@bigonotes.com