Skip to content
gohacks.dev
Concurrency

Why a Goroutine Can Move Between OS Threads in Go Code

Watch one goroutine run on several OS threads, learn how the G, M and P model allows it, and see what breaks when code assumes thread affinity.

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

Every language with threads has a rule that looks like "this thread did this, so the state lives in this thread". Go breaks it on purpose. A goroutine has no thread of its own. The runtime hands goroutines to threads when they have work to do, takes them back when they block, and gives them to whichever thread is free the next time they can run. Most of the time you never notice. When a program does notice, it is usually because of a C library, an operating system feature or a GUI toolkit that assumed the same thread would be there on the next call.

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

This article shows the move happening, explains the model that allows it, and lists the places where it bites. It is background for the scheduler, LockOSThread, blocking syscalls and cgo calls, and it belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.

The G, M and P model

The runtime's scheduler has three kinds of object, usually abbreviated G, M and P.

  • A G is a goroutine: its stack, its program counter and its state.
  • An M is a machine: an OS thread that the kernel schedules.
  • A P is a processor: a token that a thread must hold to run Go code. It carries a local queue of runnable goroutines and a cache for memory allocation. There are exactly GOMAXPROCS of them, which defaults to the number of CPUs.

An M takes a P, picks a runnable G from the queue, and runs it until it finishes, blocks or is preempted. If the G blocks on a channel or a lock or a timer, the M leaves it parked and picks another G, and the blocked goroutine goes back to a queue once the thing it waited for happens. Nothing in that sequence says it will be the same M. The G that was woken may sit on any P's queue, and whichever M holds that P next runs it. A goroutine that blocks ten times can run its eleven slices on eleven different threads. The full mechanics, including work stealing between Ps, are in the scheduler explained.

Two consequences follow from this design, and both are intended. Goroutines are cheap, because creating one does not create a thread. And a program with a hundred thousand mostly idle goroutines needs only a handful of threads.

Threads versus goroutines

Create ten thousand goroutines that all sleep at once, and count how many OS threads the runtime has created. The threadcreate profile counts them:

main.goGo
package main

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

func threads() int { return pprof.Lookup("threadcreate").Count() }

func main() {
	fmt.Println("CPUs:", runtime.NumCPU(), "GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("threads at start:", threads())

	var wg sync.WaitGroup
	for range 10000 {
		wg.Go(func() {
			time.Sleep(200 * time.Millisecond)
		})
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println("goroutines:", runtime.NumGoroutine(), "threads:", threads())
	wg.Wait()
}
Text
CPUs: 10 GOMAXPROCS: 10
threads at start: 5
goroutines: 10001 threads: 12

Ten thousand goroutines, alive at the same moment, and a dozen threads. The sleeping goroutines are not holding any: a timer wakes them and the runtime runs them on whichever thread is free. A thread-per-task design would have asked the kernel for ten thousand stacks. This is also the reason Go programs can hold hundreds of thousands of concurrent connections.

Seeing one goroutine change threads

To observe a move you need the thread's identity, which the Go runtime deliberately does not expose. A small cgo helper can ask the C library: pthread_self returns the calling thread's ID. This program has one goroutine sleep for a millisecond twenty times, ask for its thread each time it wakes, and count how many different threads it saw:

main.goGo
package main

/*
#include <pthread.h>
static unsigned long thread_id(void) { return (unsigned long)pthread_self(); }
*/
import "C"

import (
	"fmt"
	"time"
)

func main() {
	seen := map[uint64]int{}
	done := make(chan struct{})
	go func() {
		defer close(done)
		for range 40 {
			seen[uint64(C.thread_id())]++
			time.Sleep(time.Millisecond) // park the goroutine, giving up its thread
		}
	}()
	<-done
	fmt.Println("wakeups:", 40, "distinct OS threads this goroutine ran on:", len(seen))
}
Text
wakeups: 40 distinct OS threads this goroutine ran on: 2

One goroutine, a loop that never changes, and two threads in this run. Each time.Sleep parks the goroutine; the timer makes it runnable again; and the next free M picks it up. The count varies from run to run, since it depends on which threads are idle at each wakeup, and on a busier machine it climbs. It is never guaranteed to be one. The program's behavior is identical either way, which is the point: the language semantics do not depend on the thread.

What does not exist because of this

Several things programmers expect from other languages are missing from Go, and the thread mobility is part of the reason.

No goroutine-local storage. Python has threading.local, Java has ThreadLocal, and both rely on a stable thread. A goroutine has no identity the runtime lets you read, and the standard library's answer to "per-request state" is explicit: pass a context.Context (context value). The Go FAQ and many proposals explain that goroutine IDs are withheld on purpose, to keep programs from growing a hidden dependency on them.

No thread-safe-because-single-threaded assumption. Code that is correct only if it runs on one particular thread, such as a C library's global non-thread-safe handle used by "the main thread", is not safe from arbitrary goroutines. The main goroutine does not stay on the process's first thread either, unless you ask.

Nothing to configure per goroutine. You cannot set a nice value, an affinity mask or a thread name for a goroutine, because there is no thread that is guaranteed to be its own. Those settings are per thread, and they affect whichever goroutine happens to be running there.

When thread identity matters

The set of cases is small and specific. It is the same set in every garbage-collected, M:N language runtime.

  • Thread-local storage in a C library. OpenGL contexts, some database drivers, and certain crypto or audio libraries keep state in thread-local variables or require that all calls come from the thread that initialized them. After a goroutine moves, a call lands on a thread that has no context.
  • Linux namespaces and per-thread credentials. setns and some setuid variants apply to the calling thread only. Run in a goroutine that migrates and the change is applied to one thread while the next call runs on another. The Go runtime itself handles setuid for the whole process since Go 1.16, but per-thread operations remain per thread.
  • GUI toolkits and platform APIs. Many require UI calls on the main thread (macOS AppKit is the best-known), so the usual Go solution is to start the event loop on the main goroutine, locked to the main thread.
  • Signal masks and thread names for profiling. Per-thread by nature.

The remedy is runtime.LockOSThread, which wires the calling goroutine to its current thread until it exits or unlocks. That has costs and rules of its own, and the next article, LockOSThread and the rare cases that need it, measures them.

Observing the model yourself

Two environment variables make the scheduler visible without any code. GODEBUG=schedtrace=1000 prints one line per second: the number of Ps, idle Ps, threads, spinning threads, the global run queue length and each P's local queue. GODEBUG=scheddetail=1,schedtrace=1000 adds per-M and per-G detail. They are the quickest way to confirm that a program is not creating a thread per task, and to see runnable queues build up when it is CPU-bound. The diagnostics documentation and the runtime package documentation describe the variables, and the HACKING file in the runtime source defines the terms used here.

The practical summary: in Go code that stays in Go, thread identity is an implementation detail you do not need and cannot read. The moment you cross into code that cares, through cgo, a syscall with thread scope, or a platform API, you take on the job of telling the runtime to keep the goroutine where it is.

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.