Concurrency Roadmap/Concurrency Foundations

Processes, Threads & Coroutines

The three ways an OS (or runtime) lets you run more than one thing at once — and why picking the wrong one silently costs you either isolation, memory, or scalability.

!!2/5Theory: 30m
Language-specific mechanics: Concurrency Language Manual — Concurrency Models & Runtime Fundamentals

The core mental model

Every concurrency mechanism you'll ever use is really just a different answer to one question: what unit of execution am I creating, and what does it share with the thing that created it? There are three answers you need to know cold, and the entire rest of this roadmap is built on getting this taxonomy exactly right before you touch synchronization.

UnitOwns its own...Shares...Created/switched by
ProcessAddress space, file descriptor table, heap, security contextNothing by default (needs explicit IPC)The OS kernel
ThreadProgram counter, register set, stackAddress space, open files, heap — everything else in the processThe OS kernel (for kernel threads)
CoroutineA saved "resume point" (its own stack or continuation)Everything a thread shares, plus the OS thread itselfA language runtime / scheduler, in user space

The pattern across the row is decreasing isolation, decreasing creation cost, increasing count you can afford. A process might cost megabytes and milliseconds to spin up; a thread costs kilobytes and microseconds; a coroutine can cost as little as a few hundred bytes, which is why some runtimes comfortably run millions of them.

Processes: isolation as the default

A process is a running program with its own private virtual address space — its own view of memory, its own file descriptors, its own everything, enforced by the OS via page tables. Two processes cannot corrupt each other's memory by accident; if one crashes, the kernel reclaims its resources and the other is unaffected. This isolation is exactly why processes are expensive: creating one means the kernel has to build a fresh address space and page tables, and any communication between two processes has to go through explicit, kernel-mediated channels (pipes, sockets, shared memory segments) instead of just reading a variable.

The classic interview-relevant detail here is fork(): on POSIX systems, a process can only be created by duplicating an existing one (copy-on-write, so it's cheaper than it sounds), and the child immediately diverges from the parent. This is the mechanism underneath most other process-creation APIs, and "how many processes does this fork()-calling snippet create" is a genuinely common systems-interview warm-up — the answer is always a function of how many existing processes execute each subsequent fork() call, not a fixed multiplier.

Threads: the shared-everything alternative

A thread is a path of execution within a process. Every process starts with exactly one thread (conventionally called the main thread), and that thread can create more. All threads in a process share the address space, heap, and open file handles — which is precisely why threads are cheap to create and fast to communicate between (just read/write a shared variable), and precisely why they're dangerous: nothing stops two threads from reading and writing the same memory at the same instant unless you explicitly coordinate them. That coordination problem is the entire subject of the next topic on this roadmap.

What a thread does have that's private: its own program counter, its own register set, and its own stack. That's the minimum state the OS needs to save and restore in order to pause one thread and run another — which is exactly what a context switch is (covered in depth in this topic's third subtopic).

Kernel-level vs. user-level threads

This is the detail that separates a surface-level answer from a senior-level one: not all "threads" are known to the OS kernel.

  • Kernel-level threads (KLTs) are threads the OS scheduler directly knows about and schedules onto CPU cores. This is what pthread_create (and, transitively, java.lang.Thread, Python's threading.Thread, etc.) gives you on modern systems — a 1:1 model, one kernel thread per user-visible thread.
  • User-level threads (ULTs) are threads multiplexed by a library entirely in user space; the kernel sees only the one (or few) underlying kernel threads carrying them. Switching between ULTs can be extremely fast (no kernel trap needed) — but if one ULT makes a blocking system call, the entire underlying kernel thread blocks, potentially starving every other ULT multiplexed onto it, unless the runtime specifically works around this (as Go's goroutine scheduler and Java's virtual threads do, by detecting blocking calls and moving other work onto a spare kernel thread).

Most mainstream languages today use the 1:1 model for their "plain" threads, which is exactly why the next section exists.

Coroutines: cooperative, not preemptive

A coroutine is a unit of execution that can voluntarily suspend itself and later be resumed from exactly where it left off — without ever requiring a full kernel-level context switch, because it never needed its own OS thread in the first place. The scheduling here is cooperative: a coroutine keeps running until it explicitly yields control (typically at an await/suspend point), rather than being forcibly preempted by a timer interrupt the way a kernel-scheduled thread is.

This is the mechanism behind Python's async/await, JavaScript's async functions, Kotlin's coroutines, and (conceptually) Go's goroutines. A single OS thread can host thousands of suspended coroutines because each one's "saved state" is just a small continuation/stack, not a full kernel thread control block. The trade-off: a coroutine that never yields (e.g., it runs a tight CPU-bound loop with no await) will starve every other coroutine sharing its thread — there's no timer interrupt coming to rescue you, unlike with preemptively-scheduled OS threads.

Why interviewers care

This subtopic rarely produces a coding question on its own — its job is to be the vocabulary you're graded on for the rest of the interview. When you say "I'll use a thread pool here," a strong interviewer is silently checking whether you understand that you're trading isolation for shared-memory speed, and whether you'd reach for coroutines instead if the workload is I/O-bound with high concurrency and low per-task CPU cost. Getting this framing right before you write a line of synchronization code is what separates "knows the Lock API" from "understands why the Lock API needs to exist."

Pitfalls and interview gotchas

  • Confusing "lightweight" with "free." Threads and coroutines reduce cost relative to processes, but shared mutable state is not free — it's a liability that has to be actively managed (that's the entire rest of this roadmap).
  • Assuming all threads are kernel-scheduled. Green threads / ULTs exist, and "a blocking call on one thread stalls everything" is a real failure mode worth naming if asked about a specific runtime.
  • Treating coroutines as "just async threads." A coroutine that never yields will monopolize its thread indefinitely — there's no preemption to save you, which is the opposite failure mode from an OS thread hogging a core (which the scheduler will eventually interrupt).
  • Forgetting that fork() duplicates, it doesn't share. A child process gets a copy of the parent's memory (copy-on-write) — mutating a "shared" variable after fork() does not propagate back to the parent, unlike a genuinely shared thread-local variable.

Where this goes next

Concurrency vs. Parallelism (next) sharpens exactly what you gain by having more than one thread or process: sometimes it's genuine simultaneous execution, and sometimes it's only the appearance of it via fast switching on a single core. Then Scheduling & Context Switching gets precise about the mechanism — and the cost — of that switching, for both threads and processes.

Reference implementations in:

Spawning an OS thread and waiting for it to finish

The same shape in all five languages: create, start, join. The interesting differences are underneath, not in the syntax.

var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() fmt.Println("Running on a goroutine") }() wg.Wait() // blocks until Done() has been called Add()'s worth of times fmt.Println("goroutine has finished")

Go has no Thread type — go f() always starts a goroutine, multiplexed M:N onto OS threads by the runtime. There is no join handle either: sync.WaitGroup (or a channel receive) is how you wait. runtime.LockOSThread() pins a goroutine to a dedicated OS thread if you actually need one (cgo, thread-local state); most code never does.

Lightweight, non-OS-thread concurrency: coroutines and virtual threads

None of these five snippets create a traditional 1:1 OS thread per unit of work — that's the entire point.

var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() fmt.Println("cheap goroutine, not a 1:1 OS thread") }() } wg.Wait()

go f() is Go's analogue of virtual threads and coroutines — it's been the only model since day one, not an alternative bolted on later. A goroutine starts at ~2KB of stack and is scheduled M:N onto GOMAXPROCS OS threads, so spawning thousands (or millions) is idiomatic, not a red flag.

Further Resources (Optional)