Goroutines: A Hundred Thousand Threads and a Trapdoor
What a goroutine is, why the go keyword makes concurrency almost free, how the runtime multiplexes thousands of them onto a handful of OS threads, and the one rule that trips up everyone: when main returns, the program exits and your goroutines die with it. Compiled and run against Go 1.26.5.
A goroutine is a function running concurrently with the rest of your program. You start one by writing go in front of a function call, and that is nearly the whole of the syntax. The call returns immediately, the function runs somewhere else, and your code carries on. Concurrency in Go is not a library you import or a thread pool you configure; it is a keyword.
What makes this more than a syntactic nicety is the cost. A goroutine is not an operating-system thread. It starts with a stack of about two kilobytes that grows and shrinks on demand, and the Go runtime multiplexes many of them onto a small number of real OS threads. So goroutines are cheap enough that you launch them by the thousand without a second thought, and the design of Go’s standard library assumes you will. This chapter is about what that buys you, how the machinery works, and the one sharp edge that catches everyone exactly once.
The go keyword
Here is a goroutine:
package main
import "fmt"
func main() {
go fmt.Println("hello from a goroutine")
fmt.Println("main is done")
}
go fmt.Println(...) schedules that call to run concurrently and returns right away. main moves on to its own Println without waiting. Two functions are now eligible to run at the same time, and the runtime decides how to interleave them.
Run this and something unsettling happens:
main is done
The greeting is gone. Run it again and you might, occasionally, see it:
main is done
hello from a goroutine
I ran this program eight times. Seven times it printed only main is done; once it printed the greeting too, after main’s own line. That is not a bug in the program. It is the single most important fact about goroutines, and it deserves its own heading.
The trapdoor: when main returns, everything stops
When the main function returns, the program exits immediately, and every goroutine still running is killed on the spot. No wind-down, no chance to finish, no error. The main goroutine is special: its return is the program’s return.
In the program above, main launches the greeter and then reaches its closing brace almost instantly. The scheduled goroutine has barely been handed to the runtime before the whole process exits out from under it. The one run that printed the greeting is the case where the scheduler happened to give it a slice of time before main finished. The rest of the time the trapdoor opened first.
This is the newcomer surprise. You launch background work, the program ends, and the work silently never happened. There was no crash to point at. The fix is not to hope the scheduler is kind; it is to make main actually wait until the work is done.
Why time.Sleep is the wrong fix
The tempting first patch is to give the goroutine a moment:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("hello from a goroutine")
time.Sleep(10 * time.Millisecond)
fmt.Println("main is done")
}
This works, in the sense that it now prints the greeting on every run:
hello from a goroutine
main is done
Five runs, five greetings. So why call it wrong? Because it is a guess dressed as a solution. You have no idea how long the goroutine will take; ten milliseconds is a number you made up. Make the work slower than your guess and the greeting vanishes again. Make the sleep longer to be safe and you have inserted a fixed delay into every run for no reason. Sleeping to synchronize is a race condition with a comfortable margin, and margins get eaten. You are not waiting for the work; you are waiting for a duration and hoping the work fits inside it.
The right answer is to wait for the actual event: the goroutine signalling that it is finished. Go gives you two proper tools for that. A channel, which the goroutine sends on when it is done and main receives from, is the next chapter. A sync.WaitGroup, a counter that main blocks on until every goroutine has marked itself complete, comes a few chapters later. Both replace “wait roughly this long” with “wait until exactly this happened.” Hold that thought; the rest of this series is largely about doing synchronization honestly.
They really are cheap
The claim that goroutines are cheap is easy to state and easy to doubt, so let us make a hundred thousand of them stand up at once and count.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("goroutines at start:", runtime.NumGoroutine())
release := make(chan struct{})
var running, done sync.WaitGroup
const n = 100_000
running.Add(n)
done.Add(n)
for i := 0; i < n; i++ {
go func() {
running.Done()
<-release // block here, staying alive
done.Done()
}()
}
running.Wait() // every goroutine has reached the block
fmt.Println("goroutines alive at once:", runtime.NumGoroutine())
close(release) // let them all finish
done.Wait()
fmt.Println("goroutines after they finish:", runtime.NumGoroutine())
}
Each goroutine parks on <-release and waits, so all hundred thousand are alive simultaneously when we count. The output:
goroutines at start: 1
goroutines alive at once: 100001
goroutines after they finish: 1
A hundred thousand and one, the extra being main itself. This ran in a fraction of a second on a laptop and used a few hundred megabytes. Try that with a hundred thousand OS threads and your machine will fall over long before it succeeds; a thread is a megabyte or more of stack and a kernel-scheduled entity. The runtime.NumGoroutine() calls are the built-in headcount, and they confirm the shape of the whole exercise: one goroutine before, a hundred thousand and one at the peak, back to one after they drain. Concurrency at this scale is a normal thing to do in Go, not a stunt.
What multiplexes onto what
Your goroutines do not each get an OS thread. The runtime keeps a pool of threads and schedules goroutines onto them, parking a goroutine that blocks (on a channel, a lock, or I/O) and running another in its place on the same thread. The knob that controls how many goroutines run truly in parallel is GOMAXPROCS: the maximum number of OS threads executing Go code at the same instant. By default it matches the number of CPUs the process can see.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
On the machine I ran this:
NumCPU: 16
GOMAXPROCS: 16
runtime.GOMAXPROCS(0) reads the current value without changing it; pass a positive number to set it. Part of what makes the multiplexing so effective is how the runtime handles blocking. When a goroutine blocks on a channel, a lock, or network I/O, the runtime parks it and runs a different goroutine on that same thread, so a blocked goroutine costs you nothing but its own small stack. This is why a server can hold a hundred thousand goroutines each waiting on a slow connection without needing a hundred thousand threads: the waiting ones are parked, and only the handful doing actual work occupy threads at any moment. The distinction worth internalizing is concurrency versus parallelism. You can have a hundred thousand goroutines concurrently (all in flight, interleaved) while only sixteen of them run in parallel at any given nanosecond, because there are sixteen threads to run them on. Goroutines are about structure, how you decompose a program into independent activities. GOMAXPROCS is about how many of those activities the hardware executes at once. You rarely need to touch it; the default is right almost always, and reaching for it is a late-stage tuning decision, not a starting move.
A panic in a goroutine takes down everything
There is a second sharp edge, and it cuts deeper than the trapdoor. An unrecovered panic in any goroutine crashes the whole process, not just the goroutine that panicked. There is no isolation between goroutines here: one of ten thousand background workers hitting an unhandled panic brings down all ten thousand and main with them. A goroutine is not a little sandbox that can fail on its own.
The tempting assumption is that a recover up in main can act as a safety net for the goroutines it launches. It cannot. recover only catches a panic unwinding through its own goroutine’s deferred calls; a panic in another goroutine is on a different stack entirely and never passes through main’s defers. Here is that mistake in full:
package main
import (
"fmt"
"time"
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main recovered:", r) // this never runs
}
}()
go func() {
panic("boom from a goroutine")
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("main finished normally") // never reached
}
main sets up a defer/recover that looks like it should catch anything, launches a goroutine that panics, and waits. The output:
panic: boom from a goroutine
goroutine 19 [running]:
main.main.func2()
.../main.go:16 +0x25
created by main.main in goroutine 1
.../main.go:15 +0x3b
exit status 2
The process dies with exit status 2, and it does so on every run. Neither main recovered: nor main finished normally ever prints. The recover in main did nothing, because the panic was never on main’s stack to catch. The runtime printed the panicking goroutine’s trace and killed the program.
The fix is to put the defer/recover inside the goroutine that might panic, so the recovery runs on the same stack the panic unwinds through:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Println("goroutine recovered:", r)
}
}()
panic("boom from a goroutine")
}()
wg.Wait()
fmt.Println("main finished normally")
}
goroutine recovered: boom from a goroutine
main finished normally
Now the panic unwinds through the goroutine’s own deferred call, recover catches it there, the goroutine returns cleanly, and the process exits 0. The rule to carry: every goroutine is responsible for its own panics. If you launch a goroutine that runs code which could panic and you don’t want that to be fatal, the recovery has to live inside that goroutine, not somewhere up the call tree that launched it.
Final thoughts
A goroutine is a concurrently-running function, started with the go keyword, cheap enough to launch by the tens of thousands because the runtime multiplexes it onto a small pool of OS threads rather than giving it one of its own. The load-bearing rule, the one that will bite you if nothing else here does, is that main returning ends the program and kills every goroutine still in flight. time.Sleep papers over that with a guess and should never be your synchronization; you want to wait for the event, not the clock. The tools that do it properly are channels and WaitGroup, and the next chapter starts with the first of them, which turns out to be a synchronization primitive as much as a data conduit.
Next: channels: the unbuffered handshake — how a channel makes two goroutines meet at a single point in time.
Comments