May 2, 20264 min

How Many Threads Does Go Really Use? A 2026 Runtime Investigation

A practical 2026 investigation into how many OS threads the Go runtime keeps alive with GOMAXPROCS=1, using schedtrace, scheddetail, GC settings, and blocking syscalls.

There is a common rumor that the Go runtime pre-allocates a handful of operating system threads before your program does any real work.

I wanted a more concrete answer: with GOMAXPROCS=1, no user goroutines, and a tiny busy loop, how many threads are actually alive?

The short answer: in this experiment, the minimum was three OS threads.

The more useful answer is why those threads exist.

Test Program

Keep the program intentionally boring:

package main

import "runtime"

func main() {
    runtime.GOMAXPROCS(1) // keep a single P

    var values []int
    for i := 0; i < 10_000_000_000; i++ {
        values = append(values, i)
    }
}

There are no custom goroutines and no explicit concurrency. The loop simply keeps the process busy long enough to observe the scheduler.

First Look with schedtrace

Build and run with scheduler tracing enabled:

go build -o threads
GOMAXPROCS=1 GODEBUG=schedtrace=1000 ./threads

The runtime prints a scheduler snapshot every 1000 ms.

Even with a single processor (P), the trace shows more than one thread (M). In this setup, three OS threads appear.

That is fewer than the rumored five, but still more than a naive "single-threaded" mental model.

More Detail with scheddetail

Enable detailed scheduler output:

GOMAXPROCS=1 GODEBUG="schedtrace=1000,scheddetail=1" ./threads

Now the output shows one processor, usually P0, and several runtime goroutines related to garbage collection and background maintenance.

You can see entries for helpers such as the sweeper, scavenger, and force-GC goroutine. They are runtime service goroutines, not user work.

The important point: even when you write no goroutines yourself, the runtime still has internal work to manage.

What Happens If GC Is Disabled?

Turn off automatic garbage collection:

GOGC=off GOMAXPROCS=1 GODEBUG="schedtrace=1000,scheddetail=1" ./threads

The thread count stays at three.

Disabling automatic GC changes when collection happens, but the runtime helper goroutines still exist. They may be parked, but they are part of the runtime's structure.

Where Do the Extra Threads Come From?

Reading the runtime code points to a few sources:

  • the system monitor thread, often called sysmon,
  • the main runtime thread attached to the active processor,
  • a spare thread that lets the runtime keep execution moving when handoffs or blocking operations happen.

In Go scheduler terms, goroutines (G) run on logical processors (P), and processors are executed by OS threads (M).

With GOMAXPROCS=1, there is one active P, but that does not mean the whole process owns exactly one OS thread.

Testing a Runtime Hypothesis

A local experimental build of the runtime with a startup lock removed showed only two threads.

Do not do this in production. The point of the experiment is not to patch Go, but to confirm the source of the extra thread.

That result supports the idea that the runtime starts from a small baseline and keeps a spare M available for coordination.

Blocking Syscalls Make the Spare Useful

Add a goroutine that blocks in a syscall:

go func() {
    unix.Read(unix.Stdin, make([]byte, 1)) // blocks on stdin
}()

After launch, the process still has a small number of threads, but the roles become easier to understand:

  • one thread can be blocked in read,
  • another can continue driving P0,
  • another can act as the system monitor.

This is why the spare matters. If a goroutine blocks in the kernel, Go still needs a way to keep runnable work moving.

Key Findings

With GOMAXPROCS=1, this experiment showed three OS threads.

Each additional active P can require its own M, usually created lazily.

GC helper goroutines exist even when automatic GC is disabled. They can sleep until needed.

The runtime does not eagerly create a pile of unnecessary workers. It simply keeps the threads it has already launched when they are useful for latency and coordination.

Why It Matters

For normal applications, two or three base threads are not a problem. The extra stack memory is usually tiny compared with the reliability and latency benefits.

But in tight containers, tiny embedded boards, or very high-density process deployments, this baseline matters. "One Go process" does not always mean "one OS thread", even when GOMAXPROCS=1.

That is the practical takeaway for 2026: Go's scheduler is efficient, but it is still a runtime with internal services, background coordination, and a small crew of OS threads keeping the process responsive.