Synchronization Primitives with Python Specifics

Dated Jun 2, 2026; last modified on Tue, 02 Jun 2026

In Chromium, why wasn’t explicit threading a thing? Data structures are usually not thread-safe. There were guardrails for scenarios like checking WeakPtrs on a different thread. However, I didn’t come out of it with a mental model for threading primitives.

Introduction

Threads are smaller units of a process that allow executing tasks in parallel, sharing memory space. Threads are particularly useful for I/O bound tasks, where much of the time is spent waiting for external resources.

Compared to Java’s threading model, Python’s model has some differences. Locks and condition variables are separate objects. Furthermore, there are no priorities, no thread groups, and threads cannot be destroyed, stopped, suspended, resumed, or interrupted.

The Thread Object

t.start() must be called at most once per t, lest it raises a RuntimeError.

Why make it a hard error instead of no-op’ing on subequent t.start() calls?

t.is_alive() returns True just before the t.run() method starts until after the t.run() method terminates. threading.enumerate() returns a list of alive threads.

If run() raises an exception, threading.excepthook() is called to handle it. By default threading.excepthook() ignores SystemExit.

By default, join(timeout=None) blocks until the thread terminates. t.join(timeout=2.5) blocks for ~2.5s. If t has not terminated by then, t.is_alive() == True. A thread can be joined many times.

t.daemon must be set before t.start() is called, else a RuntimeError. The initial value is inherited from the creating thread. The main thread is not a daemon thread. The entire Python program exists when no alive non-daemon threads are left.

Thread-Local Data

Thread-local data is data whose values are thread-specific, e.g.,

mydata = local()
mydata.number = 42 # Stored inside mydata.__dict__

log = []

def f():
  items = sorted(mydata.__dict__.items())
  log.append(items)
  mydata.number = 11
  log.append(mydata.number)

thread = threading.Thread(target=f)
thread.start()
thread.join()

… where log will have two items, [], and 11. mydata.number = 42 executed on a different thread than the one mydata.number = 11 executed on.

Subclasses of local can define __slots__ that are shared across threads, e.g.,

class MyLocal(local):
  __slots__ = "number"

mydata = MyLocal()
mydata.number = 42

thread = threading.Thread(target=f)
thread.start()
thread.join()

… where mydata.number == 11.

Lock Objects

Also referred to as a mutex in other languages, i.e., a mutual exclusion lock.

A Lock is a synchronization primitive that assumes one of two states: locked and unlocked. It is not owned by a particular thread when locked. A lock creates a critical section where only one thread executes at a time.

If acquire(blocking=True, timeout=-1) returns True, then l.locked() == True. Using blocking=True blocks until the lock is unlocked, and then locks it and returns True. If blocking=False, then acquire only locks if l was already unlocked. timeout=-1 specifies an unbounded wait; it’s an error to do l.acquire(blocking=False, timeout=2.5).

release() releases a lock, and can be called from any thread. l.release() on an already unlocked Lock raises a RuntimeError. If any other threads are blocked waiting on l to become unlocked, l.release() allows exactly one of them to proceed.

The main variants for locks are coarse-grained (one lock for everything), fine-grained (per-resource locks), and read-write (multiple readers OR one writer).

Atomics

Atomics provide thread-safe operations on single variables without locks, e.g.,

using System.Threading;

int counter = 0;
Interlocked.Increment(ref counter);

… which use uninterruptible CPU instructions like compare-and-swap (CAS). Python lacks these, and you’d have to use manually use locks.

Some languages, e.g., C#, support specifying variables as volatile. This tells the compiler that a field might be modified by multiple concurrent threads, and thus the compiler excludes that variable from certain kinds of optimizations. volatile does not provide atomicity for operations other than assignment, doesn’t prevent race conditions, nor provide ordering guarantees for other memory operations.

RLock Objects

A reentrant lock is a synchronization primitive that may be acquired multiple times by the same thread. It is owned by a particular thread when locked; in the unlocked state, no thread owns it.

acquire()/release() must be called in pairs. Failing to call release() as many times as acquire() can lead to deadlock.

acquire(blocking=True, timeout=-1). If no thread owns l, acquire the lock and return True. If the same thread owns l, acquire the lock again and return True (Lock would have blocked until l can be acquired). If another thread owns the lock:

  • If blocking=True, block until we can acquire the lock, while respecting timeout.
  • Otherwise, return False as we cannot acquire the lock.

Doesn’t reentrancy on Lock always cause deadlock? If t locked l, how can t unlock l while its own execution is blocked waiting for “someone” to unlock t? Ah, some other thread t2 can call l.release().

release() decrements the recursion level. If the new recursion level is zero, l is unlocked (not owned by any thread) and exactly one of any pending threads proceed. If the recursion level is still non-zero, l remains locked and owned by the calling thread. RuntimeError results if release() is called on a lock that is not acquired.

Condition Variables

Condition variables let threads wait efficiently for a condition to become true.

A condition variable is always associated with some kind of lock, e.g.,

cv = thread.Condition(lock=l) # cv has acquire(), release(), and locked() targeting l

with cv:
  while not an_item_is_available(): # Can be replaced w/ cv.wait_for(an_item_is_available)
    cv.wait()
  get_an_available_item()

with cv:
  make_an_item_available()
  cv.notify()

wait(timeout=None) releases the underlying lock (RuntimeError is the lock had not been acquired) and blocks until another thread awakens it via a notify()/notify_call() call. Once awakened, wait() re-acquires the lock and returns. wait_for(predicate, timeout=None) follows the same rules as wait.

Think in terms of invariants. The calling thread owns the lock before wait() and after wait(), no matter what happens.

The while loop (and the cv.wait_for utility) is needed because wait() can return after an arbitrary long time, and the condition that prompted notify() may no longer hold true. This is inherent to multi-threaded programming.

notify(n=1) and notify_all() don’t release the lock (and so awakened threads don’t return from their wait() call immediately). The thread that called notify() needs to relinquish ownership of the lock. RuntimeError if the calling thread hasn’t acquired the lock when calling notify*().

notify(n=1) wakes up at most \(n\) threads, and no-ops if no threads are waiting. notify_all() wakes up all threads waiting on this condition. In a typical producer-consumer situation, adding one item to the buffer only needs to wake up one consumer thread.

Semaphore Objects

Semaphore manages an internal counter which is decremented by each acquire(blocking=True, timeout=None) and incremented by each release(). When the counter is \(0\) on an acquire() call, the semaphore blocks, waiting until some other thread calls release(n=1).

BoundedSemaphore(value=1) checks that its counter never exceeds its initial value, and raises ValueError if it does. BoundedSemaphores guard resources with limited capacity; releasing too many times is a sign of a bug.

Semaphores are useful for limiting concurrent operations, e.g., at most 10 API calls at a given time.

Event Objects

threading.Event affords a simple communication mechanism: one thread signals an event and other threads wait for it. Event manages a flag that can be modified via set() and clear() and queried via is_set(). wait(timeout=None) blocks as long as ev.is_set() == False and the timeout, if given, has not expired.

Timer Objects

Timer subclasses Thread and represents an action that should be run only after a certain amount of time has passed, e.g.,

def hello(name):
  print(f"hello, {name}")

t = Timer(30.0, hello, args=("Chege",))
t.start() # After 30s, "hello, world" will be printed. We can cancel() before 30s elapses.

Barrier Objects

Barrier is a synchronization primitive for use by \(n\) threads that need to wait for each other, e.g.,

b = Barrier(2, timeout=None, action=None)

def server():
  start_server()
  b.wait()
  while True:
    connection = accept_connection()
    process_server_connection(connection)

def client():
  b.wait()
  while True:
    connection = make_connection()
    process_client_connection(connection)

A Barrier can be reused any number of times for the same number of threads.

wait(timeout=None) passes the barrier. When all \(n\) threads call wait(), they are all released simultaneously. wait returns an integer \(x \in [0, N)\), which can be used for housekeeping, e.g., printing something. If action was passed in the constructor, one of the threads will have called it prior to being released.

A Barrier transitions into a broken state if action throws, or abort() is called. Any active and future wait() calls fail with BrokenBarrierError. Instead of aborting, it’s simpler to specify a sensible timeout value. reset() returns the barrier to the default, empty state and raises BrokenBarrierError for waiting threads.

Blocking Queues

Blocking queues provide thread-safe producer-consumer handoff. Producers call q.put(item, block=True, timeout=None) to add items, with the block=True blocking if there’s no free spot available by the end of timeout. q.get(block=True, timeout=None) removes and returns an item from the queue, with block=True blocking for timeout until an item is available for removal.

q.shutdown(immediate=False) makes put calls raise ShutDown exceptions, and immediate=False allows get calls to succeed until there are no more items.

q.join() blocks until all items in the queue have been gotten and processed; the count of unfinished tasks goes up whenever an item is added, and goes down whenever a consumer thread calls q.task_done() to indicate the item was retrieved and all work on it is complete.

Some languages, e.g., C# and Go, have channels that are modern versions of blocking queues. See Passing Data Asynchronously Between Producers and Consumers (.NET) for more about .NET’s Channel<T> data structure.

A lost-wakeup bug can be demonstrated using a toy implementation of a blocking queue. Suppose the consumer runs:

lock.lock()
bool empty = queue.isEmpty();
lock.unlock();

if (empty)
  event.waitForSignal();

… and the producer runs:

lock.lock();
queue.add(item);
lock.unlock();

event.signal();

… then the producer can add an item and signal after the consumer’s check, but before its wait. In that case, the consumer sleeps even though work is available. Correct usage involves checking the predicate in a while loop while holding the lock and calling a condition variable’s await() (which atomically releases that same lock and parks the thread).

References

  1. threading — Thread-based parallelism — Python 3.14.5 documentation. docs.python.org . Accessed Jun 2, 2026.
  2. Introduction to Concurrency | Hello Interview Low Level Design. www.hellointerview.com . Accessed Oct 21, 2026.
  3. volatile keyword - C# reference | Microsoft Learn. learn.microsoft.com . Accessed Oct 21, 2026.
  4. queue — A synchronized queue class — Python 3.15.0 documentation. docs.python.org . Accessed Oct 21, 2026.
  5. Coordination in Low Level Design | Hello Interview Low Level Design. www.hellointerview.com . Accessed Oct 22, 2026.