← Back to Writings

Why a goroutine switch is so cheap? The mechanism behind it

GOGoroutinesConcurrencyOS InternalsMemory Management
9 min read11 Oct '26
Goroutine switch
Goroutine switch

The main idea is that switching between goroutines is cheap because Go usually doesn't need to save and restore every CPU register when switching from one goroutine to another.

Before any deeper explanation some tl;dr about CPU registers(for those of us who forgot our OS classes)

i just found a better analogy to understand the relationship better. computer's main memory (RAM) is like a massive warehouse shelves down the street. SSD is a cargo ship parked at the port, registers are the workbench directly in front of the CPU worker's hands.

a CPU register is the smallest, fastest storage location inside a computer's processor. They temporarily hold values, memory addresses, and execution information.

Important types of registers

General purpose registers(GPR) Hold numbers, temporary values, and sometimes addresses used during calculations.

Program Counter(PC) Holds the address of the instruction the CPU should execute next (conceptually)

Stack Pointer(SP) Holds the address marking the current top of the stack, which stores things such as function-call information and local variables.


Calling convention

Imagine you have two goroutines running on the same OS thread.

go
func worker() {    fmt.Println("Before blocking")     ch := make(chan int)    <-ch // Blocks because nobody sends a value     fmt.Println("After blocking")}

when worker is blocked on the channel, GO's runtime can pause it and run another goroutine on the same OS thread.

conceptually GO's runtime must save worker goroutine's execution state before switching to another goroutine right? so what exactly GO saves to resume `worker` later?

First let us understand what a function call does to CPU registers. a function might use them to hold pointers, integers, or intermediate results. When a function calls another function in a program, the CPU needs a standardized contract known as a calling convention so both functions agree on how registers and memory are used without overwriting each other's data.

Because a CPU has a very limited set of general-purpose registers, the calling function (caller) and the called function (callee) are likely going to use the exact same registers. To prevent the callee from accidentally destroying data that the caller still needs, calling conventions divide registers into two rulesets:

  1. caller-saved registers(volatile/scratch registers)
    1. If the caller has important data sitting in these registers right before making a function call, it is the caller's responsibility to back them up (usually by pushing them onto the stack) before calling the function.
    2. The called function is completely free to trash, modify, and overwrite these registers however it wants without restoring them. When the function returns, the caller assumes those register contents are destroyed
  2. callee-saved registers(non-volatile/preserved registers)
    1. If the callee (the function being called) wants to use one of these registers for its own work, it must save the original value (e.g push it to the stack) at the very beginning of its execution, and then restore that exact value right before returning to the caller.
    2. The caller can safely rely on these registers remaining completely unchanged across a function call.

Caller-saved registers are ideal for short-lived data, especially in leaf functions that don’t call other functions. They act as scratch space for temporary calculations without needing to save and restore their values.

Callee-saved registers are useful when a function needs to keep values alive across multiple function calls, such as a loop counter or a pointer. The function saves and restores these registers only if it uses them, avoiding the need for the caller to save them around every call.

What does GO choose? Under Go’s official internal ABI specification, Go does not use callee-saved registers.

refer - https://go.dev/src/cmd/compile/abi-internal
There are no callee-save registers, so a call may overwrite any register that doesn’t have a fixed meaning, including argument registers.

Instead, Go treats virtually all general-purpose registers as caller-saved (also known as call-clobbered or volatile) This intentional design drastically simplifies stack tracing for the Garbage Collector (GC), eases dynamic stack growth, and streamlines stack unwinding during panic recovery. If Go utilized callee-saved registers, the GC would be forced to continuously scan spilled register areas in the stack frames to locate active memory pointers.

Now we move to the title

I think we have enough context to understand the rest of the content now

In C or C++, when a thread yields or switches, the OS kernel steps in, pauses the thread, and explicitly saves all CPU registers (both caller-saved and callee-saved) into a kernel data structure called a Thread Control Block (TCB). Because Go's ABI treats most registers as caller-saved (scratch registers), the GO runtime doesn't need to automatically save all register values during a goroutine context switch. The runtime assumes that if a goroutine was in the middle of a computation using caller-saved registers, those values are already safely stored away or dead.

The Catch: Garbage Collection and Stack Growth

Even though caller-saved registers don't need to be preserved across normal function calls, the Go runtime cannot completely ignore them during certain critical events, specifically:

  • garbage collection: when a GC safepoint is triggered, the runtime needs to scan all memory pointers to see what objects are still active. If a pointer to a heap-allocated object is sitting temporarily inside a register right at that exact millisecond, the GC must know about it. If it ignores the register, it might think the object has no references and accidentally free it out from under the running goroutine!
  • if a goroutine needs to grow its stack,the runtime needs a complete map of the goroutine's active state.

Go doesn't just yank execution away from a CPU core arbitrarily whenever it feels like it. goroutine switch happens at a controlled point in the runtime, where Go can use the architecture's calling convention and compiler-generated code to manage the required state. that controlled point is what brings us the concept of safepoints in GO

Safepoints

Go is a garbage-collected language with moving/growable stacks. For the GC to safely sweep memory without accidentally deleting a live object, it must answer one absolute question at any pause: "Where are all the active pointers right now?"

If a pointer to a heap object is sitting inside a CPU register or on the stack, and the GC misses it, the GC will treat that object as dead garbage and free it. This leads to immediate memory corruption or crashes.

However, inspecting arbitrary machine code instructions at a random microsecond is nearly impossible because machine code is dynamic and unstructured. Safepoints solve this by establishing a contract between the compiler and the runtime.

When the Go compiler translates your source code into machine code, it doesn't just emit instructions; it also generates metadata tables embedded directly into the binary (known as stack maps and live register maps). For every single function, the compiler creates a map that says:

"If execution stops at PC (Program Counter) offset 0x48a2, then variable x is located at stack offset -16, variable y is dead, and register RAX holds a pointer to a heap object."

A safepoint is any instruction offset where this metadata is guaranteed to be valid and available.

umm.. if it felt a bit confusing the gist is the compiler has predicted the state of the program beforehand. it isn't predicting what values your variables will hold (it doesn't know if x is going to be 5 or 10,000 at runtime) It predicts where those variables and pointers live at every single instruction offset.

  • The GC reads this, marks those memory objects as alive, and safely ignores them.
  • the runtime waits for the goroutine to reach a safepoint. At a safepoint, the goroutine's state is clean, predictable, and fully mapped out. The runtime can instantly copy the stack pointer (SP) and program counter (PC) into the goroutine's gobuf structure, park it aside, and load a completely different goroutine onto the CPU core.

In short: Go avoids the overhead of saving registers on every single function call, but it relies on compiler metadata and strict safepoints so the runtime always knows where live data is hiding when it needs to pause a goroutine.

gobuf

Go's runtime uses a structure called gobuf to store the minimal execution context needed for a goroutine switch.

Conceptually, it contains fields like these:

go
type gobuf struct {    sp  uintptr // Stack pointer    pc  uintptr // Program counter    bp  uintptr // Base pointer, where applicable    // Other fields may also be present}

the structure has additional fields and exact details depend on the GO version and architecture(amd64, arm64,...) Go saves the necessary context for one goroutine and restore another goroutine's context here without performing a full OS-thread context switch.

gobuf is not the entire goroutine state. The goroutine's stack, scheduler metadata, and other runtime state remain in their respective structures.

so why is this cheaper than switching OS threads?

atp, you might have guessed, the entire switching happens at the user-space, there is no kernel trapping.

Goroutine switch

  • Runtime saves and restores the necessary execution context.
  • No kernel entry is required just to switch goroutines.

OS-thread context switch

  • The OS scheduler participates in switching threads.
  • Kernel scheduling and thread-state management add overhead.

which is why a goroutine switch lands at around 100-200ns versus microseconds for an OS thread.


(Optional) asynchronous preemption

lol i read about this as well, so putting em here

If the runtime could only interrupt goroutines at cooperative safe points, a goroutine that never reached a safepoint? It could monopolize its OS thread for too long.

go
func main() {    go func() {        for {            // Infinite CPU-bound loop        }    }()     time.Sleep(time.Second)    fmt.Println("Hello!")}

The first goroutine runs an infinite loop. Without asynchronous preemption, a goroutine like this could keep running for a long time without reaching a point where the runtime can easily take control.

That could delay other goroutines that need CPU time.

Read about this if you have time after this, you'll love it.

For more such interesting concepts about GO and rabbitholes follow @kartikktwt

peace 🕊️

Continue reading

All writings