Skip to content
BytePatterns

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.

UnitMemorySwitched by
Processits own address spacethe OS, at any instruction; the most expensive switch
Threadshared heap, its own stackthe OS, at any instruction; cheaper, same address space
Coroutineshared everything, one threadthe 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 await that actually suspends, and only then can another one run. No await, 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 read does 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.get or a heavy loop freezes the whole event loop. Use the async version or asyncio.to_thread.
  • Assuming coroutines never race. Any read, await, write sequence can lose updates; use an asyncio.Lock there.
  • 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.