Mike Mwita


Learning the Ropes: Running a Million Goroutines on One Core

#go #concurrency #goroutines #performance #runtime 

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.

flowchart LR G["Goroutines\n(G)"] -->|"queued on"| P["Processors\n(P)"] P -->|"run on"| M["OS threads\n(M)"] M --> CPU["CPU\ncores"] M -.->|"blocked\nsyscall"| X["Extra\nthread"]

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:

sequenceDiagram participant A as goroutine A participant P as P0 on thread M0 participant N as netpoller participant K as kernel (epoll / kqueue) participant B as goroutine B A->>P: conn.Read(), no data yet P->>N: park A, watch its socket N->>K: register the file descriptor P->>B: run B on the same thread K-->>N: socket is readable N-->>P: A is runnable again P->>A: resume A when its turn comes

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:

idle/main.gogo
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()
bash
GOMAXPROCS=1 ./idle 1000000

Results on an Apple MacBook Pro M1, Go 1.26.1, with GOMAXPROCS=1:

GoroutinesRuntime memoryPer goroutineTime to spawnForced GC
10,00025 MiB~2.6 KB34 ms14 ms
100,000263 MiB~2.8 KB380 ms119 ms
1,000,000~2.6 GiB~2.7 KB4.2–5.9 s1.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.

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:

conns benchmark · GOMAXPROCS=1
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=4

It 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:

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:

fetch.gogo
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.

Pick the limit from the remote side’s capacity, not from how many goroutines you can afford. The other service is almost always the smaller number.

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.