Python Module 14 – Concurrency and parallelism: threads, processes, interpreters
Threads: threading and ThreadPoolExecutor
Start and join threads, run I/O-bound work on a ThreadPoolExecutor, and watch two threads lose an update when they change the same number.
What you will learn
- Start threads with threading.Thread and wait for them with join()
- Run I/O-bound work on a ThreadPoolExecutor with map() and submit()
- Demonstrate a lost update between two threads and explain it
- Measure how often unprotected updates are lost, and reproduce a check-then-act race
- Handle an exception or a timeout from a worker thread through its future
- Choose a pool size and name worker threads for debugging
Before you start
What you’ll learn · Before you start
What you will learn
- Start threads with threading.Thread and wait for them with join()
- Run I/O-bound work on a ThreadPoolExecutor with map() and submit()
- Demonstrate a lost update between two threads and explain it
- Measure how often unprotected updates are lost, and reproduce a check-then-act race
- Handle an exception or a timeout from a worker thread through its future
- Choose a pool size and name worker threads for debugging
Before you start
On this page
A thread is a second line of execution inside the same program: it runs a function while the rest of the
program carries on, and it sees the same objects. threading.Thread(target=f, args=(x,)) creates one, start() sets
it running and join() waits until it has finished. For I/O-bound work such as downloads, a
concurrent.futures.ThreadPoolExecutor is usually the better tool: it keeps a fixed set of threads, hands them your
function calls and gives the results back in order. Because threads share memory, passing data between them costs
nothing, and that is also their danger: two threads that change the same number without coordinating can lose an
update. This lesson starts threads by hand, then runs a pool of them, then makes a lost update happen.
Starting a thread and waiting for it
Thread takes the function to run as target and its arguments as a tuple in args. A name is optional: without
one, Python calls the thread “Thread-1 (download)”, a number and the function’s name, but a name of your own makes logs
and debuggers far easier to read. Inside the thread, threading.current_thread().name tells the code which thread
it runs in:
import threading
import time
finished = [] # list.append is safe to call from several threads
def download(page, seconds):
time.sleep(seconds) # stands in for waiting on the network
finished.append(f"{page} (in {threading.current_thread().name})")
jobs = {"home": 0.3, "news": 0.1, "cart": 0.2}
threads = [
threading.Thread(target=download, args=(page, seconds), name=f"get-{page}")
for page, seconds in jobs.items()
]
start = time.perf_counter()
for thread in threads:
thread.start() # returns at once: the thread runs on its own
print(f"started {len(threads)} threads; alive now: {sum(t.is_alive() for t in threads)}")
for thread in threads:
thread.join() # waits until that thread has finished
took = time.perf_counter() - start
print("finished in this order:", ", ".join(finished))
print(f"all done after about {took:.1f} s, alive now: {sum(t.is_alive() for t in threads)}") Output
started 3 threads; alive now: 3 finished in this order: news (in get-news), cart (in get-cart), home (in get-home) all done after about 0.3 s, alive now: 0
This output changes from run to run: the total time is measured on each run; the order in which the threads finish stays the same
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 start_join.py
start()returned at once: right after the loop all three threads were alive and sleeping.- They finished in the order of their waits, 0.1, 0.2 and 0.3 seconds, not in the order they started, and the whole job took about as long as the slowest download.
join()blocked until each thread had finished, so the final count of living threads is zero.
A thread created with daemon=True is stopped abruptly when the program exits, without running its clean-up, so give
that flag only to work that may be cut off at any moment. An ordinary thread keeps the program alive: Python waits for
every one of them to finish before it exits.
A pool of threads with ThreadPoolExecutor
Creating one thread per task stops scaling once there are thousands of tasks. A ThreadPoolExecutor starts at most
max_workers threads and feeds them the calls one at a time:
What pool.map() does with ten downloads and five threads
Text description of the diagram
1. The main thread calls pool.map(download, PAGES). The pool puts one call of download() per page into its work queue, in the order of PAGES. 2. Five worker threads each take the next call from the queue, run it, and take another as soon as they are free, so at most five downloads are in progress at a time. 3. Each finished call stores its result. map() hands the results back in the order of PAGES, not in the order in which the downloads finished, so list() waits until every result is there. 4. When the with block ends, the pool shuts down: it waits for the calls in progress and stops its threads.
import time
from concurrent.futures import ThreadPoolExecutor
PAGES = [f"/results/class-{n}" for n in range(1, 11)] # ten pages to download
def download(path):
time.sleep(0.2) # each page takes 0.2 s to arrive
return f"{path}: {len(path) * 120} bytes"
start = time.perf_counter()
one_by_one = [download(path) for path in PAGES]
sequential = time.perf_counter() - start
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
pooled = list(pool.map(download, PAGES)) # results come back in the order of PAGES
threaded = time.perf_counter() - start
print(f"one after another: {sequential:.1f} s")
print(f"5 worker threads: {threaded:.1f} s")
print(f"same results, same order: {one_by_one == pooled}")
print(pooled[0]) Output
one after another: 2.0 s 5 worker threads: 0.4 s same results, same order: True /results/class-1: 1920 bytes
This output changes from run to run: the times are measured on each run; the results are the same every time
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_downloads.py
Ten waits of 0.2 seconds took 2 seconds in turn and about 0.4 seconds on five threads: two rounds of five downloads
in parallel. pool.map() returned the results in the order of PAGES, so the list compares equal to the one built in
turn. pool.submit(download, path) is the other way in: it returns a Future at once, a placeholder for a result that
does not exist yet, and future.result() waits for that one call. The with block waits for every call before it
shuts the pool down, so no thread is left running when the block ends. Leave out max_workers and the pool chooses a
size meant for waiting work, which is a sensible start for downloads like these.
When threads share data: a lost update
balance = balance + amount looks like one step, but it is three: read the balance, add, write it back. Python’s
documentation lists i = i+1 among the operations that are not atomic. If another thread runs between the read and
the write, one of the two updates disappears. The scheduler does that only now and then, so this example uses a
threading.Barrier to make it happen every time: each thread waits at the barrier, after its read, until the other
thread has read too.
import threading
class Wallet:
def __init__(self, balance):
self.balance = balance
wallet = Wallet(0)
both_have_read = threading.Barrier(2) # makes the unlucky timing happen every time
def deposit(amount):
current = wallet.balance # 1. read
both_have_read.wait() # wait until the other thread has read too
wallet.balance = current + amount # 2. add and 3. write
threads = [threading.Thread(target=deposit, args=(500,)) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("two deposits of 500 each")
print(f"expected balance: 1000, actual balance: {wallet.balance}") Output
two deposits of 500 each expected balance: 1000, actual balance: 500
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lost_update.py
Both threads read 0, both computed 500, and the second write replaced the first. The program raised no error and gave
no warning: the bug shows up only as a wrong number, and only when the timing is unlucky. Python’s glossary calls this
a race condition: the result depends on the order in which the threads happen to run. There are three ways out.
A lock makes the read, the add and the write one step that only one thread at a time may take. A queue lets
one thread own the data while the others send it requests. And returning results instead of sharing them, as
pool.map() does above, avoids shared state altogether. The next lesson builds the first two.
Check yourself
Four questions on threads and pools
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Lost updates counted, and a check-then-act race reproduced Pro: opens with a Pro, Premium or Ultimate pass
A race shows up only when a thread is switched at the wrong moment, so it can hide from tests for months. This part counts lost updates on CPython 3.14.8 with and without a wait inside the update, reproduces a check-then-act race with real money, and shows how to make such bugs fail every time in a test.
In this part:
- Four million unprotected increments, counted
- Check-then-act: two threads pass the same check
- Making a race fail every time in a test
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Errors, timeouts and pool sizes in real thread code Pro: opens with a Pro, Premium or Ultimate pass
A thread that crashes does not stop the program, and a pool sized by guesswork either piles up threads or leaves work waiting. This part shows where a worker’s exception goes, how a future hands it back, how to stop waiting for a slow call, and how large a pool should be, with the numbers worked out.
In this part:
- Where a worker’s exception goes
- Waiting less than forever
- How many threads: the default and a rule with numbers
- Exercise: fetch every page, in order, with errors per URL
- Fetch every page on a pool, in order, with errors per URL
3 runnable examples (Python) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What do start() and join() do on a threading.Thread, and what happens to a daemon thread when the program exits?
start()asks the operating system to run the thread's target function alongside the code that called it, and it returns at once, so the caller carries on while the thread works. Callingstart()a second time on the sameThreadraisesRuntimeError.join()blocks the calling thread until the thread has finished, or until the optional timeout runs out, after whichis_alive()says whether it is still running. A daemon thread, created withdaemon=True, does not keep the program alive: when only daemon threads are left, Python exits and stops them abruptly, without running their clean-up code, so open files or half-written data may be left behind. Ordinary threads are waited for before the program exits, which is why long-running workers need a way to be told to stop. -
Mid to senior
Every two minutes a job must check the status of up to 6,000 pending UPI payments through a provider's API. Each call takes about 400 ms, and the provider allows at most 25 calls in flight. Design the threading part: how many threads, and how do you handle slow and failed calls?
-
Mid to senior
A background thread that uploads log files stopped working a week ago, and nobody noticed: the service kept running and no error reached the monitoring. Explain how that can happen, and how you would make such failures visible.
-
Senior to staff
A counter of processed orders is updated from a pool of threads. Every test passes, but in production the daily total is a few hundred short of the database's count. How do you prove that a race causes it, and how do you fix it for good?
Key takeaways
Thread(target=f, args=(x,)).start()runsf(x)alongside the rest of the program;join()waits for it.- A
ThreadPoolExecutorreuses a fixed set of threads;map()returns results in input order,submit()returns aFuture. - Waiting work, such as downloads, overlaps on threads: ten waits of 0.2 s took about 0.4 s on five threads.
x = x + 1is a read, an add and a write; two threads can both read the old value, and one update is lost without any error.
References
- threading: Thread objects (Python Software Foundation)
- threading.excepthook() (Python Software Foundation)
- threading: Barrier objects (Python Software Foundation)
- concurrent.futures: ThreadPoolExecutor (Python Software Foundation)
- concurrent.futures: Future objects (Python Software Foundation)
- What's New In Python 3.14: threading (Python Software Foundation)
- Library and Extension FAQ: what kinds of global value mutation are thread-safe? (Python Software Foundation)
- Glossary: race condition (Python Software Foundation)
- sys.setswitchinterval() (Python Software Foundation)
- os.process_cpu_count() (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress