skip to content

Why must Go's runtime rewrite pointers when it moves a goroutine's stack?

level: middleimportance: should knowfreq 38%

answer

  1. the copy lands at a different address
  2. the compiler already told the runtime what is a pointer
  3. nothing on the heap points into a stack
  4. one of unsafe.Pointer and uintptr gets updated

basics

~20 s

Growing a goroutine's stack copies it to a different address, so every pointer that referred to the old stack would dangle. The runtime uses compiler-generated pointer maps to find those words and add the address delta to each.

solid answer

~50 s

Go grows a stack by allocating a bigger one and copying, so the whole live region lands at a new address. Any pointer to a local variable is now pointing at freed memory unless the runtime rewrites it. It can do that because the compiler emits precise pointer maps: for every frame at every call site it records which slots hold pointers, so the runtime walks the frames and adds the delta to each pointer word, including those in defer records. Escape analysis helps too — anything whose address outlives the frame is heap-allocated, so no heap object points into a stack and the fixups stay confined to the stacks themselves. The practical consequence: an `unsafe.Pointer` to a local gets updated, but the same address stored in a `uintptr` does not, so it silently goes stale.

code

go · 6 lines
go
var x int
p := unsafe.Pointer(&x) // a pointer: rewritten if the stack is copied
u := uintptr(p)         // an integer: never updated, may go stale
recurseDeeply()         // any call here may grow and move the stack
_ = p
_ = u

go deeper

for a junior

Recall the one-line consequence: growing a goroutine's stack copies it somewhere else, so the runtime has to correct pointers into it, and an address you saved as a plain integer will be wrong afterwards.

for a middle

Explain the mechanism: the compiler emits precise pointer maps per frame, and the runtime walks the frames adding the address delta to every pointer slot, including those in deferred-call records.

for a senior

Connect it to design constraints you actually enforce in review: unsafe pointer arithmetic must stay within one expression, integer copies of addresses are never valid across a call, and escape analysis is what keeps the fixup set bounded.

for a principal

Own the tradeoff: precise stack metadata is the price of cheap goroutines, and it constrains what foreign or unsafe code may hold. Decide deliberately where your codebase permits address arithmetic at all.

## Moving memory that the program is standing on When a goroutine's stack runs out of room, Go does not extend it in place — it allocates a new, larger block and **copies the used region into it**. That means every byte of live data changes address. If a local variable `x` sat at address `0x...c020` and the stack moved by some delta, `&x` is now a pointer into memory that has just been freed. Any pointer that the running program holds to that stack must be corrected, or the goroutine resumes with dangling references. So stack growth is not just an allocation and a `memmove`; it is a **relocation**, and relocation requires knowing exactly which machine words are pointers. ## Precise pointer maps make it possible The Go compiler emits, alongside the code, metadata describing the layout of each function's frame: which slots hold pointers and which hold plain integers, valid at each point where the function can be suspended (that is, at its call sites and preemption points). The runtime walks the goroutine's frames from the top down, consults that metadata for each frame, and for each slot marked as a pointer adds the delta between the old and new stack base. This is the same precision the garbage collector needs to scan stacks accurately — Go's collector does not guess whether a word is a pointer. The two capabilities come from the same metadata, which is why "Go has precise stack maps" and "Go can move stacks" are really one fact. The fixups are not limited to local variables. The runtime also adjusts: - **spilled arguments and return slots** in each frame, - **pointers inside deferred-call records** attached to the goroutine, including the receiver and arguments captured when the `defer` statement ran, - **runtime bookkeeping** that references stack addresses, such as the panic chain. ## Why the problem stays bounded A general moving collector must also find every pointer *from elsewhere* into the region it moves. Go escapes that obligation for stacks: **escape analysis guarantees that no heap object points into a goroutine's stack.** If the compiler cannot prove that a value's lifetime ends with the frame — because its address is stored into a heap object, returned, captured by an escaping closure, or passed somewhere opaque — the value is heap-allocated instead. So the only references into a stack come from that stack itself (and from runtime structures the runtime already knows about), which is exactly what the frame walk covers. That is also why goroutine stacks can move even though the Go heap is **non-moving**: the heap has arbitrary unknown references into it, a stack does not. ## `unsafe.Pointer` versus `uintptr` The distinction that trips people up is a direct consequence. `unsafe.Pointer` is a *pointer type*: the compiler records it in the pointer maps, so when the stack moves, a local `unsafe.Pointer` holding a stack address is rewritten like any other pointer. A `uintptr` is *just an integer*. It is invisible to the pointer maps, nothing updates it, and it does not keep anything alive either. So this is broken: ```go var x int u := uintptr(unsafe.Pointer(&x)) // an integer snapshot of an address deep(0) // may grow and move the stack // u now names an address that no longer holds x ``` This is the reason the `unsafe` package's documented rules require pointer arithmetic to happen in **one expression**: `unsafe.Pointer(uintptr(unsafe.Pointer(&s[0])) + offset)`. Splitting that across statements creates a window in which the value is a bare integer and the stack may move underneath it. ## Things you cannot do because stacks move - You cannot cache the address of a local in an integer field and expect it to stay valid. - You cannot assume two `&local` values printed at different times will be equal, because a growth between them changes both. - Code that hands the address of a stack variable to something the runtime cannot see and fix up must first make that value live somewhere stable, which in practice means the heap. ## What to say in an interview Growth copies, copying relocates, relocation needs to know what is a pointer, and the compiler's precise stack maps supply that. `unsafe.Pointer` participates; `uintptr` does not. That chain of four sentences is the whole answer, and each link is a fact a middle-level Go engineer is expected to have.

  • Why can Go move goroutine stacks when its garbage collector never moves heap objects?
    Because the set of references into a stack is knowable. Escape analysis guarantees no heap object holds a pointer into a stack, so the only references come from that stack's own frames and runtime records, all of which the runtime can walk with the compiler's pointer maps. Heap objects can be referenced from anywhere, so relocating them would require finding every such reference.
  • What does this imply for storing an address in a uintptr across a function call?
    It is unsafe. A `uintptr` is an integer, invisible to the pointer maps, so it is neither updated when the stack is copied nor treated as keeping anything alive. The `unsafe` package's rules exist for this reason: convert to `uintptr` and back within a single expression, never across statements or calls.
  • Which structures besides ordinary local variables need adjusting when a stack is copied?
    Spilled arguments and result slots in every frame, and the goroutine's deferred-call records, which hold the receiver and the arguments captured when the `defer` statement executed. Runtime state that references stack addresses, such as the active panic chain, is adjusted too.

saying these in an interview costs you the question

  • Says goroutine stacks never move, only heap objects do
  • Treats uintptr and unsafe.Pointer as interchangeable
  • Claims the runtime scans stacks conservatively so no fixups are needed
  • Thinks the addresses stay valid because virtual memory is remapped
  • Says heap objects commonly hold pointers into goroutine stacks