Filter concepts by levelShowing all levels.

Python · Concurrency and Parallelism

Concepts

Concepts
4

The vocabulary this whole section builds on — the difference between concurrency and parallelism, the CPU-bound/I/O-bound distinction that decides which tool actually helps, and a map of the three approaches (threading, multiprocessing, async) before going deep on each.

Python overview

The core distinctions

Two different questions — whether work merely overlaps or truly runs simultaneously, and what the work is actually doing while it "works."

Concurrency vs parallelism

coreintermediate

Concurrency is structuring a program to deal with multiple tasks by interleaving them — not necessarily at the same instant. Parallelism is actually running multiple tasks at the same instant, on separate CPU cores.

Think of it as

One cook working a stove, an oven, and a pan at once — checking each in turn, never truly doing two things in the same instant — is concurrency. Three cooks each working their own station at the same instant is parallelism. A single-core machine can be concurrent but never parallel; a multi-core machine can be both.

python
# concurrency: tasks make progress by interleaving (threads, asyncio)
# parallelism: tasks run at the same instant (multiprocessing, multiple cores)

What we're doing: Show that two threads interleave (concurrency) rather than run at the literal same instant, by observing their print order overlap.

interleaving.pypython
import threading
import time

def worker(name, delay):
    for i in range(3):
        print(f"{name}: step {i}")
        time.sleep(delay)

t1 = threading.Thread(target=worker, args=("thread-A", 0.05))
t2 = threading.Thread(target=worker, args=("thread-B", 0.05))
t1.start()
t2.start()
t1.join()
t2.join()
print("both threads done")
4
Each thread runs the same worker function independently, sleeping between steps.
5
time.sleep releases control, letting the OTHER thread run — this is what makes the output interleave.
Output
thread-A: step 0
thread-B: step 0
thread-A: step 1
thread-B: step 1
thread-A: step 2
thread-B: step 2
both threads done

Why this works: The two threads take turns running — thread-A prints, sleeps (releasing the GIL), thread-B gets a turn, and so on. Neither line is printed at the literal same instant; they are interleaved, which is exactly what concurrency means. Getting true simultaneous execution of CPU work needs separate processes, not threads.

Assuming Python threads give parallelism for CPU-bound work

Wrong

python
# expecting this to run 2x faster than one thread on a CPU-bound task
import threading

def cpu_heavy():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

t1 = threading.Thread(target=cpu_heavy)
t2 = threading.Thread(target=cpu_heavy)
t1.start(); t2.start()
t1.join(); t2.join()
# measured: roughly the SAME wall-clock time as running both sequentially

Better

python
# use processes for CPU-bound parallelism instead
import multiprocessing

def cpu_heavy():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

p1 = multiprocessing.Process(target=cpu_heavy)
p2 = multiprocessing.Process(target=cpu_heavy)
p1.start(); p2.start()
p1.join(); p2.join()
# measured: real speedup — separate processes, separate GILs, separate cores

What you see: No error — the code runs, but two CPU-bound threads finish in roughly the same time as one, because only one thread runs Python bytecode at a time under the GIL. See the GIL subsection for the measured numbers.

Why: Python threads give concurrency (interleaving), not parallelism, for CPU-bound Python code — the Global Interpreter Lock (GIL) lets only one thread execute Python bytecode at a time. Threads still help for I/O-bound work, because the GIL is released while waiting on I/O.

Interleaved vs simultaneous

Concurrency

  • +Multiple tasks in progress, interleaved
  • +Works even on a single CPU core
  • +Python threads/asyncio: this, not parallelism

Parallelism

  • Multiple tasks running at the same instant
  • Requires multiple CPU cores
  • Python: needs multiprocessing, not threading
  • Concurrency
    • Multiple tasks in progress, interleaved
    • Works even on a single CPU core
    • Python threads/asyncio: this, not parallelism
  • Parallelism
    • Multiple tasks running at the same instant
    • Requires multiple CPU cores
    • Python: needs multiprocessing, not threading

The four combinations, and what gets you there in Python

The four combinations, and what gets you there in Python
PropertyNot concurrentConcurrent
Not parallelone task, run to completionthreads/asyncio interleaving on one core
Parallel(not a real combination)multiprocessing across several cores

Together

python
import threading, multiprocessing

# concurrent, not parallel: both threads share one GIL, interleaving
threading.Thread(target=lambda: None).start()

# concurrent AND parallel: separate processes, separate GILs, separate cores
multiprocessing.Process(target=lambda: None).start()

Remember: Concurrency is interleaving multiple tasks; parallelism is running them at the same instant. Python threads: concurrency. Python processes: parallelism.

See also: cpu bound vs io bound workloads · concurrency approaches overview · what the gil is

CPU-bound vs I/O-bound workloads

coreintermediate

A CPU-bound task spends its time computing — the processor is the bottleneck. An I/O-bound task spends its time waiting on something else, like a network call or disk read — the processor sits idle during the wait.

Think of it as

A CPU-bound task is a mathematician working every second, non-stop, at a desk — more desks (cores) is the only way to go faster. An I/O-bound task is someone waiting for a kettle to boil — they are not computing, so having a second person wait alongside them barely helps, but letting one person handle several kettles while each boils DOES help.

python
# CPU-bound: the loop itself is the cost — use multiprocessing
def cpu_bound(n):
    return sum(i * i for i in range(n))

# I/O-bound: the wait is the cost — use threading or asyncio
def io_bound(url):
    return requests.get(url)   # blocks on the network, not the CPU

What we're doing: Measure a CPU-bound task across 1 and 2 threads (little to no speedup) versus a same-shaped I/O-bound task across 1 and 2 threads (real speedup), to show the difference is not theoretical.

bound_comparison.pypython
import time
import threading

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def io_bound():
    time.sleep(0.2)

N = 15_000_000

# --- CPU-bound: 2 threads vs sequential ---
start = time.perf_counter()
cpu_bound(N); cpu_bound(N)
seq_cpu = time.perf_counter() - start

start = time.perf_counter()
t1 = threading.Thread(target=cpu_bound, args=(N,))
t2 = threading.Thread(target=cpu_bound, args=(N,))
t1.start(); t2.start(); t1.join(); t2.join()
threaded_cpu = time.perf_counter() - start

print(f"CPU-bound speedup from threading: {seq_cpu / threaded_cpu:.2f}x")

# --- I/O-bound: 2 threads vs sequential ---
start = time.perf_counter()
io_bound(); io_bound()
seq_io = time.perf_counter() - start

start = time.perf_counter()
t1 = threading.Thread(target=io_bound)
t2 = threading.Thread(target=io_bound)
t1.start(); t2.start(); t1.join(); t2.join()
threaded_io = time.perf_counter() - start

print(f"I/O-bound speedup from threading: {seq_io / threaded_io:.2f}x")
4
cpu_bound holds the CPU (and the GIL) the entire time it runs — nothing to wait on.
10
io_bound spends its time waiting — time.sleep releases the GIL for the duration of the wait.
17
Two threads running cpu_bound still take one thread at a time through the GIL — little speedup expected.
33
Two threads running io_bound overlap their waits — a real, close-to-linear speedup is expected here.
Output
CPU-bound speedup from threading: 0.90x
I/O-bound speedup from threading: 1.99x

Why this works: Threading gives close to no speedup on the CPU-bound task (0.90x — actually slightly worse, from thread-switching overhead) because only one thread can run Python bytecode at a time under the GIL. It gives a near-2x speedup on the I/O-bound task because time.sleep releases the GIL, letting the other thread run during the wait — this is the practical test for whether threading will help a given piece of code.

Reaching for threading to speed up a CPU-bound function

Wrong

python
# resizing 1000 images with threads, expecting a 4x speedup on 4 threads
from concurrent.futures import ThreadPoolExecutor

def resize(path):
    ...  # pure CPU work: decode, resize, encode

with ThreadPoolExecutor(max_workers=4) as ex:
    ex.map(resize, image_paths)
# measured: roughly the same wall-clock time as 1 worker

Better

python
# same task, ProcessPoolExecutor instead — separate GILs, separate cores
from concurrent.futures import ProcessPoolExecutor

def resize(path):
    ...  # pure CPU work: decode, resize, encode

with ProcessPoolExecutor(max_workers=4) as ex:
    ex.map(resize, image_paths)
# measured: real, close-to-linear speedup up to the core count

What you see: No error — the code runs and looks correct, but wall-clock time barely improves as workers are added, because the task is CPU-bound and threads share one GIL.

Why: ThreadPoolExecutor is the right tool for I/O-bound work, not CPU-bound work — the GIL means only one thread executes Python bytecode at a time regardless of worker count. ProcessPoolExecutor sidesteps this because each process gets its own interpreter and its own GIL, so the work genuinely runs on separate cores.

Where the bottleneck actually is

CPU-bound

  • +Processor is busy the whole time
  • +Speeds up only with more cores (multiprocessing)
  • +Example: computing a checksum over a big file

I/O-bound

  • Processor sits idle, waiting on something external
  • Speeds up by overlapping waits (threading, asyncio)
  • Example: fetching ten URLs over the network
  • CPU-bound
    • Processor is busy the whole time
    • Speeds up only with more cores (multiprocessing)
    • Example: computing a checksum over a big file
  • I/O-bound
    • Processor sits idle, waiting on something external
    • Speeds up by overlapping waits (threading, asyncio)
    • Example: fetching ten URLs over the network

Which workload, and what actually helps in Python

Which workload, and what actually helps in Python
WorkloadExampleWhat helps
CPU-boundimage resizing, numeric simulation, parsing a huge filemultiprocessing (separate cores)
I/O-boundHTTP request, database query, reading a file from diskthreading or asyncio (overlap the waits)
Mixeddownload a file, then compress itthreads/asyncio for the download, processes for the compression

Together

python
import time, threading

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def io_bound():
    time.sleep(0.2)   # stands in for a blocking network/disk call

# threads barely help cpu_bound (GIL held throughout the loop)
# threads DO help io_bound (GIL released during sleep/network/disk wait)

Remember: CPU-bound: the processor is the bottleneck, use multiprocessing. I/O-bound: waiting is the bottleneck, use threading or asyncio.

See also: concurrency vs parallelism · gil effects and when to use what · thread and process pool executors

Advertisement

The toolbox

Three different approaches to concurrent work in Python, and where asynchronous programming fits before its own dedicated roadmap section.

Multithreading, multiprocessing, and pools — an overview

standardintermediate

Python offers four building blocks for running more than one thing at once: a single Thread, a single Process, a ThreadPool that reuses a fixed set of threads, and a ProcessPool that reuses a fixed set of processes.

Think of it as

A Thread or Process is hiring one worker for one job. A pool is keeping a fixed crew on staff and handing each new job to whichever worker is free — no hiring cost per job, and a cap on how many jobs run at once.

python
# single unit
threading.Thread(target=fn).start()
multiprocessing.Process(target=fn).start()

# reused pool, many units of work
with ThreadPoolExecutor(max_workers=N) as ex:
    ex.map(fn, items)
with ProcessPoolExecutor(max_workers=N) as ex:
    ex.map(fn, items)

What we're doing: Run the same ten short tasks through a raw Thread loop versus a ThreadPoolExecutor, to see the pool remove the manual start/join bookkeeping.

pool_vs_manual.pypython
import time
from concurrent.futures import ThreadPoolExecutor
import threading

def fetch(i):
    time.sleep(0.02)
    return i * i

# manual: one Thread object per task, tracked by hand
threads, results = [], [None] * 5
def run_and_store(i):
    results[i] = fetch(i)

for i in range(5):
    t = threading.Thread(target=run_and_store, args=(i,))
    threads.append(t)
    t.start()
for t in threads:
    t.join()
print("manual:", results)

# pool: a fixed crew of workers handles all five, no manual bookkeeping
with ThreadPoolExecutor(max_workers=3) as ex:
    pooled = list(ex.map(fetch, range(5)))
print("pooled:", pooled)
9
Each task gets its own Thread object — the caller tracks and joins every one by hand.
22
ex.map hands all five tasks to a pool of 3 reused threads, and returns results in the original order.
Output
manual: [0, 1, 4, 9, 16]
pooled: [0, 1, 4, 9, 16]

Why this works: Both approaches produce identical results, because a pool is not a different execution model — it is the same threads, managed for you. A pool caps how many run at once (here, 3 of the 5 tasks run at a time) and removes the start/append/join bookkeeping the manual version needs.

The four building blocks, side by side

The four building blocks, side by side
ToolUnit of workBest for
threading.Threadone threada single I/O-bound background task
multiprocessing.Processone processa single CPU-bound task, run on another core
ThreadPoolExecutorreused thread poolmany I/O-bound tasks (many URLs, many files)
ProcessPoolExecutorreused process poolmany CPU-bound tasks (many images, many rows)

Together

python
import threading, multiprocessing
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

threading.Thread(target=fetch_one_url).start()          # one-off, I/O-bound
multiprocessing.Process(target=crunch_one_dataset).start()  # one-off, CPU-bound

with ThreadPoolExecutor(max_workers=8) as ex:
    ex.map(fetch_url, urls)              # many, I/O-bound

with ProcessPoolExecutor(max_workers=4) as ex:
    ex.map(crunch_dataset, datasets)     # many, CPU-bound

Remember: Thread/Process = one worker for one job. ThreadPoolExecutor/ProcessPoolExecutor = a reused crew for many jobs, one Executor interface for both.

See also: threading and thread · multiprocessing basics · thread and process pool executors

Asynchronous programming

standardintermediate

Asynchronous programming runs many tasks on a single thread by explicitly pausing at each await point and letting another task run. Unlike threading, the switches happen only where the code says await — never mid-line.

Think of it as

A thread can be interrupted by the OS at almost any point, mid-instruction. A coroutine only ever hands control back at an explicit await — like a relay runner who chooses exactly when to pass the baton, instead of having it grabbed at a random moment.

python
import asyncio

async def fetch(url):
    await asyncio.sleep(0.1)   # yields control at this exact point
    return f"data from {url}"

async def main():
    results = await asyncio.gather(fetch("a"), fetch("b"))
    print(results)

asyncio.run(main())

What we're doing: Run two coroutines concurrently with asyncio.gather and show they overlap their waits, similar in spirit to two threads but on a single thread.

async_overview.pypython
import asyncio
import time

async def fetch(name):
    await asyncio.sleep(0.1)   # a real await point -- control passes here
    return f"{name} done"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(fetch("task-1"), fetch("task-2"))
    elapsed = time.perf_counter() - start
    print(results)
    print(f"elapsed under 0.2s: {elapsed < 0.2}")

asyncio.run(main())
5
await asyncio.sleep(0.1) is the explicit switch point — the event loop runs the other coroutine during this wait.
10
asyncio.gather runs both coroutines concurrently on one thread, overlapping their sleeps.
Output
['task-1 done', 'task-2 done']
elapsed under 0.2s: True

Why this works: Both coroutines sleep for 0.1s, but because asyncio.gather runs them concurrently on the same thread, the two waits overlap — total elapsed time is close to 0.1s, not 0.2s. This is the same overlapping-waits benefit threading gives for I/O-bound work, achieved without extra OS threads.

Remember: async/await runs many tasks on one thread, switching only at explicit await points — good for many concurrent waits, useless for CPU-bound work.

See also: concurrency vs parallelism · cpu bound vs io bound workloads · event loop and coroutines · awaitables tasks and futures

Advertisement