Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Frameworks & Tooling  ›  Lesson

Threads and the GIL

Threading 16 minthe GIL lets one thread run Python bytecode at a time; threads help I/O waits, not CPU work; race conditions as lost updates
Your task
Write final_value(schedule, start=0). schedule is a list of [thread_id, step] pairs where step is "read", "incr" or "write". Each thread has its own local value: "read" copies the shared counter into that thread's local, "incr" adds one to the local, "write" copies the local back into the shared counter. Simulate the schedule in order and return the final shared value. Example: a schedule where A fully finishes before B starts gives 2; an interleaved schedule where both read before either writes gives 1 (a lost update).

Threads and the GIL

In short: concurrency is structuring a program to deal with many things at once (an order being packed while another is being paid for); parallelism is actually doing many things at the literal same instant on different cores. CPython's threading module gives you concurrency, but not parallelism — the GIL (Global Interpreter Lock) only ever lets one thread run Python bytecode at a time, no matter how many CPU cores you have. A thread that's waiting (for a file, a socket, a timer) releases the GIL, so other threads can run while it waits — that's where threads genuinely help. A thread that's computing holds it until the interpreter forces a switch every few milliseconds, so four threads doing pure arithmetic just take turns and run no faster than one. (Python 3.13+ also ships an optional "free-threaded" build without a GIL; the standard build still has one.) And a thread can be interrupted between any two bytecodes, which is exactly what makes two threads sharing one counter lose updates. None of threading.Thread, Lock or real parallel timing can run here — Pyodide has no real OS threads, and timing is never deterministic anyway. What is deterministic, and runs here, is simulating an interleaving by hand: given an exact order of steps, compute what a shared counter ends up holding.

One door, the GIL: thread A is inside running Python bytecode while threads B and C wait outside. A thread doing a slow network wait releases the lock so another can run; pure number-crunching only takes turns when the interpreter forces a switch

Wait for a slow download, not block on it

Threads earn their keep when most of the time is spent waiting, not computing — three downloads waiting on the network can all be "in flight" at once, because each one releases the GIL the moment it's waiting on the network, not the CPU:

import threading
import time

def download(name, seconds):
    time.sleep(seconds)          # simulates waiting on the network
    print(f"{name} done")

threads = [threading.Thread(target=download, args=(f"file{i}", 1)) for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()                     # wait for every thread to finish
# all three ~1 second apart finish in ~1 second total, not ~3 —
# each sleep() releases the GIL while it waits

t.start() launches the thread; t.join() blocks the main thread until that one finishes. Three 1-second waits finish in about 1 second total, not 3, because while one thread sleeps, another can run.

Crunch numbers across 4 threads

The same pattern applied to CPU-bound work doesn't speed anything up — there's no waiting to overlap, only computing, and the GIL only lets one thread compute at a time:

import threading

def count_to(n):
    total = 0
    for i in range(n):
        total += 1
    return total

threads = [threading.Thread(target=count_to, args=(50_000_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
# four threads, but still only ONE running Python bytecode at any instant —
# this takes roughly as long as running count_to four times in a row

This is the one-line test for "will threads help here?" — does the work spend most of its time waiting, or computing? Waiting: threads help. Computing: they don't (multiprocessing, next lesson, is the fix for that).

Lose an update to a shared counter

The GIL can swap which thread is running between any two bytecodes — not just between function calls. counter += 1 isn't one step; it's three: read the current value, add one, write it back. Two threads can interleave those three steps and silently lose an update:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1

threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)   # often LESS than 200_000 — some updates got lost

Both threads read the same old value before either writes the new one, so one increment vanishes. A Lock fixes it by making the three steps atomic — only one thread can hold the lock at a time:

lock = threading.Lock()

def safe_increment():
    global counter
    for _ in range(100_000):
        with lock:          # only one thread inside this block at a time
            counter += 1

with lock: is what makes read-add-write one uninterruptible step instead of three interruptible ones. Exactly which interleavings happen is up to the OS scheduler and never guaranteed — but the pattern of a lost update is something you can compute by hand, for an exact, chosen interleaving.

python

Quick practice

quick drill

A function downloads 10 web pages, one after another, each taking about 1 second. Will running it with 10 threads instead of 1 make it roughly 10x faster?

What does this print?
def final_value(schedule, start=0):
    shared = start
    locals_ = {}
    for thread, step in schedule:
        if step == "read":
            locals_[thread] = shared
        elif step == "incr":
            locals_[thread] = locals_[thread] + 1
        elif step == "write":
            shared = locals_[thread]
    return shared

schedule = [["A", "read"], ["A", "incr"], ["B", "read"], ["A", "write"], ["B", "incr"], ["B", "write"]]
print(final_value(schedule))
What does this print?
def final_value(schedule, start=0):
    shared = start
    locals_ = {}
    for thread, step in schedule:
        if step == "read":
            locals_[thread] = shared
        elif step == "incr":
            locals_[thread] = locals_[thread] + 1
        elif step == "write":
            shared = locals_[thread]
    return shared

schedule = [["A", "read"], ["A", "incr"], ["A", "write"], ["A", "read"], ["A", "incr"], ["A", "write"]]
print(final_value(schedule))
quick drill

Four threads each sum 10 million numbers purely in Python (no I/O at all). Compared to doing the same work in one thread, four threads will be about how much faster?


🎯 Your turn

Write final_value(schedule, start=0). schedule is a list of [thread_id, step] pairs, where step is "read", "incr", or "write". Each thread has its own local value: "read" copies the shared counter into that thread's local; "incr" adds one to the local; "write" copies the local back into the shared counter. Simulate the schedule in order and return the final shared value.

  • final_value([["A","read"],["A","incr"],["A","write"],["B","read"],["B","incr"],["B","write"]]) → 2
  • final_value([["A","read"],["B","read"],["A","incr"],["B","incr"],["A","write"],["B","write"]]) → 1 (a lost update)

Hint: keep a dict of each thread's local value, updating it on "read" and "incr", and only touch the shared value on "write".

Press ▶ Run, try the Live App chips, then ✓ Check. After that, the practice set below builds tools for spotting and avoiding the race this function can produce.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upApply one step

Write apply_step(shared, local, step) for a SINGLE thread. step is "read", "incr" or "write". Return [new_shared, new_local] after applying it.

concurrency
DrillRun one thread uninterrupted

Write run_steps(start, steps). A single thread runs every step in steps in order, with nothing else interleaved. Return the final shared value.

concurrency
BuildDetect a risky schedule

Write is_interleaved(schedule). schedule is a list of [thread_id, step] pairs. Return True if the thread ids are NOT grouped into contiguous blocks — i.e. a thread appears again after a DIFFERENT thread has already run at least one step.

concurrency
BossCount the lost updates

Write lost_updates(schedule). Every "incr" step represents one attempted increment, starting from 0. Return how many of those increments never made it into the final shared value — (number of incr steps) - final_value(schedule).

concurrency
CapstoneBuild a schedule with no race

Write make_safe_schedule(threads). Given a list of thread ids, build a schedule where each thread fully completes "read", "incr", "write" before the next thread starts — guaranteed never to lose an update, no matter how many threads.

concurrency
final_value.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
final_value(schedule, start) → intSimulate a schedule of read/incr/write steps across threads sharing one counter, and return the final counter value.
try:
returned
No app map for this section yet.
Press Run to execute your code.
Press Check to run the tests for this lesson.