Threading and Futures
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.pyOutput (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.pyOutput:
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.pyOutput:
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.pyOutput (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
startthenjoin. The executorwithblock is join-plus-shutdown.- Bound
max_workers. Do notThread()in an unbounded loop. - Lock only the shared counter or list. Prefer returning a value.
.mapwhen 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
- In
print_threads.py, comment out thejoinloop. Run it. Notice missing lines or a tiny elapsed time. - Change Case 2 so
seatreturnsnand the parent sums. Delete the lock. - Use
pool.submitandfuture.result()instead of.mapfor one ticket. Print that one line. - Set
max_workers=1inprint_pool.py.elapsed < 0.15should becomeFalse.