Skip to content

Python Concurrency — threading, multiprocessing, asyncio

Concurrency means making progress on more than one task at a time. Python offers three approaches, each suited to a different kind of work:

ApproachBest forWhy
threadingI/O-bound work (network, disk)Threads wait together; cheap to create.
multiprocessingCPU-bound work (number crunching)Separate processes use multiple cores.
asyncioMany I/O tasks at onceOne thread, cooperative await, very scalable.

CPython has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at a time. So threads don’t speed up CPU-bound code — but they’re great for I/O, where threads spend most time waiting (and release the GIL while waiting). For CPU-bound parallelism, use multiprocessing, which sidesteps the GIL with separate processes.

Rule of thumb: I/O-bound → threading or asyncio. CPU-bound → multiprocessing.

Run functions in separate threads. Start them, then join to wait for completion.

threading_basic.py
import threading
import time
 
def worker(name):
    print(f"{name} starting")
    time.sleep(1)               # simulates an I/O wait
    print(f"{name} done")
 
threads = [threading.Thread(target=worker, args=(f"T{i}",)) for i in range(3)]
for t in threads:
    t.start()                   # begin running concurrently
for t in threads:
    t.join()                    # wait for each to finish
print("all done")

Because the threads sleep concurrently, three 1-second waits finish in about 1 second, not 3.

When threads modify shared state, guard it with a Lock to avoid race conditions.

lock.py
import threading
 
counter = 0
lock = threading.Lock()
 
def increment():
    global counter
    for _ in range(100_000):
        with lock:              # only one thread at a time
            counter += 1
 
ts = [threading.Thread(target=increment) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(counter)                  # 200000 (correct, thanks to the lock)

concurrent.futures — a simpler interface

Section titled “concurrent.futures — a simpler interface”

ThreadPoolExecutor / ProcessPoolExecutor manage a pool of workers and collect results for you.

executor.py
from concurrent.futures import ThreadPoolExecutor
 
def square(n):
    return n * n
 
with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(square, [1, 2, 3, 4, 5]))
print(results)                  # [1, 4, 9, 16, 25]

Swap ThreadPoolExecutor for ProcessPoolExecutor to run CPU-bound work across cores — the API is identical.

Each process has its own Python interpreter and memory, so they run truly in parallel on multiple cores.

multiprocessing_basic.py
from multiprocessing import Pool
 
def heavy(n):
    return sum(i * i for i in range(n))
 
if __name__ == "__main__":          # required guard on Windows/macOS
    with Pool(processes=4) as pool:
        results = pool.map(heavy, [10_000, 20_000, 30_000])
    print(results)

Always wrap multiprocessing entry code in if __name__ == "__main__": — without it, spawned processes re-import and re-run your script.

asyncio runs many I/O tasks on a single thread using cooperative multitasking. An async def function is a coroutine; await yields control while waiting, letting other coroutines run.

asyncio_basic.py
import asyncio
 
async def fetch(name, delay):
    print(f"{name} start")
    await asyncio.sleep(delay)      # non-blocking wait
    print(f"{name} done")
    return name
 
async def main():
    # Run three coroutines concurrently
    results = await asyncio.gather(
        fetch("A", 1),
        fetch("B", 1),
        fetch("C", 1),
    )
    print(results)
 
asyncio.run(main())                 # entry point

All three “fetches” overlap, so the total time is about 1 second, not 3.

PiecePurpose
async defDefines a coroutine.
await xWait for an awaitable without blocking the thread.
asyncio.run(coro)Run the top-level coroutine.
asyncio.gather(*coros)Run many coroutines concurrently, collect results.
asyncio.create_task(coro)Schedule a coroutine to run in the background.
asyncio.sleep(s)Non-blocking sleep.
decision.txt
Is the work CPU-bound (math, parsing, compression)?
   -> multiprocessing (use all cores, beat the GIL)
 
Is the work I/O-bound (HTTP, files, DB)?
   A few tasks, simple code?      -> threading / ThreadPoolExecutor
   Hundreds/thousands of tasks?   -> asyncio
  • Threads won’t speed up CPU work — the GIL serializes them; use multiprocessing.
  • Forgetting join — the main program may exit before threads finish.
  • Unguarded shared state — use a Lock around shared mutable data.
  • Blocking calls inside asynciotime.sleep or heavy CPU work freezes the whole event loop; use await asyncio.sleep and run CPU work in an executor.
  • Missing if __name__ == "__main__": — breaks multiprocessing on Windows/macOS.

Exercise 3 – Map work over a thread pool

Section titled “Exercise 3 – Map work over a thread pool”

The choice is not a matter of taste. It follows from one question — is your program waiting, or is it computing?

diagram Diagram mermaid

The dividing line is the GIL: one lock that lets only one thread execute Python bytecode at a time. A thread that is waiting releases it, so waiting overlaps freely. A thread that is computing holds it, so computing does not overlap at all.

Wall-clock speedup on a shared machine is noisy. A ratio measured inside one run is not. Time the CPU each worker actually consumed and divide by the elapsed time:

overlap.py
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
 
def cpu_thread(n):                       # per-THREAD cpu time
    t = time.thread_time()
    s = 0
    for i in range(n):
        s += i * i
    return time.thread_time() - t
 
def cpu_proc(n):                         # per-PROCESS cpu time
    t = time.process_time()
    s = 0
    for i in range(n):
        s += i * i
    return time.process_time() - t
 
if __name__ == "__main__":               # required on Windows and macOS
    N = 10_000_000
    t = time.perf_counter()
    with ThreadPoolExecutor(4) as ex:
        d = list(ex.map(cpu_thread, [N] * 4))
    print(sum(d) / (time.perf_counter() - t))    # 1.00
 
    t = time.perf_counter()
    with ProcessPoolExecutor(4) as ex:
        d = list(ex.map(cpu_proc, [N] * 4))
    print(sum(d) / (time.perf_counter() - t))    # 3.05

Measured on CPython 3.14.4, 8 logical CPUs, four workers each squaring 10 million integers:

poolwallCPU consumedCPU ÷ wallreading
4 threads2.72 s2.72 s1.00×perfectly serialized
4 processes1.53 s4.67 s3.05×genuinely parallel

The threads consumed exactly as much CPU as the clock advanced: at any instant, one of them was running. The processes consumed 4.67 s of CPU in 1.53 s of wall time, which is only possible on more than one core.

One GIL, four threads. Only the thread holding the baton runs; the others queue. Switch to processes and each gets its own interpreter — and its own baton. Toggle the workload to see why waiting changes everything.

sketch The GIL as a baton p5.js
CPU-bound threads must pass one baton so only one runs at a time. Processes each hold their own. Waiting threads release the baton, so I/O overlaps.

Ten tasks that each sleep 0.1 s, measured on the same machine:

approachwallspeedup
sequential1.005 s1.00×
ThreadPoolExecutor(10)0.110 s9.17×
asyncio.gather of 100.116 s8.64×

time.sleep releases the GIL, and so does every well-behaved socket and file call. Ten threads therefore wait simultaneously and the total is set by the longest wait rather than their sum.

Threads and coroutines are equally fast here. What separates them is cost per task:

cost.py
# 500 threads that do nothing   -> 104.2 ms   (208 us each)
# 500 coroutines that do nothing ->  3.0 ms   (6 us each)

Roughly 35× cheaper. A thread needs an OS stack; a coroutine is an object. At ten concurrent waits the difference is irrelevant — at ten thousand, it decides whether the program runs at all.

pch.quizTag pch.quizDefaultTitle
  1. Four CPU-bound tasks run in a ThreadPoolExecutor(4). Measured CPU time divided by wall time is 1.00. What does that mean?

    pch.quizShowAnswer

    B — only one thread executed Python bytecode at a time, so nothing overlapped — A ratio of 1 means the workers together burned exactly one core-second per second of clock. That is the GIL serializing them. The same measurement over four processes gives 3.05.

  2. Why measure workers with time.thread_time rather than time.perf_counter?

    pch.quizShowAnswer

    B — perf_counter measures elapsed time, so a thread blocked on the GIL still accrues duration — All four threads are alive for the whole window, so summing their elapsed times reports near-perfect overlap and appears to disprove the GIL. thread_time counts CPU actually consumed.

  3. For ten tasks that each sleep 0.1 s, threads gave 9.17x and asyncio 8.64x. What separates them?

    pch.quizShowAnswer

    A — nothing measurable here; coroutines cost about 35x less to create, which matters at large scale — Both overlap the waiting, so both approach 10x. Measured creation cost was 208 us per thread against 6 us per coroutine, which decides things at thousands of concurrent tasks, not ten.

  4. When is a process pool likely to be SLOWER than a plain sequential loop?

    pch.quizShowAnswer

    C — when each task is short, because pool startup and pickling dominate — Starting a 4-worker pool cost about 1 second here, plus pickling every argument and result. Tasks measured in milliseconds cannot repay that.

  • The GIL means threads don’t parallelize CPU work — they shine for I/O.
  • threading + Lock for simple concurrent I/O; concurrent.futures for pools and results.
  • multiprocessing for CPU-bound parallelism across cores (guard with __main__).
  • asyncio (async/await, gather, run) scales to many concurrent I/O tasks on one thread.
  • Match the tool to the workload: CPU → processes, I/O → threads/async.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading