The idea: who schedules the task?
A program that does many things at once needs many tasks. With OS threads, every task is a thread that the kernel creates, schedules and switches: one task, one kernel thread (1:1). With goroutines, the Go runtime runs many tasks on a few OS threads and switches them itself, in user space (M:N). The canvas runs the same tasks both ways on 2 cores: threads on the left, goroutines on the right, one unit of work per step.
OS threads (1:1)
Creating a thread is a system call: pthread_create calls clone(), and the kernel sets up a task struct, a kernel stack and a user stack. The JVM reserves 1 MB of stack per thread by default (-Xss1m); C programs get 8 MB. The memory is reserved address space, and the OS fills pages only when they are touched, but every thread still costs kernel memory, a slot in the scheduler and a limit (ulimit -u, threads-max). In return the kernel does everything: a thread that blocks simply sleeps, and the scheduler preempts a busy thread when another one wakes up. (Demo: spawn 10,000 tasks, left lane: 10,001 threads, 9.8 GB of stacks reserved.)
Goroutines (M:N, G-M-P)
A goroutine (G) is a small struct in the Go runtime with a 2 KB stack. go f() is a function call (newproc), about 0.3 µs, with no system call. The stack grows when needed: the function prologue checks it, and morestack copies it to a block twice the size. Gs run on Ms (OS threads), and an M must hold a P (a "processor", one per GOMAXPROCS) to run Go code. Each P has a run queue, so 10,000 runnable Gs need only 2 busy threads on 2 cores. (Demo: spawn 10,000 tasks, right lane: 2 threads, 19.5 MB.) The details are on The Go Scheduler and Inside a Goroutine.
What a switch costs
When thread A hands a value to thread B and waits, A calls futex_wake and futex_wait (two system calls) and the kernel switches the core: it saves A's registers, picks B and restores B's. That is about 2–5 µs, plus cold caches afterwards. A goroutine hand-off over a channel stays in user space: gopark saves 3 registers (SP, PC, BP) in A's g, schedule() picks B, and gogo loads B's 3 registers, about 0.2 µs. (Demo: ping-pong: 2,000 switches cost 6 ms with threads and 400 µs with goroutines.)
Blocking calls and the netpoller
A G that calls a blocking system call, such as read() on a file, takes its M into the kernel with it. The P must not sit idle, so sysmon notices the P stuck in a syscall (after about 20 µs) and handoffp gives it to another M, creating a thread if no idle one exists. When the call returns, the G looks for a free P; if there is none it goes to the global run queue and its M parks as an idle thread. (Demo: a blocking system call: Go needs a third thread, while the threads lane just switches to T3.)
Network I/O is different. A G that reads from a socket with no data parks on the netpoller (epoll on Linux, kqueue on macOS) and holds no thread at all, so 10,000 idle connections need 10,000 × 2 KB and 2 threads (Demo: 10,000 idle network connections). File reads have no such path: 10,000 goroutines blocked in file reads need 10,000 threads, and the runtime stops at debug.SetMaxThreads (10,000 by default) with fatal error: thread exhaustion (Demo: 10,000 blocking file reads). Bound such work with a worker pool, as in Thread Pool.
Preemption: where threads win
The kernel preempts often: Linux's scheduler (CFS, and EEVDF since 6.6) runs a task that wakes up after sleeping ahead of one that has been using the CPU. Go's scheduler only switches a G when it blocks, yields or makes a call into the runtime. A G in a tight loop is stopped by sysmon after 10 ms (forcePreemptNS), with a signal (SIGURG, asynchronous preemption since Go 1.14). So with two CPU hogs on 2 cores, a short request is answered after 1 ms with threads and after 11 ms with goroutines (Demo: CPU hogs delay a short request).
When to pick which
| Situation | OS threads | Goroutines |
|---|---|---|
| Many tasks, mostly waiting on the network (servers) | One thread and stack each; thousands get heavy | ✓ 2 KB each, the netpoller holds no thread |
| Frequent hand-offs between tasks | ~3 µs per switch through the kernel | ✓ ~0.2 µs in user space |
| Blocking system calls, cgo, file I/O | ✓ the thread just sleeps | Each call pins an M; many at once need many threads |
| A few long CPU-bound tasks with tight latency | ✓ the kernel preempts on wakeup | Preemption only after 10 ms |
| Priorities, CPU pinning, real-time | ✓ nice, sched_setaffinity, SCHED_FIFO per thread | Only for a whole M with runtime.LockOSThread |
Other languages moved toward Go's model: Java 21's virtual threads are M:N threads on a pool of carrier threads, and async/await runtimes (Node, Rust's Tokio) run many tasks on a few threads in a similar way.
Common mistakes
- "Goroutines are free." Each one is at least 2 KB plus what it keeps alive, and a goroutine that never ends is a leak.
- "A goroutine never uses a thread while it waits." True for channels, timers and the network; false for blocking system calls and cgo calls.
- "Threads use 1 MB of RAM each." They reserve it; the resident memory is what the stack actually touched, plus the kernel's own per-thread memory.
- "GOMAXPROCS is the number of threads." It is the number of Ps, the threads that run Go code at the same time. A program can have many more Ms.
Java 21's virtual threads do the same M:N trick on ForkJoinPool carriers; see Java Virtual Threads vs Platform Threads.
What the page leaves out
The costs are typical orders of magnitude, not measurements: real numbers depend on the CPU, the kernel and the cache state, and a thread's first switch after a long sleep also pays for cold caches and TLB misses. The page shows 2 cores and one shared run queue per lane; it leaves out Go's runnext slot, work stealing and the global queue's fairness rule (shown on The Go Scheduler), the spinning Ms, sysmon's own thread, stack shrinking during GC, the cost of a goroutine's growing stack copies, thread-local storage, the kernel's vDSO calls that need no switch at all, and NUMA placement.