Mike Mwita
Learning the Ropes: Running a Million Goroutines on One Core
F or a long time, I believed this about Go:
The Go runtime switches between goroutines without involving the kernel scheduler, parking one whenever it waits on I/O and running another. If a task spends almost all its time waiting on the network, one CPU core can comfortably handle a million goroutines.
I had read it, repeated it, and never actually checked it. So I set out to confirm it: first by learning how the runtime pulls this off, then by running a million goroutines on a single core and watching what happened.
The scheduler kept its promise. Almost everything around it turned out to be a different story.
The Part That Is True #
Go uses an M:N scheduler: many goroutines run on a small number of OS threads. Three pieces make it work.
- G is a goroutine: a function with its own small stack that grows as needed.
- M is a real OS thread. The kernel schedules these, not goroutines.
- P is a permission to run Go code, with a local queue of runnable goroutines. A thread must hold a P to run a G.The number of Ps is
GOMAXPROCS, which defaults to the number of CPUs. Since Go 1.25 it also respects container CPU limits on Linux. It limits how many goroutines run at once, not how many exist.
When a goroutine reads from a socket that has no data yet, the runtime does not block the thread. It parks the goroutine with the netpoller and runs the next one on the same thread:
Switching from A to B is a user-space operation that saves and restores a handful of registers. No kernel context switch, no new thread. That is why I/O-heavy Go services can hold huge numbers of connections cheaply.
I Measured It #
The claim is about one core, so I pinned the runtime to one P and parked increasing numbers of goroutines on a channel. This is the core of the benchmark:
release := make(chan struct{})
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
<-release
}()
}
runtime.ReadMemStats(&after)
gcStart := time.Now()
runtime.GC()
gc := time.Since(gcStart)
close(release)
wg.Wait()GOMAXPROCS=1 ./idle 1000000Results on an Apple MacBook Pro M1, Go 1.26.1, with GOMAXPROCS=1:
| Goroutines | Runtime memory | Per goroutine | Time to spawn | Forced GC |
|---|---|---|---|---|
| 10,000 | 25 MiB | ~2.6 KB | 34 ms | 14 ms |
| 100,000 | 263 MiB | ~2.8 KB | 380 ms | 119 ms |
| 1,000,000 | ~2.6 GiB | ~2.7 KB | 4.2–5.9 s | 1.1–1.6 s |
So yes: one core held a million goroutines, and waking all of them took under a second. But look at the other columns.
- Memory is the real price. About 2.7 KB per parked goroutine, almost all of it stack. A million idle goroutines cost 2.6 GiB before a single byte of request data.
- GC grows with goroutine count. A full collection took over a second, because the collector has to scan every goroutine’s stack. With all 8 logical CPUs available it dropped to about 0.65 s, still far from free.
runtime.GC() forces a full, blocking collection, which exaggerates what a live service sees. Go’s real GC runs concurrently, but stack scanning is still work proportional to the number of goroutines, and it shows up as CPU and latency.What Actually Stops You #
The Kernel Is Still Involved #
“Without the kernel” really means “without kernel thread scheduling”. Every socket is a kernel object, and readiness comes from epoll on Linux or kqueue on macOS. The kernel just is not deciding which goroutine runs.
Not All I/O Parks #
The netpoller handles network I/O. File I/O on Linux, cgo calls and other blocking syscalls block the OS thread instead. The runtime then hands that thread’s P to another thread, creating new threads as needed, up to a default limit of 10,000.That limit is set with debug.SetMaxThreads. Hitting it crashes the program, which is a good reason not to spawn a goroutine per file operation on a slow disk.
A million goroutines reading from a network socket is fine. A million goroutines reading from a slow disk is a thread explosion.
The Operating System Gives Out First #
To test real connections, I opened TCP connections to a local listener, one parked goroutine per connection, until something failed:
stopped at 16342 connections after 2.826s:
dial tcp 127.0.0.1:54282: connect: can't assign requested address
connections=16342 goroutines=32686 threads=4It stopped at 16,342 connections with the CPU nearly idle. The limit was ephemeral ports: macOS gives outgoing connections the range 49152–65535, which is 16,384 ports for a single destination address and port.Linux defaults to 32768–60999, about 28,000 ports. The limit applies per source IP, destination IP and destination port, so spreading connections across destinations or reusing them with a pool raises it.
Behind that wall are more of them:
- File descriptors — every socket is one, and
ulimit -ncaps them per process. - Socket buffers — the kernel reserves memory per connection on top of your goroutine.
- Connection tracking — NAT and firewalls keep per-connection state with their own table limits.
HTTP/2 Is Not Unbounded #
HTTP/2 multiplexes many requests over one TCP connection, but the server caps it with SETTINGS_MAX_CONCURRENT_STREAMS. Go’s HTTP/2 server defaults to 250 streams per connection, and flow-control windows and TCP head-of-line blocking still apply.
The Remote Side #
The claim usually comes with a quiet caveat: as long as the remote service can keep up. That caveat carries the most weight, and it rarely holds. A million concurrent requests from your side is a load test, or an outage, for the service on the other end.
What I Do Instead #
“You can spawn a million goroutines” is true. “You should” almost never is. The fix is to make concurrency a decision instead of an accident.
Bound it, and give every call a deadline:
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(100)
for _, url := range urls {
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return fetch(ctx, url)
})
}
return g.Wait()
}SetLimit makes g.Go wait until a slot frees up, so the number of in-flight requests never exceeds 100 however many URLs arrive. The timeout ensures a slow remote cannot pin goroutines forever.
The rest of the checklist is unglamorous: reuse connections with a pool, raise ulimit -n deliberately, watch goroutine count as a metric, and treat a steadily climbing number as a leak until proven otherwise.
The Takeaway #
The scheduler lives up to the claim: on one core, a million parked goroutines woke up in under a second. Everything around the scheduler is the problem: 2.6 GiB of stacks, second-long collections, and an operating system that ran out of ports at sixteen thousand connections.