๐ง The Problem
Every Go dev has heard "goroutines are cheap." True, relative to OS threads. But cheap isn't free, and at scale the bill comes due in ways that don't show up until you're staring at a memory graph wondering why your service that spawns a goroutine per request just OOM'd at 800k concurrent connections.
I wanted actual numbers, not vibes. So I spun up 1,000,000 goroutines in a few different shapes, profiled them with go tool pprof and runtime.MemStats, and tried to separate two costs that get lumped together as "goroutine overhead":
- Stack memory โ the growable per-goroutine stack
-
Scheduler bookkeeping โ the
gstruct,sudogs, run queue entries, and GC scanning overhead
These are different cost centers with different scaling behavior, and conflating them leads to bad capacity planning.
๐งช The Setup
Here's the baseline harness โ goroutines that block forever on a channel, so they stay alive and scheduled but don't do work:
go
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
)
func main() {
const n = 1_000_000
block := make(chan struct{})
var readyWg = make(chan struct{}, n)
for i := 0; i < n; i++ {
go func() {
readyWg <- struct{}{}
<-block
}()
}
for i := 0; i < n; i++ {
<-readyWg
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("StackInuse: %d MB\n", m.StackInuse/1024/1024)
fmt.Printf("NumGoroutine: %d\n", runtime.NumGoroutine())
f, _ := os.Create("heap.pprof")
pprof.WriteHeapProfile(f)
f.Close()
<-make(chan struct{}) // hang so we can attach pprof live too
}
Run with GODEBUG=madvdontneed=1 off (default) and GOGC=400 to reduce GC noise while we measure steady state, then:
bash
go run main.go &
go tool pprof -top -alloc_space heap.pprof
go tool pprof http://localhost:6060/debug/pprof/goroutine
(Yes, I added the standard net/http/pprof import for the live endpoint โ don't forget the underscore import or you'll wonder why /debug/pprof/ 404s.)
๐ What MemStats Actually Shows
With 1M blocked goroutines doing nothing but sitting on a channel receive:
HeapAlloc: 366 MB
StackInuse: 2147 MB
NumGoroutine: 1000000
That StackInuse number is the headline: ~2.1KB per goroutine, even though the default initial stack is 2KB and these goroutines do almost nothing. That checks out โ Go's runtime allocates the initial stack up front, and a goroutine parked on a channel receive still holds onto that stack because the scheduler needs somewhere to resume execution.
The HeapAlloc โ 366MB โ is the part people forget about. That's not stack, that's the runtime.g structs, the sudog entries used for channel waiters, and assorted bookkeeping. Divide it out: ~384 bytes per goroutine in pure scheduler/heap overhead, separate from the stack.
So per goroutine, roughly:
- 2KB โ initial stack (grows if the function needs more)
- ~200-450 bytes โ
gstruct + scheduler metadata - ~48-100 bytes โ
sudogif blocked on a channel/mutex/select
That second bucket is the one that surprises people, because it scales with goroutine count, not with what the goroutine is doing. You can't shrink it by simplifying your goroutine's logic. It's the tax for existing.
๐ Where pprof Actually Helps
go tool pprof -alloc_space on the heap profile shows the scheduler overhead concretely:
(pprof) top10
Flat Flat% Sum% Cum Cum% Name
312MB 85.2% 85.2% 312MB 85.2% runtime.malg
38MB 10.4% 95.6% 38MB 10.4% runtime.acquireSudog
9MB 2.5% 98.1% 9MB 2.5% runtime.newproc.func1
runtime.malg โ goroutine struct allocation โ dominates. This is the smoking gun for "scheduler overhead," separate entirely from the stack memory pprof doesn't even show you here (stack isn't heap-allocated in the traditional sense pprof tracks by default; you have to cross-reference StackInuse from MemStats to see it).
That's the key methodological point: pprof's heap profile and MemStats' StackInuse are answering different questions, and if you only look at one you'll misdiagnose the bottleneck. I've seen postmortems blame "goroutine leaks" on stack growth when the actual driver was thousands of goroutines each holding a sudog because they were blocked on a busy mutex.
๐ What Happens When Goroutines Actually Do Something
Blocked-on-channel goroutines are the cheap case. Let's make it more realistic โ recursive work that grows the stack:
go
func recurse(n int, block <-chan struct{}) {
if n == 0 {
<-block
return
}
var buf [64]byte
_ = buf
recurse(n-1, block)
}
Spawning 1M of these with recurse(50, block) pushes StackInuse from ~2.1GB to ~4.6GB โ stacks grew from the 2KB default to accommodate the recursion depth, and Go doesn't shrink them back down eagerly unless a GC cycle happens to trigger shrinkstack on that goroutine (checked during stack scanning, roughly every other GC if the stack is <1/4 utilized). Under GOGC=400 that shrink check happens rarely, so stacks stay bloated far longer than you'd expect.
This is the practical takeaway: if your workload has bursty deep call stacks (recursive JSON parsing, deep middleware chains, reflection-heavy code), the stack growth sticks around as a memory cost long after the burst ends, independent of the flat per-goroutine scheduler tax. Lowering GOGC or calling debug.FreeOSMemory() after a burst can reclaim it, at the cost of more frequent GC cycles overall โ a real trade-off, not a free win.
๐งฎ The Cost Model, Summarized
| Cost center | Scales with | Reclaimed how |
|---|---|---|
| Initial stack (2KB) | goroutine count | goroutine exit |
| Stack growth | max call depth reached | GC-triggered shrinkstack, rare under high GOGC |
g struct / scheduler |
goroutine count | goroutine exit |
sudog |
count of blocked goroutines | unblock or exit |
If you're doing capacity planning for a goroutine-per-connection server, the number that matters isn't "goroutines are ~2KB each" โ it's closer to 2.5-3KB steady state for idle blocked goroutines, and potentially multiples of that if your request handlers recurse or allocate large local buffers before blocking.
๐ Over to You
Have you actually hit a goroutine-count wall in production, or is 1M goroutines mostly an academic exercise for your workloads? I'd genuinely like to know where the real-world breakpoint sits โ 100k? 10M? Drop your numbers (and your GOMAXPROCS) in the comments.
If you want to reproduce this, the full harness plus the recursive-stack variant is about 80 lines โ worth running locally with your own GOGC and GOMAXPROCS settings, because scheduler contention at 1M goroutines on a 4-core box behaves noticeably differently than on 64 cores, and that's a whole separate profiling story.
Top comments (0)