Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 24 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 9 of 24 minutes free
Free: Starting a thread and waiting for it · A pool of threads with ThreadPoolExecutor · When threads share data: a lost update · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 15 minutes.

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:

Three downloads on three threads Python · start_join.py
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:

Flow: the main thread queues ten calls, five worker threads take them one by one, and map() returns the results in input order.Main thread callspool.map(download, PAGES)Work queue:10 calls, in order5 worker threadsResults, handed backin the order of PAGESThe with block ends:the pool shuts down01234queues one call per pagea free worker takes the next calleach finished call stores its resultlist() has waited for all ten

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.

Ten downloads of 0.2 s: one by one, then on five threads Python · pool_downloads.py
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.

Two deposits, one of them lost Python · lost_update.py
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.

  1. Question 1 of 4 What does this print?

    Read the code, then choose one answer.

    import threading, time
    
    def work():
        time.sleep(0.5)
    
    t = threading.Thread(target=work)
    t.start()
    print("alive:", t.is_alive())
    t.join()
    print("alive:", t.is_alive())
    Show the answer to question 1

    Answer: alive: True, then alive: False

    start() returns at once and leaves the new thread running, so right after it the thread is still sleeping and is_alive() is True. join() waits until work() has returned, so afterwards the thread is no longer alive.

  2. Question 2 of 4 threading.Thread(target=download, args=("/home")) fails when the thread starts. Why, and what is the fix?

    Choose one answer.

    Show the answer to question 2

    Answer: ("/home") is just a string in brackets, so the thread calls download("/", "h", "o", "m", "e"); write args=("/home",)

    Brackets alone do not make a tuple: the comma does. The thread unpacks args as the arguments of the call, and a string unpacks into its characters, so download() receives five arguments. A one-item tuple needs a trailing comma. A list such as ["/home"] also works, because args accepts any sequence.

  3. Question 3 of 4 pool.map(download, ["/a", "/b", "/c"]) runs on three threads, and "/c" finishes first. In what order does map() hand back the results?

    Choose one answer.

    Show the answer to question 3

    Answer: /a, /b, /c: the order of the input, whatever order they finish in

    Executor.map() yields results in the order of its input. If "/a" is the slowest, the loop waits for it before it hands back anything, even though "/c" is ready. Use submit() with as_completed() to handle results as they finish.

  4. Question 4 of 4 In lost_update.py two threads each add 500 to a balance of 0 and the balance ends at 500. What went wrong?

    Choose one answer.

    Show the answer to question 4

    Answer: Both threads read 0 before either wrote, so the second write replaced the first instead of adding to it

    balance = balance + amount is three steps: read, add, write. Both threads read 0, both computed 500, and both wrote 500, so one deposit vanished. The barrier only makes that timing certain; without it the same thing happens now and then, whenever a thread is switched between its read and its write.

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.

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.

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

  1. 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. Calling start() a second time on the same Thread raises RuntimeError. join() blocks the calling thread until the thread has finished, or until the optional timeout runs out, after which is_alive() says whether it is still running. A daemon thread, created with daemon=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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Thread(target=f, args=(x,)).start() runs f(x) alongside the rest of the program; join() waits for it.
  • A ThreadPoolExecutor reuses a fixed set of threads; map() returns results in input order, submit() returns a Future.
  • Waiting work, such as downloads, overlaps on threads: ten waits of 0.2 s took about 0.4 s on five threads.
  • x = x + 1 is a read, an add and a write; two threads can both read the old value, and one update is lost without any error.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.