Warm-up · Activity 1 of 7
// 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
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()
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)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.()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)Practice · Activity 5 of 7
Match each call to what it does.
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()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.pyOutput
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
Hint 1
Make results = [""] * len(jobs) first, and a small inner function run(i, job) that stores job() in results[i].
Hint 2
Create one Thread per job with target=run and args=(i, job); enumerate(jobs) gives you i.
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.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 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
Hint 1
The bug is between seen = self.balance and the write: another thread can read the same old balance there.
Hint 2
with self._lock: acquires the lock and releases it at the end of the block, even on an exception.
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.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon 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 onceWhy, 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 startedWhy, 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 lockWhy, 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.