Skip to content
gohacks.dev
Concurrency

Count Goroutines at Runtime and Watch for Slow Growth

Count goroutines with runtime.NumGoroutine or runtime/metrics, export the number from a service, and read a steady climb as the earliest sign of a leak.

3 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

The quickest health check for a concurrent Go program is one number: how many goroutines exist right now. It costs almost nothing to read, and a graph of it over a day tells you more about leaks than any amount of staring at code. This note shows how to read it, how to export it, and what the shape of the graph means.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It is a companion to goroutine leaks and detecting leaks in tests, and it belongs to the concurrency material. Everything ran on Go 1.25.6.

Reading the count

main.goGo
package main

import (
	"fmt"
	"runtime"
	"runtime/metrics"
	"sync"
	"time"
)

func goroutinesMetric() uint64 {
	sample := []metrics.Sample{{Name: "/sched/goroutines:goroutines"}}
	metrics.Read(sample)
	return sample[0].Value.Uint64()
}

func main() {
	fmt.Println("at start:", runtime.NumGoroutine())

	release := make(chan struct{})
	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() { <-release })
	}
	time.Sleep(10 * time.Millisecond)

	fmt.Println("with 50 workers:", runtime.NumGoroutine(), "| metric:", goroutinesMetric())

	close(release)
	wg.Wait()
	time.Sleep(10 * time.Millisecond)
	fmt.Println("after they finish:", runtime.NumGoroutine())
}
Text
at start: 1
with 50 workers: 51 | metric: 51
after they finish: 1

At the start there is one goroutine, main. With fifty workers blocked the count is 51, and both runtime.NumGoroutine() and the runtime/metrics reading agree. After the workers finish, the count returns to 1. That return is the behaviour of a healthy program: the number rises while work is in flight and falls back when it is done. Any code that leaves the count higher than it started has left goroutines behind.

Note the details. The count includes the calling goroutine and every goroutine the program started, and also some the runtime owns; the exact baseline of a trivial program can differ between Go versions, so compare against your own baseline, not against a fixed number. It is a snapshot, so a spike for a moment is normal.

Exporting it from a service

A service should export the count as a metric. The Prometheus Go client's default collector exposes go_goroutines, and OpenTelemetry's runtime instrumentation exposes a goroutine count too, so if you already collect runtime metrics you likely have it. The standard library alternative is expvar, which serves JSON on /debug/vars, and the net/http/pprof handler shows the count at the top of /debug/pprof/goroutine?debug=1. For a quick log line, a ticker works:

Go
go func() {
	t := time.NewTicker(30 * time.Second)
	defer t.Stop()
	for range t.C {
		slog.Info("runtime", "goroutines", runtime.NumGoroutine())
	}
}()

(That goroutine needs a way to stop in a real program, for the reason this whole series keeps stating.)

Reading the graph

The shape tells you what is happening.

ShapeLikely meaning
Rises and falls with trafficHealthy: goroutines per request or per connection
Flat lineA fixed pool; good, if it is the pool size
Slow steady ramp, ignoring trafficA leak of one goroutine per some event
Staircase that steps up at certain timesA leak tied to a periodic job or a deploy
Sudden spike that decaysBurst of work, or a retry storm that cleared
Spike that staysA burst that leaked or a dependency that hung

Compare with request rate on the same dashboard. A service that handles twice the traffic at noon should show more goroutines at noon; a count that is higher every day at the same time of day, with the same traffic, is leaking. Leaks are slow, so look at a window of hours or days, and set the alert on the slope (increase over six hours) and not a threshold.

The count says that, the profile says where

When the graph says leak, the next step is the goroutine profile: /debug/pprof/goroutine?debug=1 groups goroutines by stack with counts, and the stack with the biggest count and a blocked state is the leak (goroutine leaks shows how to read it). Take two snapshots an hour apart and compare: the stack whose count grew is the one. In CI, a leak check on every package that starts goroutines catches the rest. The reference is the runtime.NumGoroutine documentation and the list of supported metrics in runtime/metrics.

Also inDevOps
Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.