← Back to Notebook

Why Goroutines Are Cheap

August 14, 2026

— And Why That's Not the Whole Story

go someFunc() looks like it starts a thread. It doesn't — and that difference is the whole topic of this note.

The crux: goroutines are cheap because Go's own runtime schedules them in user space, not the OS — but cheap doesn't mean coordinated, and that gap is where the bugs live.

An OS thread reserves 1–2 MB of stack and needs a kernel context switch to run. A goroutine starts at ~2 KB and grows as needed; the Go runtime multiplexes thousands of them onto a handful of real OS threads itself. That's why spawning one is free enough to do casually — and exactly why go doesn't also wait for the work to finish. It schedules; it doesn't block.

That's the trap: go sendEmail(user) returns immediately. If main exits right after, the email may never send. Coordination isn't automatic just because spawning is cheap — you still have to say "wait for this":

done := make(chan bool)

go func() {
    sendEmail(user)
    done <- true
}()

<-done // blocks until the goroutine finishes

The same gap shows up with shared data: two goroutines writing the same variable won't crash, they'll just silently produce a wrong answer under load. A sync.Mutex around the shared write fixes it — and go test -race catches the cases you missed, before production does.

Remember: cheap goroutines change the default from "ration threads" to "spawn freely" — but you, not the runtime, are still on the hook for saying when something is done and protecting what it touches.