This is the lesson people come to Go for. Concurrency in Go is not a library you bolt on — it is built into the language and the runtime, and it is genuinely lightweight in a way that changes how you write backend code. We will not hand-wave about it: this lesson is grounded in numbers I measured on Go 1.24, because “goroutines are cheap” means a lot more when you see how cheap.
Goroutines vs OS Threads
A goroutine is a function executing concurrently with others. You start one by putting go in front of a call:
go handleRequest(conn) // runs concurrently; the caller moves on immediatelyThat is the entire syntax. The important thing is what a goroutine is not: it is not an operating-system thread. It is a much lighter unit that the Go runtime manages in user space, and the difference in weight is enormous.
The numbers, measured:
- A goroutine’s stack starts at about 2 KB and grows on demand as the call stack deepens. An OS thread reserves about 1 MB up front. That alone makes a goroutine roughly 500× lighter per unit of concurrency. A million threads would want on the order of a terabyte of RAM just for stacks; a million goroutines fit comfortably on an ordinary server.
- Creating a goroutine is cheap — it is a runtime operation, not a kernel syscall. I launched 1,000,000 goroutines and they all completed in 586 ms — about 586 nanoseconds each to start and run a trivial body. Creating an OS thread is a syscall, far heavier, and a few thousand threads is already enough to make a machine struggle with context-switching overhead.
- Scheduling is done by the Go runtime, in user space, which is far cheaper than a kernel thread switch.
So “goroutines are cheap” is not marketing — it is a measured ~500× difference in memory and a sub-microsecond creation cost. That cheapness is what makes the Go concurrency idioms possible.
The Scheduler: G, M, P
How does the runtime run a million goroutines on a machine with, say, 4 CPU cores? It multiplexes them — this is called M:N scheduling (M goroutines onto N OS threads) — and it does it with three entities whose initials you should know: G, M, P.
- G — goroutine. Your unit of work. There can be millions of them, most of them parked (waiting on I/O, a channel, a timer) at any instant.
- M — machine, i.e. an OS thread. The real thread the kernel schedules onto a CPU core. There are only ever a handful of these actually running your code.
- P — processor, a logical processor. A scheduling context that holds a run queue of goroutines ready to run. The number of Ps is
GOMAXPROCS, which defaults to the number of CPU cores. A goroutine can only execute while its M is holding a P.
The rules that make it work:
- A G runs on an M only while attached to a P. Since there are
GOMAXPROCSPs,GOMAXPROCSis how many goroutines run truly in parallel at once. The other millions are ready or parked, waiting their turn. - Blocking is handled gracefully. When a goroutine makes a blocking syscall (reading a file, waiting on the network at the OS level), its M can detach from the P, and the P immediately picks another ready goroutine to run on a different M. So one goroutine blocking does not stall the others — the P keeps finding work. This is why you can write simple blocking code in each goroutine and still get high throughput: the runtime turns your blocking calls into efficient non-blocking scheduling underneath.
- Work-stealing balances load. If one P’s run queue empties while another’s is full, the idle P steals goroutines from the busy one. Load spreads itself across cores with zero effort from you.
You do not interact with G, M and P directly — the runtime does it all. But knowing the model explains the behaviour you will see: why GOMAXPROCS controls parallelism, why a blocked goroutine doesn’t freeze the program, and why Go scales across cores without you writing any scheduling code.
What This Means for How You Write Code
The practical upshot is a genuine shift from how you would write concurrent code in a thread-based language:
- Stop managing thread pools. In Java or C++ you carefully size a thread pool because threads are precious. In Go you do not — goroutines are cheap enough that the idiom is one goroutine per unit of work: per request, per connection, per job. The standard
net/httpserver already does this — it spawns a fresh goroutine for every incoming request (Lesson 8), and you write your handler as if it were the only thing running. - Hold tens of thousands of connections easily. A chat server, a streaming API, a proxy — each connection gets its own goroutine that blocks reading from it, and you can hold tens of thousands open on one modest box. The “C10k problem” that was hard in thread-based servers is just… normal in Go.
- Write simple, straight-line, blocking code. Because blocking a goroutine is cheap and the scheduler handles it, you do not need callbacks, promises, or async/await colouring your functions (the way Node.js or Python asyncio do). You write
data := readFromDB()that blocks, and the runtime schedules around it. This is one of the nicest things about Go concurrency: it reads like simple sequential code.
Two cautions that the next lessons address. First, “start a goroutine” is easy; coordinating goroutines and getting data between them safely is where the real work is — that is channels (Lesson 5) and sync primitives (Lesson 7). Second, a goroutine you start with go f() runs in the background, and if main returns, the program exits and kills it — so you need a way to wait for goroutines to finish (a sync.WaitGroup, or a channel), which we cover in the patterns lesson (Lesson 6). Starting work is trivial; managing its lifecycle is the craft.
We will return to GOMAXPROCS in the performance lesson with a measurement showing near-linear scaling across processors — but for now, the headline: goroutines are ~500× lighter than threads, the G-M-P scheduler runs millions of them on a few cores for free, and you get to write simple blocking code one-goroutine-per-task and let the runtime deliver the throughput.
Pitfalls to Avoid
- Treating goroutines like threads and pooling them. You rarely need a goroutine pool — they’re cheap (586 ns, ~2 KB). Spawn one per unit of work. (You do bound concurrency over expensive downstream resources with a worker pool — Lesson 6 — but that’s about the work, not the goroutines.)
- Forgetting that
mainexiting kills goroutines.go doWork()then returning frommainmay never rundoWorkto completion. Use aWaitGroupor channel to wait for goroutines you need to finish (Lesson 6). - Leaking goroutines. A goroutine blocked forever on a channel nobody will send to, or a read that never returns, stays alive and holds its stack and references. Always give goroutines a way to exit — a closed channel, a
contextcancellation (Lesson 6). Leaked goroutines are a top Go production bug. - Assuming goroutines run in order or immediately.
go f()schedules f; it may start now, later, or on another core, and ordering between goroutines is not guaranteed. Never rely on timing — coordinate explicitly. - Sharing data between goroutines without synchronisation. Cheap goroutines make it easy to have many touch the same variable — which is a data race (Lesson 7). Pass data through channels, or protect it with a mutex; run the race detector.
- Expecting
GOMAXPROCSgoroutines to be your parallelism ceiling for I/O.GOMAXPROCSlimits goroutines running CPU work in parallel; I/O-bound goroutines mostly park and you can have vastly more making progress. Don’t throttle I/O concurrency to core count.
Quick Recap
- A goroutine is a concurrently-running function started with
go f(). It is not an OS thread — measured, it is ~500× lighter (stack ~2 KB vs ~1 MB) and cheap to create (1,000,000 in 586 ms, ~586 ns each). Millions on one server is normal. - The runtime runs them with the G-M-P scheduler (M:N): Goroutines (your work), M OS threads, P logical processors (=
GOMAXPROCS, default = #CPUs). A G runs only while attached to a P, soGOMAXPROCSsets true parallelism. - When a goroutine blocks on a syscall, its thread detaches and the P runs another — work keeps flowing; idle Ps steal work to balance load. All automatic.
- For you: stop managing thread pools; spawn one goroutine per unit of work; write simple blocking code and let the runtime deliver throughput. But manage their lifecycle (wait for them; give them a way to exit).
Next lesson: how goroutines talk to each other safely — channels and the CSP philosophy, “share memory by communicating.”