Skip to content

Concurrency in Python — The Big Picture

Real programs spend a lot of time waiting for a network response, a file to load, a database query to return. During that waiting time the CPU is idle but the program is blocked, unable to do anything else. Concurrency is the set of techniques that allow a program to make progress on other work while waiting, rather than sitting idle.

Python offers three distinct models for concurrency, each designed for a different kind of problem. Choosing the wrong one not only fails to help, it can actually make things slower.

CONCURRENCY IN PYTHON
┌────────────────┼────────────────┐
│ │ │
Threading Multiprocessing Async/Await
│ │ │
Multiple threads, Multiple processes, Single thread,
shared memory, separate memory, cooperative,
I/O-bound tasks CPU-bound tasks I/O-bound tasks

Threading — One Process, Multiple Threads

Section titled “Threading — One Process, Multiple Threads”

A thread is a lightweight unit of execution that lives inside a process. All threads in the same process share the same memory: they see the same variables, the same objects, the same heap:

Process (one Python interpreter, one GIL)
──────────────────────────────────────────
┌─────────────────────────────────────────┐
│ │
│ Shared Memory │
│ ───────────── │
│ variables, objects, heap │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ Thread 1 Thread 2 Thread 3 │
│ (running) (waiting) (waiting) │
│ │
│ GIL — only one thread runs at a time │
└─────────────────────────────────────────┘
lightweight — spawning a thread is fast
shared memory — threads can read/write the same data
GIL — only one thread executes Python bytecode at a time

:::danger[Threads share memory] Because threads share memory, communication between them is simple: one thread writes a value and another reads it. But this also means they can interfere with each other, requiring locks to prevent race conditions. :::

Multiprocessing — Multiple Processes, Separate Memory

Section titled “Multiprocessing — Multiple Processes, Separate Memory”

A process is a completely independent Python interpreter with its own memory space, its own GIL, and its own heap. Processes do not share anything by default:

Process A Process B
(own interpreter, own GIL) (own interpreter, own GIL)
────────────────────────── ──────────────────────────
┌──────────────────────┐ ┌──────────────────────┐
│ Memory A │ │ Memory B │
│ variables, heap │ │ variables, heap │
│ ▲ │ │ ▲ │
│ │ │ │ │ │
│ Thread (running) │ │ Thread (running) │
└──────────────────────┘ └──────────────────────┘
│ │
└──────────┬───────────────────┘
communicate via:
pipes, queues, shared memory
(explicit, serialised)
heavyweight — spawning a process is slow
separate memory — no sharing by default
no GIL contention — truly parallel on multiple cores

:::danger[Processes have separate memory] Because processes have separate memory, they cannot accidentally interfere with each other. But communication requires explicit serialisation: data must be pickled, sent through a pipe or queue, and unpickled on the other side. This overhead makes multiprocessing unsuitable for tasks that require frequent communication between workers. :::

Threading Multiprocessing
───────── ───────────────
what thread inside separate Python
is created a process interpreter
memory shared separate
GIL one shared GIL one GIL per process
parallelism limited by GIL truly parallel
overhead low high
communication easy — shared memory explicit — pipes/queues
risk race conditions serialisation overhead
best for I/O-bound tasks CPU-bound tasks

A concrete way to think about it:

Threading Multiprocessing
───────── ───────────────
like workers sharing like workers in
one office separate offices
│ │
they can pass notes they must send
directly — fast letters — slow
│ │
but only one can but all can work
use the computer simultaneously
at a time (GIL) on their own computer

:::danger[The rule] This is why the rule is firm: for CPU-bound work, threading is not just unhelpful, it is actively worse than a single thread because of the added overhead of thread switching with no parallelism gain. :::

Threading — GIL limits parallelism
────────────────────────────────────
core 1: thread A running ──► thread A waiting for I/O
GIL released ──► thread B runs
core 2: idle ──► idle (GIL held by thread A or B)
only one thread runs Python at a time
but I/O releases the GIL — so threading helps for I/O-bound work
Multiprocessing — no GIL contention
─────────────────────────────────────
core 1: process A running ──► truly parallel
core 2: process B running ──► truly parallel
each process has its own GIL — no contention

This is why the three-model split exists:

  • threading for I/O with shared memory
  • multiprocessing for CPU work without GIL limits,
  • asyncio for high-concurrency I/O with minimal overhead.

Implications:

  • Threading does NOT speed up CPU-bound tasks (only one thread runs at a time)
  • Threading DOES help I/O-bound tasks (GIL is released during I/O waits)
  • Multiprocessing bypasses the GIL entirely (separate processes)