Processes, Threads, and Coroutines: What They Are and When to Use Each


The words “process,” “thread,” and “coroutine” come up constantly in backend engineering. They’re sometimes used imprecisely - “just spin up a thread for that” - without accounting for what they actually cost and what they actually provide. Understanding the model underneath helps you choose the right tool and understand why things behave the way they do when they don’t.

Processes

A process is a running program. When you start your web server, the OS creates a process for it with:

  • Its own virtual address space (memory that no other process can access without explicit sharing)
  • Its own open file descriptors and network connections
  • Its own CPU register state
  • A process ID

Processes are isolated by default. If one process crashes, others are unaffected. If one process is compromised, it can’t read the memory of another. This isolation is the main reason to use multiple processes instead of multiple threads.

The cost: creating a process is relatively expensive (milliseconds), and communicating between processes requires explicit IPC (Inter-Process Communication) mechanisms: pipes, sockets, shared memory, message queues.

Web servers like Gunicorn and Apache work by forking worker processes. Each worker handles requests independently. If one worker deadlocks or leaks memory, you kill and restart it without affecting other workers. Chrome uses separate processes for each tab for the same reason - a JavaScript crash in one tab doesn’t crash the whole browser.

Threads

A thread is a unit of execution within a process. Threads in the same process share:

  • The same address space (all threads see the same memory)
  • The same open file descriptors
  • The same heap

But each thread has its own:

  • Stack (local variables, function call frames)
  • CPU register state

Threads are cheaper to create than processes (microseconds, not milliseconds) and communicate via shared memory - no IPC overhead. The downside: shared memory means bugs in one thread can corrupt memory seen by other threads. Race conditions, deadlocks, and data corruption are all consequences of unsynchronized access to shared state.

import threading

counter = 0

def increment():
    global counter
    for _ in range(1000000):
        counter += 1  # Not atomic - race condition

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()

print(counter)  # Probably not 2000000

The counter += 1 operation is three instructions at the CPU level: read, increment, write. If two threads interleave these instructions, increments are lost.

Threads are appropriate for CPU-bound work that can be parallelized (on machines with multiple cores) and for I/O-bound work where you want concurrency without the process creation overhead.

The GIL (CPython)

Python’s CPython interpreter has a Global Interpreter Lock - a mutex that only allows one thread to execute Python bytecode at a time. This means Python threads don’t provide parallelism for CPU-bound work. Two Python threads on an 8-core machine use only one core.

The GIL exists to protect CPython’s reference counting implementation from race conditions. It’s been controversial since Python’s early days.

The practical consequences:

  • For CPU-bound Python: use multiprocessing (separate processes, bypasses GIL) or external C extensions that release the GIL
  • For I/O-bound Python: threads still work fine because the GIL is released during I/O operations
  • For modern Python: Python 3.13 introduced experimental free-threaded mode that removes the GIL

Java, Go, and most other languages don’t have this limitation - their threads can run truly parallel on multiple cores.

Coroutines

A coroutine is a function that can suspend its execution and yield control back to a scheduler, then resume from where it left off. This is cooperative multitasking: the coroutine explicitly yields, rather than being preempted by the OS.

import asyncio

async def fetch_data(url):
    # This function can suspend here while waiting for network I/O
    response = await http_client.get(url)  # yields to event loop
    return response.text()

async def main():
    # These run concurrently within a single thread
    results = await asyncio.gather(
        fetch_data("https://api1.example.com"),
        fetch_data("https://api2.example.com"),
        fetch_data("https://api3.example.com"),
    )

While fetch_data is waiting for the network (I/O), it yields control back to the event loop. The event loop runs another coroutine. When the I/O completes, the original coroutine resumes.

All of this happens in a single thread. There’s no parallelism - only one coroutine runs at a time. But for I/O-bound work, this is sufficient: the bottleneck is waiting for I/O, not CPU computation.

Coroutines are much cheaper than threads - you can have tens of thousands of concurrent coroutines where you might only have hundreds of threads (limited by memory, as each thread needs a stack).

Choosing the Right Model

ModelBest forCost
Multiple processesCPU-bound parallelism, isolation, fault toleranceHigh (creation), IPC for communication
Multiple threadsCPU-bound parallelism (non-Python), shared stateMedium, synchronization complexity
Coroutines / asyncI/O-bound concurrency at high scaleLow, requires async-aware code

CPU-bound work that needs parallelism: use processes (or threads in non-GIL languages). Examples: image processing, ML inference, data transformation, cryptography.

I/O-bound work at high concurrency: use coroutines/async. Examples: web servers handling many simultaneous HTTP requests, API gateway services, real-time chat servers.

Mixed CPU and I/O: combine them. A web framework (async) that offloads CPU-intensive tasks to a process pool.

When in doubt for backend services: async/await (coroutines) for the web layer, process-based workers for background jobs. This is the pattern most Node.js, Python (asyncio + Celery), and Go systems follow.

The concurrency model you choose affects the entire structure of your code. Async code needs async libraries. Thread-based code needs synchronization primitives. Making the choice early and consistently is much easier than changing it later.



Read more