Skip to content
aviral gupta

// A3.1 · ~30 min · Advanced

Threads, locks and the GIL

After this lesson you can run functions in threads and wait for them, protect shared data with a Lock, and say when threads speed a program up.

Lesson 1 of 6 in A3 Concurrency

Start of the module

You will be able to

  • Start threads with Thread(target=..., args=...), join them and collect their results
  • Spot a race on shared data and fix it with a Lock held from read to write
  • Explain what the GIL means for CPU-bound and I/O-bound threads, and check for it with sys._is_gil_enabled()
  1. Warm-up · Activity 1 of 7

    Warm-up from module A1: a with block uses the context manager protocol. Which statements are true? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on. Two threads each deposit 10. The barrier makes both read the balance before either writes. What does this print?

    import threading
    
    balance = 0
    both_have_read = threading.Barrier(2)
    
    
    def deposit() -> None:
        global balance
        seen = balance  # read
        both_have_read.wait()  # wait until the other thread has read too
        balance = seen + 10  # write
    
    
    threads = [threading.Thread(target=deposit) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(balance)
  3. Practice · Activity 3 of 7

    Fill in the method that waits until the thread has finished, so that results is complete when it is printed.

    import threading
    
    results = []
    
    
    def work():
        results.append("done")
    
    
    t = threading.Thread(target=work)
    t.start()
    t.____()
    t.()
  4. Practice · Activity 4 of 7

    Four threads each add 1 a thousand times, holding a lock for each addition. What does this print?

    import threading
    
    counter = 0
    lock = threading.Lock()
    
    
    def add_many():
        global counter
        for _ in range(1000):
            with lock:
                counter += 1
    
    
    threads = [threading.Thread(target=add_many) for _ in range(4)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(counter)
  5. Practice · Activity 5 of 7

    Match each call to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The thread is called worker. What does this print?

    import threading
    
    
    def work():
        print("working in", threading.current_thread().name)
    
    
    t = threading.Thread(target=work(), name="worker")
    t.start()
    t.join()
  7. Apply · Activity 7 of 7

    Mini-task, on your own computer. Write a program that starts one thread per word in ["alpha", "be", "gamma"]. Each thread stores len(word) under its word in a dict and adds it to a shared total under a Lock. After joining every thread, print the sorted dict items and the total. Then print sys._is_gil_enabled() to see whether the GIL is enabled in your process.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Counting words in three documents at once

Three threads each count the words of one document. Each thread writes its own key in word_counts, so those writes never collide, but all three add to the shared total, so that addition is guarded by a lock. The main thread starts all three, joins them, and only then reads the results. Save it as main.py and run python main.py (python3 main.py on macOS and Linux) on your own computer: the browser runtime cannot start threads.

main.py

import threading

DOCUMENTS = {
    "intro.txt": "threads share memory and take turns under the GIL",
    "locks.txt": "a lock lets one thread at a time change shared data",
    "join.txt": "join waits until a thread has finished",
}

word_counts: dict[str, int] = {}
total = 0
total_lock = threading.Lock()


def count_words(name: str, text: str) -> None:
    global total
    n = len(text.split())
    word_counts[name] = n  # each thread writes its own key
    with total_lock:  # read, add, write back: one thread at a time
        total += n


threads = [threading.Thread(target=count_words, args=(name, text)) for name, text in DOCUMENTS.items()]
for t in threads:
    t.start()
for t in threads:
    t.join()  # wait for every thread before reading the results

for name in DOCUMENTS:
    print(f"{name}: {word_counts[name]} words")
print("total:", total)
print("still running:", sum(t.is_alive() for t in threads))

Run it with

python main.py

Output

intro.txt: 9 words
locks.txt: 11 words
join.txt: 7 words
total: 27
still running: 0
  • The results are printed in the order of DOCUMENTS, not in the order the threads happened to finish.
  • args=(name, text) is a tuple; Thread calls count_words(name, text) in the new thread.
  • total += n is guarded by total_lock, so the total is 27 on every run.
  • still running: 0 shows that join() waited for all three threads.

Exercises

Exercise 1 of 2

Run jobs in threads

Complete run_all(jobs) in main.py. jobs is a list of functions that take no arguments and return a string. Run each job in its own thread, start them all before joining any, and return the results in the order of jobs. A thread cannot return a value, so give each one its own slot in a list. Work on your own computer, because the browser cannot start threads: python main.py prints a short demo, and python learnrun.py test runs the tests.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Make results = [""] * len(jobs) first, and a small inner function run(i, job) that stores job() in results[i].

  2. Hint 2

    Create one Thread per job with target=run and args=(i, job); enumerate(jobs) gives you i.

  3. Hint 3

    Two loops: one that calls start() on every thread, then one that calls join() on every thread.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import threading
from collections.abc import Callable


def run_all(jobs: list[Callable[[], str]]) -> list[str]:
    """Run each job in its own thread; return the results in the order of jobs."""
    results = [""] * len(jobs)

    def run(i: int, job: Callable[[], str]) -> None:
        results[i] = job()

    threads = [threading.Thread(target=run, args=(i, job)) for i, job in enumerate(jobs)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return results


if __name__ == "__main__":
    print(run_all([lambda: "one", lambda: "two", lambda: threading.current_thread().name]))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import threading
from collections.abc import Callable


def run_all(jobs: list[Callable[[], str]]) -> list[str]:
    """Run each job in its own thread; return the results in the order of jobs."""
    # This runs the jobs one after another in the main thread.
    # Start one threading.Thread per job, then join them all.
    return [job() for job in jobs]


if __name__ == "__main__":
    print(run_all([lambda: "one", lambda: "two", lambda: threading.current_thread().name]))

test_main.py

import threading

from main import run_all


def test_order():
    """The results come back in the order of the jobs"""
    got = run_all([lambda: "a", lambda: "b", lambda: "c"])
    assert got == ["a", "b", "c"], f"run_all returned {got!r}, expected ['a', 'b', 'c']"


def test_not_in_main_thread():
    """Every job runs outside the main thread"""
    got = run_all([lambda: threading.current_thread().name for _ in range(3)])
    assert "MainThread" not in got, f"the jobs ran in {got!r}: start a Thread for each job"


def test_all_at_once():
    """All three jobs run at the same time and meet at a barrier"""
    barrier = threading.Barrier(3, timeout=0.5)

    def job():
        barrier.wait()
        return "met"

    try:
        got = run_all([job, job, job])
    except threading.BrokenBarrierError:
        got = []
    assert got == ["met", "met", "met"], f"run_all returned {got!r}: start every thread before you join any"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A bank account without lost deposits

Account.deposit reads the balance, calls pause() and writes the new balance. The tests pass a pause() that holds two threads between the read and the write, so one deposit gets lost. Fix deposit with the account's lock so that two deposits of 10 at the same moment always give 20. Keep the read, pause() and the write inside the lock. python main.py runs two deposits in threads; only the tests force the unlucky timing.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    The bug is between seen = self.balance and the write: another thread can read the same old balance there.

  2. Hint 2

    with self._lock: acquires the lock and releases it at the end of the block, even on an exception.

  3. Hint 3

    Indent all three lines, the read, pause() and the write, under with self._lock:.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import threading
from collections.abc import Callable


class Account:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit(self, amount: int, pause: Callable[[], None] = lambda: None) -> None:
        """Add amount to the balance. pause() runs between the read and the write."""
        with self._lock:
            seen = self.balance
            pause()
            self.balance = seen + amount


if __name__ == "__main__":
    account = Account()
    threads = [threading.Thread(target=account.deposit, args=(10,)) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print("balance:", account.balance)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import threading
from collections.abc import Callable


class Account:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit(self, amount: int, pause: Callable[[], None] = lambda: None) -> None:
        """Add amount to the balance. pause() runs between the read and the write."""
        # Two threads can both read the old balance here. Use self._lock.
        seen = self.balance
        pause()
        self.balance = seen + amount


if __name__ == "__main__":
    account = Account()
    threads = [threading.Thread(target=account.deposit, args=(10,)) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print("balance:", account.balance)

test_main.py

import threading

from main import Account


def deposit_together(account, amount, pause):
    threads = [threading.Thread(target=account.deposit, args=(amount, pause)) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return account.balance


def test_one_deposit():
    """One deposit of 25 gives a balance of 25"""
    account = Account()
    account.deposit(25)
    assert account.balance == 25, f"the balance is {account.balance}, expected 25"


def test_no_lost_update():
    """Two deposits of 10 at the same moment give 20"""
    barrier = threading.Barrier(2, timeout=0.05)

    def pause():
        try:
            barrier.wait()  # both threads stop here, between read and write
        except threading.BrokenBarrierError:
            pass

    got = deposit_together(Account(), 10, pause)
    assert got == 20, f"the balance is {got}, expected 20: one deposit was lost, so hold the lock from the read to the write"


def test_lock_is_free_afterwards():
    """The lock is released after each deposit"""
    account = Account()
    account.deposit(5)
    account.deposit(5)
    assert not account._lock.locked(), "the lock is still held after deposit(): use with self._lock:"
    assert account.balance == 10, f"the balance is {account.balance}, expected 10"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Starting the same thread twice

import threading


def work():
    pass


t = threading.Thread(target=work)
t.start()
t.join()
t.start()

What Python prints

RuntimeError: threads can only be started once

Why, and the fix

A Thread object runs its target once. To run the work again, create a new Thread: threading.Thread(target=work).start(). For many repeated jobs, a pool of threads (concurrent.futures, two lessons on) reuses its threads for you.

Joining a thread before starting it

import threading


def work():
    pass


t = threading.Thread(target=work)
t.join()
t.start()

What Python prints

RuntimeError: cannot join thread before it is started

Why, and the fix

join() waits for a running thread to end, so the thread must have been started. Call start() on every thread first, then join() on every thread.

Releasing a lock you do not hold

import threading

lock = threading.Lock()
lock.release()

What Python prints

RuntimeError: release unlocked lock

Why, and the fix

release() is only allowed on a locked Lock. Pairing acquire() and release() by hand goes wrong easily, above all when an exception skips the release. Write with lock: instead: it acquires on entry and always releases on exit.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Start, then join

threading.Thread(target=f, args=(x,)) creates a thread object; nothing runs yet. start() runs f(x) in a new thread, and may be called only once per thread object. join() blocks the calling thread until that thread has finished. Start every thread first and join them afterwards, or they run one after another. Pass the function itself as target, not f(): f() runs it right away in the current thread. A thread has no return value you can read, so let it write its result into a list slot or dict key of its own.

Races and locks

balance += 10 is three steps: read, add, write. If two threads both read before either writes, one update is lost. That is a race condition: the result depends on the timing of the threads. A threading.Lock lets one thread at a time through: with lock: acquires it on entry and releases it on exit, even when an exception is raised. Hold the lock for the whole read-modify-write, not for each step separately. The free-threading docs recommend a Lock over relying on the internal locks of built-in types.

The GIL and the free-threaded build

In the default CPython build, the global interpreter lock (GIL) lets only one thread execute Python bytecode at a time. Threads still help with I/O-bound work: the GIL is released while a thread waits for a file or the network. CPU-bound Python code does not get faster with threads; use processes (next lesson). Since 3.13 there is an optional free-threaded build, where the GIL is disabled and threads run in parallel; in 3.14 it is supported and no longer experimental, but still optional. sys._is_gil_enabled() returns True when the GIL is enabled in the running process.

Sources

Last reviewed September 29, 2026