Process vs Thread vs Coroutine: Memory, Scheduling and Cost
7 min readBytePatterns
Process vs thread vs coroutine compared on memory, scheduling and cost: what each shares, who switches it, what a crash or a blocking call takes down in Python.
Process vs thread vs coroutine in one line: a process owns its memory and is scheduled by the OS; a thread shares its process's memory and is still scheduled by the OS; a coroutine shares everything, runs on one thread, and is switched by an event loop only where the code says await. Each step down the list is cheaper and gives up a kind of protection.
| Unit | Memory | Switched by |
|---|---|---|
| Process | its own address space | the OS, at any instruction; the most expensive switch |
| Thread | shared heap, its own stack | the OS, at any instruction; cheaper, same address space |
| Coroutine | shared everything, one thread | the event loop, only at await; a function resume |
The problem it solves
"Do it concurrently" has three implementations, and the wrong one costs correctness or speed. CPU-heavy work on CPython threads runs no faster than one thread. Ten thousand sockets on ten thousand threads spend memory on stacks. A coroutine that calls a blocking function freezes every other coroutine.
The threads vs processes article covers the memory wall between the first two in depth, and async/await and the event loop covers how the loop takes turns. This article puts all three side by side.
The intuition
Memory: who can see what.
- A process has its own address space. Data crosses only by copying, through a pipe, a queue or shared memory set up on purpose.
- A thread lives inside a process. All threads share the heap, globals and open files; each has its own stack and registers.
- A coroutine is a paused function object on the heap. Thousands share one thread and every variable.
Scheduling: who decides when to switch.
- Processes and threads are scheduled preemptively by the OS. A switch can land between any two machine instructions, including in the middle of
counter += 1. - Coroutines are scheduled cooperatively by the event loop. A coroutine runs until it reaches an
awaitthat actually suspends, and only then can another one run. Noawait, no switch.
Cost: what creating and switching takes, roughly process ≫ thread ≫ coroutine. A process needs a new address space and, with the spawn start method, a fresh interpreter that re-imports your module. A thread needs an OS thread and a stack, usually megabytes of reserved virtual memory. A coroutine is a small object, and switching is resuming a function, with no kernel involved.
What each one isolates follows from the first two axes:
- A process isolates a crash: one child dying hard leaves its siblings and parent running.
- A thread isolates a blocking call: one thread waiting on a slow
readdoes not stop the others. - A coroutine isolates a data race, between awaits: no other coroutine can interleave inside a stretch of code with no
await, so many updates need no lock. It isolates neither a crash nor a blocking call.
In CPython the GIL lets only one thread run Python bytecode at a time. An optional free-threaded build exists since 3.13, but the default build still has the lock. So in practice threads are the tool for I/O, processes for CPU, and coroutines for many concurrent connections. See the threads vs processes lesson for the short version, and sizing a thread pool for how many threads to run.
Watch it run
The lesson's animation draws two pictures of the same counter. In the first, two threads sit inside one box labelled with a single address space and both point at one shared heap holding counter = 0. Thread 1 writes 5 and thread 2 reads that 5 at once: nothing was copied because nothing had to be. Then the picture changes to two processes, each with its own private counter, joined only by a pipe; anything that crosses it is serialised on one side and rebuilt on the other. A coroutine belongs in the first picture, as one more reader of the same heap.
Threads vs Processes
Step 1 of 9
A process owns its memory. Threads live inside one process and share every byte of it.
The same interactive animation as the lesson — step through it with the controls.
The code
Top-level work sits under if __name__ == "__main__":, because with spawn every child re-imports the module. First, one counter touched three ways: threads that read, yield and write lose updates; child processes increment their own copies; coroutines lose nothing without an await between read and write, and lose updates like threads with one:
import asyncio
import multiprocessing as mp
import threading
import time
counter = 0 # one module-level variable, three ways to touch it
def bump_with_gap(n):
"""Read, give up the CPU, write back: the classic lost update."""
global counter
for _ in range(n):
seen = counter
time.sleep(0) # a thread switch can happen here
counter = seen + 1
def bump_in_child(n, q):
global counter
for _ in range(n):
counter += 1 # increments the child's own copy
q.put(counter)
async def bump_between_awaits(n):
global counter
for _ in range(n):
counter += 1 # no await inside: no other coroutine can run here
await asyncio.sleep(0) # the only place the loop switches
async def bump_across_await(n):
global counter
for _ in range(n):
seen = counter
await asyncio.sleep(0) # a switch point between the read and the write
counter = seen + 1
if __name__ == "__main__":
threads = [threading.Thread(target=bump_with_gap, args=(1_000,)) for _ in range(4)]
for th in threads: th.start()
for th in threads: th.join()
print("threads:", counter < 4_000) # threads: True (updates were lost)
counter = 0
q = mp.Queue()
procs = [mp.Process(target=bump_in_child, args=(1_000, q)) for _ in range(2)]
for p in procs: p.start()
seen_by_children = sorted(q.get() for _ in procs)
for p in procs: p.join()
print("processes:", counter, seen_by_children) # processes: 0 [1000, 1000]
async def run(job):
await asyncio.gather(*(job(1_000) for _ in range(4)))
counter = 0
asyncio.run(run(bump_between_awaits))
print("coroutines, no await inside:", counter) # coroutines, no await inside: 4000
counter = 0
asyncio.run(run(bump_across_await))
print("coroutines, await inside:", counter) # coroutines, await inside: 1000
The last line matters most: with an await between read and write, all four coroutines read the same value each round, so 4,000 increments end at 1,000. Single-threaded does not mean race-free; races happen only at await.
Next, creation cost, on one machine, roughly: create and finish 10,000 coroutines, 1,000 threads and 4 processes. The numbers depend on the OS and start method; the order does not:
def nothing():
pass
async def nothing_async():
pass
def per_unit(make_and_finish, count):
start = time.perf_counter()
make_and_finish(count)
return (time.perf_counter() - start) / count * 1e6 # microseconds each
def coroutines(n):
async def main():
await asyncio.gather(*(nothing_async() for _ in range(n)))
asyncio.run(main())
def threads(n):
for _ in range(n):
th = threading.Thread(target=nothing)
th.start()
th.join()
def processes(n):
for _ in range(n):
p = mp.Process(target=nothing)
p.start()
p.join()
if __name__ == "__main__":
c = per_unit(coroutines, 10_000)
t = per_unit(threads, 1_000)
p = per_unit(processes, 4)
print(f"coroutine {c:.0f} us, thread {t:.0f} us, process {p:,.0f} us")
print(p > t > c)
# coroutine 5 us, thread 112 us, process 191,592 us (one machine, roughly; spawn)
# True
Microseconds, a tenth of a millisecond, a fifth of a second. With spawn a process start includes a new interpreter importing the module; fork on Linux is much cheaper, but still far above a thread.
Finally, a blocking call. A heartbeat coroutine ticks every 50 ms while another sleeps half a second, once with time.sleep and once with asyncio.sleep; count the ticks during the sleep:
async def ticker(stamps, stop):
while not stop.is_set():
stamps.append(time.perf_counter())
await asyncio.sleep(0.05) # a heartbeat every 50 ms, if the loop lets it
async def sleeper(blocking):
if blocking:
time.sleep(0.5) # blocks the one thread the loop runs on
else:
await asyncio.sleep(0.5) # hands the thread back to the loop
async def ticks_during(blocking):
stamps, stop = [], asyncio.Event()
beat = asyncio.create_task(ticker(stamps, stop))
await asyncio.sleep(0.01) # let the ticker start
start = time.perf_counter()
await sleeper(blocking)
end = time.perf_counter()
stop.set()
await beat
return sum(start < s < end for s in stamps)
if __name__ == "__main__":
print("time.sleep:", asyncio.run(ticks_during(True))) # time.sleep: 0
print("asyncio.sleep:", asyncio.run(ticks_during(False)) >= 5) # asyncio.sleep: True
Zero heartbeats: for half a second every coroutine on that loop was frozen. A thread running the same time.sleep would have frozen only itself.
The complexity
The costs are constants that differ by orders of magnitude:
- Create: process ≫ thread ≫ coroutine, as measured above.
- Switch: a process switch may change address spaces; a thread switch stays in one; a coroutine switch never enters the kernel.
- Memory per unit: a process duplicates an interpreter; a thread reserves a stack; a coroutine is one heap object, so tens of thousands fit easily.
Where it goes wrong
- Blocking inside a coroutine.
time.sleep,requests.getor a heavy loop freezes the whole event loop. Use the async version orasyncio.to_thread. - Assuming coroutines never race. Any read,
await, write sequence can lose updates; use anasyncio.Lockthere. - CPU-bound threads in CPython. The GIL serialises the bytecode; use processes.
- Process work outside the main guard. With
spawn, unguarded top-level code runs again in every child. - Huge payloads to processes. Every argument and result is pickled and copied; ship indices or file paths instead.
When it shows up in interviews
As a definitions question, then as a design follow-up: a chat server with many connections (coroutines), an image-resizing batch (processes), a handler that must not crash its neighbours (processes). Naming the race at await and the blocking-call freeze shows understanding, not memory.
How to say it in an interview
"I compare them on memory, scheduling and cost. A process has its own address space and the OS switches it preemptively; it is the most expensive, but it isolates crashes. A thread shares the heap, has its own stack, and is also preempted, so shared data needs locks. A coroutine shares everything on one thread and switches only at await, so it is the cheapest and has no races between awaits, but one blocking call freezes the loop. In Python, because of the GIL: processes for CPU work, threads for blocking I/O, coroutines for thousands of connections."
Frequently asked questions
What is the difference between a thread and a coroutine?
A thread is scheduled by the operating system and can be interrupted at any instruction, so shared data needs locks. A coroutine is scheduled by an event loop in user space and only gives up control at await, so it is much cheaper, but a blocking call inside it freezes every other coroutine on that loop.
Are coroutines faster than threads?
They are cheaper to create and to switch, so they scale to far more concurrent waits, like open sockets. They are not faster at computing: coroutines on one loop run on one thread, so CPU-bound work gains nothing. For CPU work in Python, use processes.
When should I use a process instead of a thread?
When the work is CPU-bound in CPython, because each process has its own interpreter and GIL, or when you need isolation, because one crashing process does not take its siblings down. Pay for it with startup time and with copying every argument and result between processes.