Home/Learn/Python A–Z/threading — Concurrency for I/O-Bound Work

threading — Concurrency for I/O-Bound Work

Intermediate
Concurrency

ThreadPoolExecutor turns "call 50 slow APIs one by one" into "call them together" — and Lock protects the shared state that threads would otherwise corrupt.

Overview

Threads share one process and one memory space, which makes them cheap to start and trivially able to share data — and that sharing is exactly where the danger lives. The modern API is concurrent.futures.ThreadPoolExecutor: submit functions to a pool, collect results, never manage raw Thread objects for routine work. The classic bug is the race condition — two threads doing read-modify-write on the same variable, losing updates — fixed with threading.Lock or by passing data through queue.Queue instead of sharing it. In backend work this pattern appears everywhere: fan-out HTTP calls, parallel S3 downloads, background workers draining a queue.

ThreadPoolExecutor — the Only API You Need Daily

pool.map preserves input order; pool.submit + as_completed yields results as they finish. Ten 1-second network calls complete in about one second total.

Fan-out I/O: 10 seconds of waiting compressed into 1
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch(url):
    time.sleep(1)                    # stands in for requests.get(url)
    return f"{url} -> 200 OK"

urls = [f"https://api.razorpay.com/v1/payments/{i}" for i in range(10)]

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))          # ordered results
print(f"{len(results)} calls in {time.perf_counter() - start:.1f}s")  # ~1.0s

# submit + as_completed: handle each result the moment it arrives
with ThreadPoolExecutor(max_workers=10) as pool:
    futures = {pool.submit(fetch, u): u for u in urls}
    for fut in as_completed(futures):
        print(futures[fut], "→", fut.result())     # fastest first

Race Conditions & Lock — the Bug Interviewers Love

counter += 1 is three bytecode steps (load, add, store); threads interleave between them and updates vanish. A with lock: block makes the sequence atomic. Better still: don't share — pass messages via queue.Queue, which is thread-safe.

Lock makes read-modify-write atomic; Queue avoids sharing entirely
import threading

counter = 0
lock = threading.Lock()

def deposit():
    global counter
    for _ in range(100_000):
        with lock:               # comment this out → lost updates
            counter += 1         # load, +1, store — NOT atomic

threads = [threading.Thread(target=deposit) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)   # 400000 with the lock; a random smaller number without

# Prefer message passing over shared state:
import queue
q = queue.Queue()                # thread-safe by design
q.put({"order_id": 101, "amount": 499})
item = q.get()                   # blocks until an item is available
q.task_done()

Key Points to Remember

  • 1Use ThreadPoolExecutor — map for ordered results, submit + as_completed for fastest-first
  • 2Threads share memory: cheap to start, dangerous to mutate shared state
  • 3x += 1 is not atomic — guard shared writes with threading.Lock (with lock:)
  • 4queue.Queue is thread-safe; producer-consumer beats shared variables

Interview Questions

Sign in to ask Aria
1

Write code to download 100 URLs concurrently with a pool of 10 threads.

MediumZomato
2

Two threads increment a counter 100k times each; the result is less than 200k. Explain and fix.

MediumPaytm
3

What is a deadlock? Show how acquiring two locks in different orders causes one.

HardAmazon

Ask Aria about threading — Concurrency for I/O-Bound Work

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…