← Back to all articles

Process, Thread and Coroutine: Three Units of Concurrency

ConcurrencyBeginner

How they relate

UnitMemorySwitch costCommunication
ProcessIsolatedHigh (kernel)IPC / pipes
ThreadShared addressMediumShared memory, lock
CoroutineShared threadLow (user space)Channels / vars

Why coroutines are lighter

Threads are scheduled by the OS; thousands of them burn CPU on context switches. Coroutines are scheduled by the runtime in user space, so one thread can host tens of thousands and yield on waits (I/O) without touching kernel resources.

Costs and traps

  1. Lock shared memory: threads and coroutines share variables, so races cause weird bugs;
  2. Do not compute heavy CPU in a coroutine: blocking the scheduler stalls its siblings;
  3. Parallel is not concurrent: multiple cores run in parallel; one core only switches.

One-line choice

CPU-bound: multiprocess or multithread to use cores. I/O-bound: coroutines or async to serve high concurrency with few threads.

Real-world cases: three concurrency choices gone wrong

  1. CPU-bound work on coroutines: image compression on a single-threaded event loop froze the whole service. CPU-bound work belongs on processes or thread pools — never block the scheduler.
  2. A thread pool sized by cores, ignoring waits: for pure computation, far more threads than cores just adds context-switch overhead. Size the pool from wait time versus compute time.
  3. Shared mutable state without locks: counters lost and caches corrupted under threads. Guard concurrent writes with locks or atomics, and prefer immutable data and message passing.

FAQ

Are coroutines always lighter than threads? Cheaper to create and switch, but a blocking call still stalls the scheduler — pair them with async I/O. What do processes cost? No shared memory, pricier IPC and slower startup, in exchange for isolation and true parallelism. What concurrency level is right? For I/O-bound work estimate ~throughput × average latency, then validate with load tests rather than guessing. How do I hunt concurrency bugs? Race detectors, reproducible load scripts and request IDs in logs — not occasional reproductions.

Testing and observing concurrent programs

Concurrency bugs are hard to reproduce, so testing and observability matter more than coding tricks.

  1. Make concurrency explicit: shared state, critical sections and lock ordering should be visible at a glance. If it takes a long read to judge whether something is thread-safe, the design is too implicit — refactor to a clearer boundary.
  2. Test with injectable scheduling: make waiting and scheduling dependencies you can control, then force interleavings deterministically to reproduce check-then-act races instead of hoping random timing hits them.
  3. Load-test bursts: concurrency problems surface under sudden load, so include both ramp-up and instantaneous spikes, watching queue depth, rejection rate and latency percentiles rather than average throughput.
  4. Observe threads and coroutines: export thread stacks, coroutine counts and queue lengths with threshold alerts. Steadily growing coroutine counts usually mean blocking calls crept into an async path or tasks never complete.
  5. Define backpressure: when producers outrun consumers you must choose explicitly — throttle, drop or buffer. Systems without backpressure fail in unpredictable ways under pressure.

Concurrency and resources

More concurrency is not better: each unit consumes memory and handles and competes for CPU. Evaluate concurrency alongside resource limits and downstream capacity to find a value that is neither overloading nor idle.

Debugging async code

Async call stacks are usually incomplete, making the origin hard to find. Attach a traceable context identifier to each task and log it consistently, and preserve the original stack when handling exceptions rather than losing it through layers of wrapping. These habits cut debugging time noticeably.