Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~31 min· topic 3 of 9

Topic 12.3

Threads with threading

In one line

A thread is a flow of code that runs inside your process and shares its memory. You start threads with threading.Thread, wait for them with join, and protect shared data with a Lock; queues and thread-local data are the safer ways to share and separate state.

Think of it like this

Two clerks sharing one notebook at a shop counter. Both can serve customers at once, which is fast. But if both read the stock number 10, both sell one item, and both write 9, the notebook says 9 when the real stock is 8. A race condition is exactly this. A lock is a pen that only one clerk may hold: whoever has the pen reads, updates and writes the number before handing the pen over.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Thread
A flow of code inside a process, sharing memory with the other threads.
start / join
start begins running the thread; join waits until it has finished.
Race condition
A bug where the result depends on the timing of threads.
Lock (mutex)
An object only one thread can hold at a time, used to guard shared data.
Critical section
The piece of code that must run by one thread at a time.
Deadlock
Threads waiting for each other forever, each holding something the other needs.
Daemon thread
A background thread that is killed when the main program ends.
Sentinel
A special value placed in a queue to tell a consumer to stop.
Thread-local
Data that each thread sees as its own private copy.

Step by step

01Start and join

A thread runs a function. start() returns at once and the new thread runs alongside the main one; join() blocks until it ends. Without join, the main thread might finish and print its result before the workers are done.

Important: the Run button on this page cannot start threads. The browser sandbox does not allow a web page to create new threads for Python, so Thread.start() raises RuntimeError. That is why the thread programs in this topic were run on a normal Python and their output is pasted next to them. Everything around threads that does not need a new thread (locks, queues, thread-local data) runs live below.

main.pywhole filepython
import threading

def work(n):
    print("working on", n)

t = threading.Thread(target=work, args=(1,))
t.start()      # runs work(1) in a new thread
t.join()       # wait for it

02How a race loses an update

The next example does not need real threads. It splits two withdrawals into their three steps (read, calculate, write) and runs the steps in an unlucky order, the way the operating system could. Both read 100. A writes 70, then B writes 50. The final balance is 50, but two withdrawals of 30 and 50 should leave 20.

This is a lost update, and it is the same bug as counter += 1 from two threads. Simulating the schedule by hand makes it deterministic so you can see it.

The simulation uses generators. Recall from Topic 9.2: a generator pauses at each yield and continues where it stopped when you call next on it. Here each pause stands for a moment where the operating system could switch threads.

How a race loses an updatediagram
Rendering diagram…

03Fix it with a lock

Wrap the read-modify-write in with lock:. A thread that arrives while another is inside waits at the with line. The result is now always correct: four threads adding 10,000 each always give 40,000. The cost is that the locked part runs one thread at a time, so keep it as small as you can.

main.pywhole filepython
lock = threading.Lock()
counter = 0

def work(times):
    global counter
    for _ in range(times):
        with lock:          # read, add, store as one unit
            counter += 1

04Locks and queues without threads

A lock is only an object with a state, so its API runs anywhere: locked() tells you if it is held, acquire(blocking=False) returns False instead of waiting, and acquire(timeout=...) waits up to that long. release() on a lock that is not held raises RuntimeError. A queue.Queue is first-in first-out and can have a maximum size; put_nowait and get_nowait raise queue.Full and queue.Empty.

05Producer and consumer with a queue

A consumer thread loops forever on queue.get(). The producer puts the work, then one sentinel. jobs.join() waits until task_done() was called for every item, and worker.join() waits for the thread to exit. No lock appears in the code, because the queue does the locking. Shared results are only appended by one thread here.

main.pywhole filepython
def consumer():
    while True:
        item = jobs.get()
        if item is STOP:
            jobs.task_done()
            break
        results.append(item.upper())
        jobs.task_done()

06Deadlock, and how a timeout helps

Thread 1 holds lock A and wants B; thread 2 holds B and wants A. With a plain acquire() both would wait forever and the program would hang. With a timeout, thread 1 gives up after 0.1 seconds and releases A, which lets thread 2 finish. In real code a timeout is a safety net: the real fix is a fixed lock order.

Deadlock, and how a timeout helpsdiagram
Rendering diagram…

07Daemon threads and thread-local data

A thread created with daemon=True is abandoned when the main thread ends, so never use it for work that must complete. Thread-local data is the opposite of shared data: set local.name = "x" in one thread and other threads see their own name (or an AttributeError if they never set it). Frameworks use it to keep a per-thread request or connection.

Try it yourself

  1. 1

    Reorder the schedule

    In the race example change the schedule to [a, a, a, b, b, b] so A finishes before B starts. Predict the final balance first. It is 20, because B now reads the balance A already updated. The bug is not in the code of either thread: it is in the interleaving.

  2. 2

    Poke the lock

    In the lock example call lock.release() twice in a row and read the error. Then replace the manual acquire/release with with lock: and notice you can no longer forget the release.

  3. 3

    Run the thread programs on your computer

    Save the fourth example as counter.py and run python counter.py. Then delete the with lock: line (and un-indent its body) and run it several times. On many machines the total stays 40000 for small loops because switches are rare; increase 10_000 to 1_000_000 and the lost updates become much more likely. A bug that depends on timing is exactly why locks are needed even when your test passes.

Code & diagrams

What happens when the Run button starts a thread New tab

This is the browser sandbox talking, not a bug in your code. The same program works on a normal Python.

Sign in to run this example in your browser.

Expected output

RuntimeError: can't start new thread
Current thread: MainThread
Threads alive: 1
A race condition, replayed by hand New tab

Each generator is a thread's three steps. We choose the order in which they run, so the bug happens every time.

Sign in to run this example in your browser.

Expected output

A read 100
B read 100
A calculated 70
B calculated 50
A wrote 70
B wrote 50
Final balance: 50 (it should be 20)
Locks, thread-local data and queues without starting a thread New tab

The objects work in a single thread, so you can learn their API here.

Sign in to run this example in your browser.

Expected output

locked at start: False
after acquire: True
second try, non-blocking: False
second try, 0.01 s timeout: False
inside with: True
after with: False
RLock taken twice by one thread: fine
main's value
queue is full
summarise translate | size now: 0
queue is empty
Threads, join, Lock and thread-local (run on a normal Python)python

Not runnable in the browser. Verified with Python 3.12 on a desktop. The output is the same every run because the lock makes the counter exact and join waits for every thread.

import threading

counter = 0
lock = threading.Lock()
local = threading.local()


def work(name, times):
    global counter
    local.name = name                 # each thread sees only its own value
    for _ in range(times):
        with lock:                    # only one thread inside at a time
            counter += 1
    assert local.name == name


threads = [threading.Thread(target=work, args=(f"worker-{i}", 10_000)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()                          # wait until each one has finished

print("Final counter:", counter)
print("Threads still alive:", threading.active_count())

Output

Final counter: 40000
Threads still alive: 1
Producer/consumer queue and a deadlock broken by a timeout (normal Python)python

Not runnable in the browser. Verified with Python 3.12 on a desktop. T1 is impatient (0.1 s) and T2 patient (0.6 s), so the outcome is the same every time.

import queue
import threading

jobs = queue.Queue()
results = []
STOP = None                            # a sentinel: "no more work"


def consumer():
    while True:
        item = jobs.get()
        if item is STOP:
            jobs.task_done()
            break
        results.append(item.upper())
        jobs.task_done()


worker = threading.Thread(target=consumer)
worker.start()
for prompt in ["summarise", "translate", "classify"]:
    jobs.put(prompt)
jobs.put(STOP)
jobs.join()                            # wait until every item has been processed
worker.join()
print("Results:", results)

# Deadlock: each thread holds one lock and waits for the other's.
lock_a, lock_b = threading.Lock(), threading.Lock()
both_hold_one = threading.Barrier(2)
report = []


def grab(first, second, name, patience):
    with first:
        both_hold_one.wait()           # make sure the bad timing really happens
        got = second.acquire(timeout=patience)
        report.append(f"{name} got the second lock: {got}")
        if got:
            second.release()


t1 = threading.Thread(target=grab, args=(lock_a, lock_b, "T1", 0.1))
t2 = threading.Thread(target=grab, args=(lock_b, lock_a, "T2", 0.6))
t1.start(); t2.start(); t1.join(); t2.join()
print(sorted(report))

Output

Results: ['SUMMARISE', 'TRANSLATE', 'CLASSIFY']
['T1 got the second lock: False', 'T2 got the second lock: True']

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Release a lock you do not hold

Call release() on a lock that was never acquired.

main.pywhole filepython
import threading

lock = threading.Lock()
lock.release()
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 4, in <module>
lock.release()
~~~~~~~~~~~~^^
RuntimeError: release unlocked lock

Myth vs fact

Myth

Because of the GIL I never need locks in Python.

Fact

The GIL can switch threads between the steps of counter += 1, so updates get lost (Topic 12.2). Any shared data that is changed by more than one thread needs protection.

Myth

If my test passed, the threaded code is correct.

Fact

Race conditions depend on timing and may need millions of operations to appear. Design for safety (queues, locks) instead of trusting a green run.

Myth

A daemon thread is a faster thread.

Fact

It only changes what happens at exit: daemon threads are killed when the main program ends. It does not change speed.

Myth

Adding more locks makes code safer.

Fact

More locks mean more chances of deadlock and less parallelism. Fewer, smaller, consistently ordered locks are safer.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Prefer message passing (queue.Queue) or immutable data over shared mutable state with locks. Most thread bugs are shared-state bugs, and a design with one owner per piece of data cannot race.

  • ▸

    Thread safety is a property of your sequence of operations, not of single calls. if key not in cache: cache[key] = compute() is two operations, so two threads can both compute. Hold a lock across the whole check-and-set, or use a library that provides an atomic operation.

  • ▸

    Other synchronisation tools exist: Event (one thread signals others), Condition, Semaphore, Barrier (used above to force the deadlock timing), and threading.Timer. Exceptions raised inside a thread do not reach the main thread; threading.excepthook is called instead, and a thread pool (Topic 12.4) returns them through futures, which is one more reason to prefer pools.

  • ▸

    In a free-threaded build (Topic 12.2) threaded code runs on several cores, and races that rarely showed up under the GIL become frequent. Code that was only safe by luck gets exposed, so run your thread tests there too if you plan to support it.

  • ▸

    A non-daemon thread keeps the interpreter alive: at exit Python waits for every non-daemon thread to finish, so a forgotten worker loop makes the program hang after its last line. Give long-running workers a stop signal (a threading.Event they check, or a sentinel in their queue) and join them. Signal handlers and KeyboardInterrupt are delivered to the main thread only, so keep the main thread responsive (loop on join(timeout=...)) if Ctrl+C must stop the program.

  • ▸

    Lock is not reentrant: the same thread acquiring it twice deadlocks itself, which is the case RLock exists for. Better still, design so a locked function never calls another locked function. From Python 3.13, queue.Queue.shutdown() signals consumers to stop without a sentinel.

Remember this

  1. 1

    threading.Thread(target=fn, args=(...)) creates a thread, .start() runs fn in it, and .join() waits until it is finished. A Thread object can be started once. All threads of a process share the same variables, which is what makes them handy (no copying) and dangerous (everyone can change everything).

  2. 2

    A race condition is a bug where the result depends on the exact timing of threads. The classic cause is a read-modify-write on shared data (counter += 1, Topic 12.2). It is the worst kind of bug to debug, because it may appear once in a thousand runs and vanish when you add a print.

  3. 3

    A Lock (a mutex) lets one thread at a time into a section of code. Use it as with lock: so it is always released, even if an error occurs. (Recall from Topic 7.4: with guarantees cleanup when its block ends.) Keep the locked section small. acquire() can also be non-blocking or have a timeout, and RLock lets the same thread take a lock again, which matters when one locked function calls another.

  4. 4

    A deadlock happens when threads wait for each other forever: thread 1 holds lock A and waits for B, thread 2 holds B and waits for A. Nothing is wrong with either thread alone. Prevent it by always taking locks in the same order, holding as few as possible, and using timeouts so a stuck thread can report instead of hanging.

  5. 5

    **queue.Queue is the safest way to pass work between threads. It is thread-safe** already, meaning it is safe to use from many threads at once, so you need no lock in your code. Producers put items and consumers get them, and join waits until every item has been marked done with task_done. A special value (a sentinel) such as None tells a consumer to stop. Prefer sending messages through a queue over sharing variables behind locks.

  6. 6

    A daemon thread (daemon=True) does not stop the program from exiting: when only daemon threads remain, Python ends and kills them mid-work. Use them only for background jobs where losing the last bit of work is fine. Thread-local data (threading.local()) gives every thread its own copy of the attributes you set on it, useful for things like a per-thread database connection. For I/O-heavy work you will usually use a pool (Topic 12.4) or asyncio instead of managing threads by hand.

Explain it without notes

01

What is a race condition? Give an example with a counter.

02

How does a Lock prevent a race, and why use with lock: instead of acquire/release?

03

What is a deadlock, and how do you prevent one?

04

Why is queue.Queue usually better than shared variables with locks?

05

Your script prints its last line but the process never exits, and it uses threads. What is the likely cause, and what do you change?

Practice

01

A shared counter starts at 0 and many threads add to it. Write a function work(times) that adds 1 to the global counter times times, with each addition inside with lock: so no update is lost. Call work(5) once and print counter.

02

Write a function try_take(lock) that returns "got it" if the lock could be acquired without waiting (and releases it), and "busy" otherwise. Test it on a free lock and a held lock.

03

Write a function drain(q) that takes every item out of a queue.Queue without blocking and returns them as a list.

Trade-offs

  • ↔

    Locks: simple and flexible, but easy to forget, easy to hold too long, and the source of deadlocks.

  • ↔

    Queues: safe and easy to reason about, but they add a hand-off and work best when the design is producer/consumer.

  • ↔

    Threads in general: cheap sharing and good for blocking I/O, but timing bugs are hard to reproduce, so many teams prefer pools or asyncio for new code.

Done when you can

  • Done when you can start and join a thread and explain why the Run button here cannot.

  • Done when you can explain a lost update with the three steps read, calculate, write.

  • Done when you can protect a counter with with lock: and explain why it must wrap the whole read-modify-write.

  • Done when you can describe a deadlock and name two ways to prevent it.

  • Done when you can build a producer and consumer with queue.Queue and a sentinel.