Filter concepts by levelShowing all levels.

Python · Concurrency and Parallelism

Threading

Concepts
5

Running functions concurrently within one process — the threading module and Thread itself, the coordination primitives (Lock, RLock, Event, Condition, Semaphore) that keep shared state correct, and the three classic ways concurrent code goes wrong.

Python overview

Threads and mutual exclusion

Starting and joining a thread, and the most common tool for protecting shared state from concurrent access.

threading and Thread

standardintermediate

threading.Thread(target=fn, args=...) creates a thread that will run fn. Nothing runs until .start() is called, and the caller can wait for it to finish with .join().

Think of it as

Thread(target=fn) is writing a job description but not hiring yet. .start() is hiring — the worker begins immediately, in parallel with you. .join() is waiting at the door until that worker reports back before you continue.

python
import threading

t = threading.Thread(target=some_function, args=(arg1, arg2))
t.start()   # begins running immediately
t.join()    # blocks here until t finishes

What we're doing: Start two threads that each print before and after a delay, and join both, showing .start() returns immediately while .join() actually waits.

thread_basics.pypython
import threading
import time

def download(name, seconds):
    print(f"{name}: starting")
    time.sleep(seconds)
    print(f"{name}: done")

t1 = threading.Thread(target=download, args=("file-A", 0.05))
t2 = threading.Thread(target=download, args=("file-B", 0.05))
t1.start()
t2.start()
print("main: both started, main keeps running")
t1.join()
t2.join()
print("main: both joined, safe to continue")
9
t1.start() launches the thread and returns immediately — it does not wait for download to finish.
11
This line runs right after both starts, before either thread necessarily finishes — proof start() does not block.
12
t1.join() blocks main until t1 specifically finishes; t2.join() then waits for t2.
Output
file-A: starting
file-B: starting
main: both started, main keeps running
file-A: done
file-B: done
main: both joined, safe to continue

Why this works: .start() hands the function off to a new OS thread and returns control to main immediately — that is why "main: both started" prints before either "done" line. .join() is what actually blocks, giving the caller a way to wait for a specific thread instead of guessing how long it takes.

Reading a thread's result before calling .join()

Wrong

python
import threading

result = {}

def compute():
    result["value"] = 42

t = threading.Thread(target=compute)
t.start()
print(result.get("value"))  # may print None -- thread might not have run yet

Better

python
import threading

result = {}

def compute():
    result["value"] = 42

t = threading.Thread(target=compute)
t.start()
t.join()   # wait for the thread to actually finish first
print(result.get("value"))  # 42, guaranteed

What you see: Inconsistent output — sometimes None, sometimes 42 — because start() does not wait for the thread to finish before returning.

Why: start() only guarantees the thread has begun, not that it has completed. Reading shared state right after start() races against the new thread actually running — join() is what actually establishes "this thread is done" before the next line executes.

Thread — the methods worth knowing

Thread — the methods worth knowing
CallEffect
Thread(target=fn, args=(a,), kwargs={})builds a thread that will run fn(a, **kwargs)
.start()begins running the thread; returns immediately
.join(timeout=None)blocks the caller until the thread finishes (or timeout elapses)
.is_alive()True while the thread is still running
.daemon = Truethread is killed automatically when the main program exits

Together

python
import threading

def download(name):
    print(f"{name}: starting")
    print(f"{name}: done")

t = threading.Thread(target=download, args=("report.csv",))
t.start()
print("is_alive right after start:", t.is_alive())
t.join()
print("is_alive after join:", t.is_alive())

Remember: Thread(target=fn).start() begins running immediately and returns right away; .join() is what actually waits for it to finish.

See also: concurrency vs parallelism · lock and rlock · race conditions deadlocks and starvation

Lock and RLock

coreintermediate

A Lock lets only one thread run a block of code at a time — with acquires it, others wait. An RLock is the same idea, but the SAME thread that holds it can acquire it again without blocking itself.

Think of it as

A Lock is a single bathroom key — whoever holds it is inside, everyone else waits, and even the key-holder can't grab a second copy for themselves. An RLock is a key that recognizes its own holder — the same person can walk back in and out through nested doors without getting stuck waiting for their own key.

python
import threading

lock = threading.Lock()

def safe_increment():
    with lock:          # blocks until acquired, releases automatically
        counter += 1

What we're doing: Run an unsynchronized counter across 4 threads (producing a wrong total from real execution), then fix it with a Lock and get the correct total.

lock_race_fix.pypython
import threading
import time

# --- unsynchronized: a genuine race condition ---
counter = 0

def increment_unsafe():
    global counter
    for _ in range(50_000):
        current = counter
        time.sleep(0)        # forces a thread switch here, widening the race window
        counter = current + 1

threads = [threading.Thread(target=increment_unsafe) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"unsynchronized total (expected 200000): {counter}")

# --- fixed with Lock ---
counter2 = 0
lock = threading.Lock()

def increment_safe():
    global counter2
    for _ in range(50_000):
        with lock:
            current = counter2
            time.sleep(0)
            counter2 = current + 1

threads = [threading.Thread(target=increment_safe) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"locked total (expected 200000): {counter2}")
9
Reading counter into current, then writing back later, splits the increment into two steps — the gap between them is where a race can happen.
10
time.sleep(0) forces Python to consider switching threads right in that gap, making the race reliably observable instead of rare.
22
with lock: means only one thread can be between reading current and writing counter2 at a time — the same gap exists, but no other thread can interleave inside it.
Output
unsynchronized total (expected 200000): 50070
locked total (expected 200000): 200000

Why this works: Without a lock, two threads can both read the same current value before either writes back — one increment is silently lost. That is exactly what happened: 149,930 increments vanished, landing on 50,070 instead of 200,000. The Lock forces every read-modify-write to complete as one uninterrupted unit, so no update is ever lost, and the total comes out exactly correct.

A single thread re-acquiring a plain Lock deadlocks

Wrong

python
import threading

lock = threading.Lock()

def outer():
    with lock:
        inner()   # same thread tries to acquire the SAME lock again

def inner():
    with lock:    # blocks forever -- the lock is already held, by this thread
        print("never reached")

outer()  # hangs

Better

python
import threading

rlock = threading.RLock()   # reentrant -- the same thread can re-acquire

def outer():
    with rlock:
        inner()

def inner():
    with rlock:
        print("inner acquired RLock while outer already held it")

outer()  # completes normally

What you see: The program hangs indefinitely (no exception, no output) — a plain Lock does not distinguish "another thread holds this" from "I already hold this myself."

Why: A Lock tracks only whether it is held, not by whom — a second acquire from the SAME thread waits for the lock to be released, but nothing else is running to release it, so it waits forever. RLock adds an owner check and an internal count: the owning thread's re-acquire succeeds immediately and increments the count, requiring a matching number of releases.

A Lock serializes access to shared state

4 threads

all want counter += 1

with lock:

only one thread inside at a time

correct total

no lost updates

  1. 4 threads — all want counter += 1
  2. with lock: — only one thread inside at a time
  3. correct total — no lost updates

Lock vs RLock

Lock vs RLock
PropertyLockRLock
Different threads acquiringsecond thread blocks until releasedsecond thread blocks until released
SAME thread acquiring twicedeadlocks — waits on itself foreversucceeds — internal count goes to 2
Releasingone release, fully unlockedneeds one release per acquire to fully unlock
Typical useprotecting one shared resourcea method that may call another locked method on itself

Together

python
import threading

rlock = threading.RLock()

def outer():
    with rlock:
        inner()          # same thread re-acquiring -- fine with RLock

def inner():
    with rlock:
        print("inner acquired RLock while outer already held it")

outer()

Remember: with lock: makes a block run one thread at a time. A plain Lock deadlocks if the same thread re-acquires it; RLock allows that.

See also: threading and thread · race conditions deadlocks and starvation · coordination primitives

Advertisement

Coordination and failure modes

Coordination primitives beyond simple exclusion, what makes a data structure safe to share across threads, and the three classic ways concurrent code fails.

Event, Condition, and Semaphore

standardintermediate

Event lets one thread signal "something happened" to others waiting on it. Condition lets a thread wait until some shared state becomes true. Semaphore caps how many threads may hold access at once.

Think of it as

Event is a starting pistol — one shot, everyone waiting hears it and goes. Condition is waiting outside a kitchen until someone shouts "order up," then checking it is actually your order. Semaphore is a parking garage with N spaces — the N+1th car waits for a space to free up.

python
evt = threading.Event()
evt.wait()          # blocks until...
evt.set()            # ...another thread calls this

cond = threading.Condition()
with cond:
    cond.wait_for(lambda: some_flag)   # blocks until predicate is True

sem = threading.Semaphore(2)
with sem:            # blocks if 2 threads already hold it
    ...

What we're doing: Demonstrate all three: an Event unblocking a waiter, a Condition releasing a consumer once a producer sets a flag, and a Semaphore capping concurrent workers to 2 out of 6.

coordination.pypython
import threading
import time

# --- Event ---
evt = threading.Event()
def waiter():
    evt.wait()
    print("Event: saw it set")

t = threading.Thread(target=waiter)
t.start()
time.sleep(0.05)
evt.set()
t.join()

# --- Condition ---
cond = threading.Condition()
item_ready = False
def consumer():
    with cond:
        cond.wait_for(lambda: item_ready)
        print("Condition: got the item")

def producer():
    global item_ready
    with cond:
        item_ready = True
        cond.notify()

ct = threading.Thread(target=consumer)
ct.start()
time.sleep(0.02)
pt = threading.Thread(target=producer)
pt.start()
ct.join(); pt.join()

# --- Semaphore ---
sem = threading.Semaphore(2)
active, max_active = 0, 0
sem_lock = threading.Lock()

def limited_worker():
    global active, max_active
    with sem:
        with sem_lock:
            active += 1
            max_active = max(max_active, active)
        time.sleep(0.05)
        with sem_lock:
            active -= 1

workers = [threading.Thread(target=limited_worker) for _ in range(6)]
for w in workers: w.start()
for w in workers: w.join()
print(f"Semaphore: max concurrent = {max_active} (cap was 2)")
7
evt.wait() blocks the thread here until evt.set() is called elsewhere.
18
cond.wait_for(predicate) blocks until item_ready is True AND the condition is notified — checking the predicate, not just waking blindly.
40
with sem: blocks once 2 threads already hold it, so at most 2 of the 6 workers run their body at the same time.
Output
Event: saw it set
Condition: got the item
Semaphore: max concurrent = 2 (cap was 2)

Why this works: Event.set() releases every thread blocked on .wait() at once — a broadcast. Condition.notify() only wakes a waiter to re-check its predicate, which is why wait_for is safer than a bare wait() — it protects against waking up too early or missing a signal. Semaphore.acquire() (via with sem:) simply refuses a 3rd concurrent holder until one of the first 2 releases, which is why max_active never exceeds 2 even with 6 threads competing.

Using Condition.wait() instead of wait_for(predicate)

Wrong

python
cond = threading.Condition()
item_ready = False

def consumer():
    with cond:
        cond.wait()          # wakes on ANY notify, even a spurious/unrelated one
        print(item_ready)    # may still be False!

Better

python
cond = threading.Condition()
item_ready = False

def consumer():
    with cond:
        cond.wait_for(lambda: item_ready)  # re-checks the predicate after each wake
        print(item_ready)    # guaranteed True here

What you see: The consumer sometimes proceeds while item_ready is still False — a bare wait() can return on any notify(), not only the one meant for this specific condition.

Why: cond.wait() only guarantees it was notified, not that the state it cares about actually changed — with multiple waiters or multiple reasons to notify, a wake-up can be spurious for a given consumer. wait_for(predicate) loops internally, re-checking the predicate after every wake, so it only returns once the condition it was told to wait for is actually true.

The three coordination primitives

The three coordination primitives
PrimitivePurposeKey methods
Eventone-shot signal: "this happened"set(), clear(), wait(timeout=None), is_set()
Conditionwait until shared state satisfies a predicatewait(), wait_for(pred), notify(), notify_all()
Semaphore(n)cap concurrent access to n holders at onceacquire(), release()

Together

python
import threading

evt = threading.Event()
cond = threading.Condition()
sem = threading.Semaphore(2)   # at most 2 threads through at once

# Event: one thread waits for a signal
def waiter():
    evt.wait()
    print("saw the event")

# Semaphore: caps concurrent access
def limited_worker():
    with sem:
        ...  # at most 2 of these run at the same time

Remember: Event: one-shot signal. Condition: wait for shared state, checked with wait_for. Semaphore(n): cap concurrent holders at n.

See also: lock and rlock · thread safety and thread safe data structures · race conditions deadlocks and starvation

Thread safety and thread-safe data structures

standardintermediate

A thread-safe operation stays correct when multiple threads call it at once, with no extra locking needed. queue.Queue is thread-safe; a plain list's append/pop are too — but x += 1 on a shared variable is not.

Think of it as

Thread-safe is a self-service counter with one clerk handling requests one at a time internally — you never have to bring your own lock. A shared plain variable is a whiteboard anyone can read AND write without coordination — correct only if everyone agrees, out loud, on when it's their turn.

python
import queue

q = queue.Queue()
q.put(item)        # safe from any thread, no lock needed
item = q.get()      # blocks until something is available

What we're doing: Hand off work items from a producer thread to a consumer thread using queue.Queue, with no manual locking required.

queue_handoff.pypython
import queue
import threading

q = queue.Queue()

def producer():
    for i in range(3):
        q.put(f"item-{i}")
    q.put(None)   # sentinel: signals "no more items"

def consumer(results):
    while True:
        item = q.get()
        if item is None:
            break
        results.append(item)

results = []
t_prod = threading.Thread(target=producer)
t_cons = threading.Thread(target=consumer, args=(results,))
t_prod.start()
t_cons.start()
t_prod.join()
t_cons.join()
print(results)
6
q.put() is safe to call from the producer thread with no lock — Queue handles its own internal locking.
12
q.get() blocks the consumer until an item exists, so it never has to poll or guess whether the producer has added anything yet.
Output
['item-0', 'item-1', 'item-2']

Why this works: queue.Queue does its own internal locking around put/get, so two threads can safely use it as a hand-off point without either one taking out a Lock manually. That is what "thread-safe data structure" means in practice: the safety is built in, not something the caller has to add.

Assuming a shared counter is thread-safe because a list append would be

Wrong

python
import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1   # looks like one step, is really read-then-write

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # NOT reliably 400000

Better

python
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1   # now the whole read-modify-write is one unit

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # reliably 400000

What you see: The final count comes out lower than expected, and the exact number changes between runs — classic evidence of lost updates from a race condition.

Why: counter += 1 is really three steps: read counter, add 1, write it back. list.append and queue.Queue.put are each a single internal operation with their own protection — a compound statement like += is not, even though it looks like one line of code.

What is and is not thread-safe by default

What is and is not thread-safe by default
OperationThread-safe?
queue.Queue().put() / .get()yes — designed for cross-thread hand-off
list.append(x) / list.pop()yes — a single bytecode-level operation
dict[key] = valueyes — same reason
counter += 1 on a shared intno — read-then-write, needs a Lock
balance -= amount (any compound assignment on shared state)no — same reason

Together

python
import queue, threading

q = queue.Queue()

def producer():
    for i in range(3):
        q.put(i)          # thread-safe -- no external lock needed

def consumer():
    for _ in range(3):
        print(q.get())    # thread-safe -- blocks until an item is available

threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()

Remember: Thread-safe means the structure handles its own locking (queue.Queue, single list/dict ops). counter += 1 on shared state needs your own Lock.

See also: lock and rlock · race conditions deadlocks and starvation · coordination primitives

Race conditions, deadlocks, and starvation

coreintermediate

A race condition is losing an update when threads interleave unsafely. A deadlock is threads permanently waiting on each other's locks. Starvation is a thread that never gets the resource it needs, with no deadlock present.

Think of it as

A race condition is two people editing the same shared spreadsheet cell at once and one edit vanishing. A deadlock is two people each holding a door the other needs, both waiting for the other to let go first. Starvation is one person always losing the queue to louder people — no gridlock, just perpetual bad luck.

python
# race condition fix: protect the whole read-modify-write
with lock:
    counter += 1

# deadlock fix: same lock order, everywhere
with lock_a:
    with lock_b:
        ...

What we're doing: Reproduce a real deadlock between two threads acquiring two locks in opposite order, using an acquire timeout so the actual interpreter run terminates and reports what happened instead of hanging.

deadlock_repro.pypython
import threading
import time

lock_a = threading.Lock()
lock_b = threading.Lock()
outcome = {}

def task_1():
    with lock_a:
        outcome["1_got_a"] = True
        time.sleep(0.1)                       # give task_2 time to grab lock_b
        got_b = lock_b.acquire(timeout=0.3)    # would hang forever without timeout
        outcome["1_got_b"] = got_b
        if got_b:
            lock_b.release()

def task_2():
    with lock_b:
        outcome["2_got_b"] = True
        time.sleep(0.1)
        got_a = lock_a.acquire(timeout=0.3)
        outcome["2_got_a"] = got_a
        if got_a:
            lock_a.release()

t1 = threading.Thread(target=task_1)
t2 = threading.Thread(target=task_2)
t1.start(); t2.start()
t1.join(); t2.join()
print(outcome)
10
task_1 holds lock_a and now wants lock_b — but task_2 already holds lock_b at this point.
11
acquire(timeout=0.3) is used here ONLY to make the real deadlock terminate for this demo — a true deadlock with plain with lock_b: would hang forever.
18
task_2 holds lock_b and wants lock_a — the exact reverse order of task_1, which is what creates the deadlock.
Output
{'1_got_a': True, '2_got_b': True, '1_got_b': False, '2_got_a': False}

Why this works: Both threads grab their first lock, sleep briefly (guaranteeing both are held simultaneously), then each tries for the other's lock — the classic deadlock shape. In this run, BOTH timed out (1_got_b and 2_got_a are both False) — neither thread could make progress until the 0.3s timeout forced a release, which is the deadlock actually happening. Rerunning this exact script can produce a different mix of True/False, because the interleaving is timing-dependent — but at least one side failing to get the other's lock happens every time, which is the point: without a fix, this is a real, reproducible deadlock, not a rare edge case.

Acquiring shared locks in a different order in different functions

Wrong

python
def transfer(from_acct, to_acct, amount):
    with from_acct.lock:
        with to_acct.lock:
            from_acct.balance -= amount
            to_acct.balance += amount

# thread 1: transfer(alice, bob, 10)   -> locks alice then bob
# thread 2: transfer(bob, alice, 5)    -> locks bob then alice
# opposite order -- can deadlock if both run at the same instant

Better

python
def transfer(from_acct, to_acct, amount):
    # always lock in a fixed, global order (e.g. by account id) --
    # regardless of which account is "from" and which is "to"
    first, second = sorted([from_acct, to_acct], key=lambda a: a.id)
    with first.lock:
        with second.lock:
            from_acct.balance -= amount
            to_acct.balance += amount

What you see: The program hangs under concurrent transfers in both directions — no exception, no crash, just two threads waiting on each other forever.

Why: transfer(alice, bob, ...) locks alice then bob; transfer(bob, alice, ...) locks bob then alice — if both run at once, each can end up holding the lock the other wants. Sorting a consistent, shared order (like account id) before locking means every thread acquires locks in the same global order, which makes this deadlock shape impossible.

Two threads, two locks, wrong order

Thread 1

  • +holds lock_a
  • +waits for lock_b
  • +never gets it — B holds it

Thread 2

  • holds lock_b
  • waits for lock_a
  • never gets it — A holds it
  • Thread 1
    • holds lock_a
    • waits for lock_b
    • never gets it — B holds it
  • Thread 2
    • holds lock_b
    • waits for lock_a
    • never gets it — A holds it

The three failure modes

The three failure modes
FailureWhat happensTypical fix
Race conditionunsynchronized read-modify-write loses updatesLock (or a thread-safe structure) around the whole operation
Deadlockthreads wait on each other's locks foreveralways acquire multiple locks in the same order
Starvationa thread never gets a turn, no gridlock involvedfair scheduling, timeouts, or priority limits

Together

python
import threading

# consistent lock order across every thread prevents the deadlock shape
lock_a, lock_b = threading.Lock(), threading.Lock()

def always_a_then_b():
    with lock_a:
        with lock_b:
            ...  # never acquire b then a elsewhere -- that reversal is what deadlocks

Remember: Race condition: lost updates from unsynchronized access. Deadlock: circular waiting on locks. Starvation: a thread perpetually denied a turn, no cycle involved.

See also: lock and rlock · thread safety and thread safe data structures · coordination primitives

Advertisement