skip to content

Runaway OS Threads

Goroutines are cheap and OS threads are not: a goroutine parked in a blocking syscall or a cgo call pins a thread, so the count climbs until the process hits the runtime's own ceiling and dies.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

How do you find out how many OS threads a running Go process has, given runtime.NumGoroutine counts goroutines?

level: juniorimportance: should knowfreq 40%

answer

  1. two populations, two counters
  2. the runtime counts one, the kernel the other
  3. runtime has no NumThreads function
  4. a scheduler gauge in runtime/metrics
  5. /sched/threads:threads, or /proc/<pid>/status

basics

~10 s

runtime.NumGoroutine counts goroutines only, never OS threads. For the thread count read the /sched/threads:threads gauge from runtime/metrics, run the process with GODEBUG=schedtrace=1000, or ask the operating system, for example /proc/<pid>/status on Linux.

solid answer

~40 s

Goroutines and OS threads are counted separately, and Go only gives you a function for the first one: `runtime.NumGoroutine()` returns how many goroutines exist right now and says nothing about threads. For threads, the direct reading is the `/sched/threads:threads` gauge in `runtime/metrics`, added in Go 1.26. Without it you can start the process with `GODEBUG=schedtrace=1000`, which makes the runtime print a scheduler line every second including a `threads=` field, or ask the kernel: `Threads:` in `/proc/<pid>/status`, the entries under `/proc/<pid>/task`, or `ps -o nlwp`. The `/debug/pprof/threadcreate` profile has a total line too, but that counts thread *creations* with their stacks, not how many are alive. In production, graph the goroutine count and the thread count together — one number alone tells you very little.

code

go · 8 lines
go
s := []metrics.Sample{{Name: "/sched/threads:threads"}}
metrics.Read(s)

threads := s[0].Value.Uint64()
goroutines := runtime.NumGoroutine()

// graph both: threads rising while goroutines stay flat is the signal
log.Printf("threads=%d goroutines=%d", threads, goroutines)

go deeper

for a junior

Be ready to state plainly that goroutines and OS threads are different populations and that runtime.NumGoroutine only counts the first. Know at least one way to see the thread count, such as /proc/<pid>/status on Linux.

for a middle

Explain where each number comes from: the runtime's own bookkeeping for goroutines and threads, versus the kernel's view. Name the runtime/metrics gauge and GODEBUG=schedtrace, and say why the threadcreate total is not a live count.

for a senior

Show you graph both series in production and read them together. Interpret the divergence: flat goroutines with climbing threads points at blocking calls, and the thread line behaves as a high-water mark you pay for in memory.

for a principal

Own what the service exports and alerts on. Decide whether thread count is a paging signal or a dashboard-only one, and make sure the memory budget is sized against the observed thread peak rather than its steady state.

## Two different populations A Go program has two kinds of "concurrent thing" and they are counted by two different bookkeepers. A **goroutine** is the runtime's own unit of concurrency: a function launched with `go f()`, with a small growable stack, scheduled entirely inside the Go runtime. Creating one costs a couple of kilobytes and no system call. An **OS thread** (the runtime calls it an **M**, for machine) is a real kernel thread created with a system call. The Go runtime multiplexes many goroutines onto a much smaller set of Ms, and it creates a new M when it needs one so that runnable goroutines can keep running. The two numbers are related only loosely, which is exactly why you need to read both. ## Counting goroutines ```go n := runtime.NumGoroutine() ``` This returns the number of goroutines that currently exist — running, runnable, and blocked. It is cheap, it is safe to call from anywhere, and it is the number `/debug/pprof/goroutine` also reports at the top of its output. It is **not** a thread count, and no amount of goroutine growth tells you directly what the kernel sees. ## Counting OS threads There is no `runtime.NumThreads()`. Four practical readings exist. **1. `runtime/metrics`.** Go 1.26 added scheduler gauges, and `/sched/threads:threads` is the current number of OS threads the runtime is managing. This is the reading to export to your metrics system, because it is cheap, live, and needs no shell access: ```go s := []metrics.Sample{{Name: "/sched/threads:threads"}} metrics.Read(s) threads := s[0].Value.Uint64() ``` **2. `GODEBUG=schedtrace=1000`.** Set this in the environment and the runtime prints one scheduler summary line per second to standard error, containing `gomaxprocs=`, `threads=`, `idlethreads=` and the run queue lengths. It costs nothing to enable on a canary and it shows the trend, not just a snapshot. Adding `scheddetail=1` prints per-M and per-P detail, which is very verbose. **3. The operating system.** On Linux, `Threads:` in `/proc/<pid>/status` is authoritative, as is counting entries in `/proc/<pid>/task`, or `ps -o nlwp -p <pid>`. This view includes threads the Go runtime did not create — for example threads started internally by a C library you linked against — so it can legitimately exceed the runtime's own count. **4. `/debug/pprof/threadcreate`.** Import `net/http/pprof` and this endpoint reports stack traces at thread creation. Its total is a count of *creation events*, not of live threads, so treat it as attribution ("who made them") rather than as a census ("how many are there"). ## Reading the two numbers together A thread count on its own is close to meaningless: a healthy Go service commonly runs a handful more threads than `GOMAXPROCS` because some goroutines are sitting in system calls. What carries signal is the **relationship over time**: - Both numbers flat, threads in the low tens — normal. - Goroutines climbing, threads flat — that is a goroutine problem (work piling up or goroutines never finishing), not a thread problem. - Threads climbing while goroutines stay flat — the interesting one. Work is not increasing, yet the runtime keeps needing fresh Ms, which means existing Ms are stuck somewhere the runtime cannot take them back, typically a long blocking system call or a call into C code. - Threads that go up and never come down — expected. The Go runtime does not aggressively reap idle Ms, so a burst leaves a permanently higher floor. That last point is why the metric is worth graphing at all: thread count behaves like a high-water mark, and the peak is what you pay for in memory. ## Why it costs anything Each OS thread carries kernel bookkeeping plus a system stack. In a pure-Go program the runtime allocates that stack itself and it is small. When C code is involved, threads are created through the platform's own thread API and get the platform's default stack reservation — commonly 8 MB of virtual address space per thread on Linux — so a thousand threads is gigabytes of address space and a resident-memory cost that grows as those stacks are actually touched. A thread count that has quietly reached four figures is therefore a memory story as much as a scheduling one. ## What to do with the numbers Export both, on the same dashboard, next to resident memory. Alert on the ratio and the trend rather than an absolute threshold, because the healthy absolute value depends on how much blocking work the service does. When the thread line moves and the goroutine line does not, go looking for blocking calls, not for a goroutine leak.

  • You have both numbers on a dashboard. What pattern actually worries you?
    A rising OS thread count with a flat goroutine count. That means the workload is not growing but the runtime keeps needing fresh threads, so existing ones are stuck in something that holds them — a long blocking system call or a call into C. Rising goroutines with flat threads is a different problem entirely: goroutines accumulating, not threads.
  • Why is the /debug/pprof/threadcreate total not a live thread count?
    That profile records a sample each time the runtime creates a thread, keyed by the stack that led to the creation. Its total is therefore a count of creation events over the life of the process, with attribution. It never decreases when threads go idle, and it is not decremented, so use it to answer "what is making threads", not "how many exist now".
  • Does the thread count come back down after a burst of blocking work ends?
    Essentially no. The Go runtime does not aggressively destroy idle Ms, so the count behaves like a high-water mark and a spike becomes the new floor for the life of the process. That is why the peak, not the average, is what you budget memory for, and why a short incident can leave a permanently fatter process.

Goroutines are the passengers, OS threads are the buses. Counting passengers tells you nothing about how many buses the depot has had to put on the road.

saying these in an interview costs you the question

  • Says runtime.NumGoroutine returns the OS thread count
  • Assumes one goroutine maps to one OS thread
  • Believes thread count can never exceed GOMAXPROCS
  • Reads a live thread count from the threadcreate total
  • Expects idle threads to be reaped back down automatically
open as a page

What does runtime/debug.SetMaxThreads do, and what happens when a Go program hits that thread limit?

level: middleimportance: should knowfreq 30%

basics

~20 s

debug.SetMaxThreads caps how many OS threads the Go runtime may create and returns the previous ceiling; the initial one is 10,000. Crossing it is not a panic: the process dies with a fatal thread-exhaustion error that recover cannot catch.

open as a page

A Go transcoder's OS thread count climbs past a thousand while its goroutine count stays flat — how do you diagnose that?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A flat goroutine count with rising OS threads means goroutines are stuck in long blocking calls into C that hold their thread. Confirm with the threadcreate and goroutine profiles, then bound how many such calls run at once.

open as a page

What does Go's /debug/pprof/threadcreate profile record, and how do you use it to find what creates threads?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

Go's threadcreate profile records the stack that led to each new OS thread, aggregated by call site, so it shows which code path keeps producing threads. It counts creation events, not live threads, and is known to under-report.

open as a page

Your Go service needs a blocking C codec — how do you decide between keeping it in-process with a thread cap and moving it out?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Decide from measured numbers: concurrent calls times call duration is the OS thread demand, and each thread costs a stack. Keep the codec in-process only behind an owned concurrency bound; move it out when the memory or blast radius is unacceptable.

open as a page