Threading and Futures

Updated

September 8, 2026

Threading and Futures

A thread is another stack in the same process. Thread(...).start() runs a function. .join() waits until it finishes. A Lock makes a critical section exclusive. ThreadPoolExecutor is a bounded pile of workers plus a Future for each job. The boring default is the executor with a with block (it shutdowns and waits) and no shared mutable state if you can return a value instead.

Mental model

Use threads when fire spends time in I/O. Each worker still needs a join, or the executor’s shutdown, or you leak work past main.

Lock around the smallest piece of shared data. If two threads append to a list or bump a counter, you need a lock or you need to stop sharing.

concurrent.futures.ThreadPoolExecutor maps a function over a collection and keeps the results in order when you use .map. Prefer it over a handwritten list of Thread objects.

Worked examples

Case 1: Thread and join

Save as print_threads.py. Three printers, each waits 0.05 seconds. Start all, then join all. The wall clock should beat the sequential 0.15 seconds from the previous chapter.

# print_threads.py
import time
from threading import Thread


def print_ticket(ticket, bucket):
    time.sleep(0.05)
    bucket.append(f"printed {ticket}")


def main():
    tickets = ["T-11", "T-12", "T-13"]
    bucket = []
    workers = [
        Thread(target=print_ticket, args=(ticket, bucket))
        for ticket in tickets
    ]
    started = time.perf_counter()
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()
    elapsed = time.perf_counter() - started
    for line in bucket:
        print(line)
    print(f"elapsed < 0.15: {elapsed < 0.15}")


if __name__ == "__main__":
    main()

Run:

uv run python print_threads.py

Output (order of printed lines may vary):

printed T-11
printed T-12
printed T-13
elapsed < 0.15: True

If you omit the join loop, main can print the elapsed time before the workers finish. Always join.

Case 2: Lock around a counter

Save as cover_count.py. Four workers each seat 1000 covers. The lock makes += exclusive.

# cover_count.py
from threading import Lock, Thread


def seat(covers, lock, n):
    for _ in range(n):
        with lock:
            covers[0] += 1


def main():
    covers = [0]
    lock = Lock()
    workers = [
        Thread(target=seat, args=(covers, lock, 1000)) for _ in range(4)
    ]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()
    print(covers[0])


if __name__ == "__main__":
    main()

Run:

uv run python cover_count.py

Output:

4000

covers is a one-element list so the workers share the object. A lock around the increment is the whole design. Better: each worker returns n and the parent sums. That version needs no lock.

Case 3: ThreadPoolExecutor

Save as print_pool.py. The context manager calls shutdown(wait=True) on the way out. .map yields results in the input order, not the finish order.

# print_pool.py
import time
from concurrent.futures import ThreadPoolExecutor


def print_ticket(ticket):
    time.sleep(0.05)
    return f"printed {ticket}"


def main():
    tickets = ["T-11", "T-12", "T-13"]
    started = time.perf_counter()
    with ThreadPoolExecutor(max_workers=3) as pool:
        lines = list(pool.map(print_ticket, tickets))
    elapsed = time.perf_counter() - started
    for line in lines:
        print(line)
    print(f"elapsed < 0.15: {elapsed < 0.15}")


if __name__ == "__main__":
    main()

Run:

uv run python print_pool.py

Output:

printed T-11
printed T-12
printed T-13
elapsed < 0.15: True

max_workers=3 bounds the pile. A pool of 3 for 3 tickets is fine. A pool of 500 for 3 tickets is not.

pool.submit returns a Future. future.result() waits and re-raises. .map is the boring form when you have one function and a list.

The trap

Save as cover_race.py. Same idea as Case 2, no lock. Each worker reads the counter, yields (time.sleep(0)), then writes. Four threads, 2000 seats each. The expected total is 8000. You will not get it.

# cover_race.py
import time
from threading import Thread


def seat(covers, n):
    for _ in range(n):
        current = covers[0]
        time.sleep(0)
        covers[0] = current + 1


def main():
    covers = [0]
    workers = [
        Thread(target=seat, args=(covers, 2000)) for _ in range(4)
    ]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()
    print(covers[0])


if __name__ == "__main__":
    main()

Run:

uv run python cover_race.py

Output (yours will differ; not 8000):

2001

The number is a symptom. The bug is shared mutable state without a lock. time.sleep(0) only makes the lost update easy to see. Put the lock back, or do not share: return n from each worker.

The boring rule

  • start then join. The executor with block is join-plus-shutdown.
  • Bound max_workers. Do not Thread() in an unbounded loop.
  • Lock only the shared counter or list. Prefer returning a value.
  • .map when order of results should match the inputs.
  • Threads for I/O waits. Do not start a thread per ticket because it looks busy.

Try this

  1. In print_threads.py, comment out the join loop. Run it. Notice missing lines or a tiny elapsed time.
  2. Change Case 2 so seat returns n and the parent sums. Delete the lock.
  3. Use pool.submit and future.result() instead of .map for one ticket. Print that one line.
  4. Set max_workers=1 in print_pool.py. elapsed < 0.15 should become False.