Skip to content
insomnius.
Part 1 of 1 — under the hood

Under the Hood: Go Architecture & Concurrency


The Go Performance Engine Cover

There's a moment most Go developers go through when "it just works" stops being enough. Maybe a service is allocating more than it should, or a benchmark is mysteriously slow on production hardware but fast on your laptop. Or maybe you just got curious about why people say goroutines are cheap.

This article is the walkthrough I wish I'd had at that point. We'll start from the CPU — how it actually runs your code — and work upward through Go's scheduler, its memory model, the GC, and a few of the smaller-but-sharp things that hide under the syntax (escape analysis, false sharing, stack growth). Every Go example here compiles and runs, and the benchmark numbers come from my own machine — you can copy them into a file and confirm.

If you want to skim, every section ends with a Rule of Thumb. Read those first if you only have a few minutes; come back for the why later.


0. How the CPU Works

Almost every Go performance trick in this article — escape analysis keeping data off the heap, struct field ordering shrinking memory by 30%, false sharing turning a 1× workload into a 3× one — is the language working with the CPU's hardware, not against it. None of those tricks will quite click without a working mental model of what the CPU does between fetching an instruction and writing a result.

So before any Go-specific story, five components and a four-stage cycle. That's the whole foundation.

Core Components

A modern CPU die contains several key components working together:

  • Control Unit (CU): The brain's conductor. It reads instructions from memory, decodes them, and sends control signals to coordinate all other components. It holds the Program Counter (PC), which tracks the address of the next instruction to execute.
  • ALU (Arithmetic Logic Unit): The workhorse. It performs all mathematical operations (ADD, SUB, MUL) and logical comparisons (AND, OR, CMP). It outputs both a result and status flags (e.g., zero, overflow).
  • Registers: Ultra-fast storage slots located directly inside the CPU core. Access time is roughly 0.3 ns — one clock cycle at 3 GHz. On x86-64, common registers include RAX, RBX, RCX, RSP (stack pointer), RBP (base pointer), and RIP (instruction pointer / PC).
  • Cache Hierarchy (L1/L2/L3): A layered memory system that bridges the enormous speed gap between registers (~0.3ns) and main RAM (~100ns). L1 is the smallest and fastest (~32KB, ~1ns), L2 is larger (~256KB, ~4ns), and L3 is shared across cores (~8MB, ~12ns). Go's performance story is deeply tied to cache efficiency.
  • System Bus: The highway connecting the CPU to RAM, carrying data, memory addresses, and control signals.

The CPU Cycle: Fetch → Decode → Execute → Store

Every single instruction your Go program runs goes through this four-stage pipeline:

  1. FETCH: The CU reads the next instruction from RAM (via the cache hierarchy and system bus), guided by the Program Counter.
  2. DECODE: The CU interprets the binary instruction — what operation to perform, which registers or memory locations are involved.
  3. EXECUTE: The ALU performs the operation. For example, ADD RAX, RBX computes the sum of two registers.
  4. STORE: The result is written back to a register or to memory through the cache hierarchy.

This cycle repeats billions of times per second on a modern CPU. Understanding this pipeline is essential because Go's runtime is carefully designed to minimize wasted cycles — goroutine context switches save only a handful of registers, struct padding respects cache line boundaries, and escape analysis keeps data in registers or on the stack to avoid the slow trip to RAM.

CPU Clock
CPU Die
Control Unit (CU)

Fetches & decodes instructions. Orchestrates data flow via control signals. Contains the Program Counter (PC) and Instruction Register (IR).

ALU

Arithmetic & Logic Unit. Performs all math (ADD, SUB, MUL) and logical operations (AND, OR, CMP). Outputs result + status flags.

Registers
RAX
RBX
RCX
RSP
RBP
PC
Cache Hierarchy
L1
32KB
~1ns
L2
256KB
~4ns
L3
8MB
~12ns
◄── System Bus (Data + Address + Control) ──►
Main Memory (RAM)
~100ns access
IDLE

Press Run Cycle to see the full Fetch → Decode → Execute → Store pipeline, or Step to advance one phase at a time.

The CPU is an Illusionist

Here's the key insight: the CPU is an illusionist. When you have a browser, a text editor, and Spotify all running "simultaneously" on a single-core machine, none of them are actually running at the same time. The CPU is performing an incredibly fast magic trick.

The Operating System's scheduler divides CPU time into tiny slices called time quanta (typically 5-10 milliseconds). It assigns each thread a slice, then forcibly interrupts it and switches to the next thread. This happens so fast — hundreds of times per second — that humans perceive all programs as running in parallel. But the CPU is actually doing one thing at a time, cycling between tasks like a juggler keeping multiple balls in the air.

Time-Slicing Simulation — Single CPU Core
CPU Core
⚙️idle
Switches
0
Switching...
Threads (waiting for CPU time)← each gets a time slice →
T1
WAITING
💾 Saving CPU state...
T2
WAITING
💾 Saving CPU state...
T3
WAITING
💾 Saving CPU state...
Timeline (what the CPU actually does)
T1
T2
T3
T1
T2
T3
T1
T2
T3
0ms0ms (simulated)

The Illusion: Your OS runs 3 programs "simultaneously" but a single core can only run one thread at a time. The kernel rapidly cycles between them (~5-10ms per slice), saving and restoring CPU state each time. Watch the timeline — it's actually sequential! The overhead of each switch costs ~1,000-5,000 nanoseconds (saving registers, flushing TLB, invalidating caches).

The Cost of the Illusion: Context Switching

Every time the OS switches from Thread A to Thread B, it must perform a context switch — and this is where the magic trick becomes expensive:

  1. Save all CPU registers — The OS saves Thread A's entire register state (RAX, RBX, RCX, RSP, RIP, floating-point registers, SIMD registers — over 1KB of data) into a kernel data structure called the Thread Control Block (TCB).
  2. Switch from User Mode to Kernel Mode — The CPU must trap into the kernel, which involves privilege level changes and security checks.
  3. Flush the TLB (Translation Lookaside Buffer) — The TLB caches virtual-to-physical address translations. A thread switch invalidates these entries, causing the new thread to suffer expensive page table walks until the TLB warms up again.
  4. Invalidate CPU caches — Thread A's hot data sitting in L1/L2 cache is likely irrelevant to Thread B. The new thread starts with cold caches, triggering expensive RAM fetches (~100ns each instead of ~1ns from L1).
  5. Restore Thread B's registers — Load Thread B's saved state from its TCB back into all CPU registers.

The total cost: ~1,000 to 5,000 nanoseconds per switch. On a 3GHz CPU, that's 3,000-15,000 wasted clock cycles — cycles that could have been doing real work. Multiply this by thousands of threads and you have a system spending more time switching between work than doing work.

Why this matters for Go: This is exactly the problem Go solves. Instead of relying on expensive OS thread context switches, Go runs its own user-space scheduler. A goroutine switch saves only the fields of the gobuf struct (sp, pc, g, ctxt, lr, bp) — roughly 48 bytes on 64-bit — and stays entirely in user space: no kernel trap, no TLB flush, no cache invalidation. The result: a goroutine switch lands in the low hundreds of nanoseconds, versus 1,000–5,000+ ns for a full OS thread switch. That's roughly a 5–20× improvement depending on workload, enough to make millions of concurrent goroutines viable where native threads choke at thousands.

See the gobuf struct and the actual switch in runtime/asm_amd64.s — the gogo routine only restores SP, CTXT, BP, PC, plus the g pointer.

Rule of Thumb

Treat memory as a pyramid: registers (~0.3 ns) → L1 (~1 ns) → L2 (~4 ns) → L3 (~12 ns) → RAM (~100 ns). Each step costs roughly 3–10× more than the last. Hot data that fits in L1 runs over 100× faster than the same data on a cold round trip to RAM, so design data layouts and loops to keep working sets small and contiguous — and minimize OS-level context switches, which torch the entire cache hierarchy at once.


1. The Execution Model: Goroutines vs. OS Threads

If you've written multithreaded code in Java or C++, you know the tax: spinning up a thread costs a system call, each thread eats a megabyte or two of stack, and switching between them is expensive in the way the previous section described — kernel trap, register save, TLB flush, the works. That's why most thread-based servers cap out around the low thousands of threads, even on beefy hardware.

Go skips that whole ceiling. A go func() lands a new goroutine on the runtime's local queue in nanoseconds, with a 2 KB stack that grows on demand. You can have a million of them on a single machine without breaking a sweat. The trick is that goroutines don't map 1:1 to OS threads — the Go runtime multiplexes thousands of them onto a small number of threads using a cooperative scheduler that lives entirely in user space.

The G-M-P Scheduler Architecture

Go maps thousands of Goroutines (G) onto a small number of OS threads (M) using the GMP Model:

  • G (Goroutine): Starts with a tiny, dynamically growable ~2KB stack.
  • M (Machine): A real OS thread managed by the kernel. 1 M runs only 1 G at a time.
  • P (Processor): A logical scheduling token that holds a local run queue of Goroutines. Only an M holding a P can execute Go code.
IDLE
›Scheduler idle. Click + go func() to spawn goroutines, then ▶ Run.
M1
OS thread, kernel-scheduled
binds
P1
logical processor — owns local runq
runs
(no G running on M1)
feeds
local runq P10G
empty
M2
OS thread, kernel-scheduled
binds
P2
logical processor — owns local runq
runs
(no G running on M2)
feeds
local runq P20G
empty
🌐 Netpoller — blocked on I/O0G
no parked goroutines
runnable
running
blocked (netpoll)
M — OS thread
P — logical processor
▶How close is this to real Go? (click to expand)

Mirrors the loop in runtime/proc.go: new goroutines land on the spawning P's local runq, each M runs one G at a time by going through schedule → findRunnable (local → netpoll → steal → park), and blocked I/O lands in the netpoller until woken. Time-slice, demote-to-tail, steal-from-tail, and M-parks-when-idle all match the real scheduler.

Simplifications for clarity:

  • No global run queue. Real Go has sched.runq, which findRunnable checks every ~61 scheduler ticks. Only the two per-P queues are shown here.
  • No runnext LIFO slot. Each P keeps a single-G LIFO cache for channel-wake locality. Omitted here — pure FIFO.
  • No handoffp on long syscalls. Real Go detaches P from an M blocked in a non-network syscall and hands it to a fresh M. The "I/O Block" button collapses everything into the netpoll path.
  • Woken netpoll Gs jump straight to RUNNING. In real Go they first land on the calling P's local runq via injectglist and get picked on the next iteration.
  • Spawn always targets P1. Real newproc puts the new G on the calling M's P. P1 stands in as the "calling" P here so work-stealing is observable.
  • No async preemption signals. Real Go 1.14+ can preempt mid-execution via SIGURG. The round-based time-slice here is a visual approximation.

Where Go Lives in the CPU

To understand why goroutine context switches are so cheap, you need to see where Go's scheduler sits within the CPU execution model. The key insight: goroutine switches never leave the P layer — they stay entirely in user space.

Where Go Lives in the CPU
CPU HardwareFetch → Decode → Execute → Store
OS Thread (M)Kernel-scheduled, ~1MB stack
Go Processor (P)User-space scheduler, local run queue
Goroutine (G)

~2KB stack, current function

G1
RUNNING
Run Queue
G2
G3
G4

Click Animate to see how a goroutine executes inside the CPU, layer by layer — and why Go's context switches are 100x cheaper.

If a Goroutine makes a blocking system call (like reading a file), the M blocks. The runtime's handoffp function then detaches the P from the blocked M, attaches it to a fresh M, and continues executing the remaining Goroutines. If instead a Goroutine blocks on a network call, it is parked by netpollblock in the Netpoller, freeing the M and P to execute other work immediately. Because this handoff happens entirely in user space, it costs hundreds of nanoseconds — not thousands — and saves only the handful of fields tracked in gobuf, compared to the full register set + kernel/TLB cost of a thread switch.

Rule of Thumb

Spawn goroutines freely. A go func() costs ~2 KB of stack and lands the new G on a P's local runq in nanoseconds — there's no design budget you need to manage. What's not free is what happens inside a goroutine: a long syscall stalls an M and forces handoffp to recruit a new one, an unbuffered channel blocks until a receiver shows up, and a goroutine that never exits is a leak. Profile for blocked or leaked goroutines, not for the cost of creating them.

📚 Source references


2. Memory Management: Stack vs. Heap

Start With the Surprise: Pointers Often Make Your Code Slower

Most developers coming from Java, Python, or Ruby reach for pointers reflexively when returning a struct from a function. The reasoning is intuitive: a pointer is 8 bytes; a struct is "all those fields" — surely smaller is faster?

In Go, that intuition is inverted for small values. The reason is a compiler pass called escape analysis — and once you've seen what it actually decides, the rule "return values, not pointers" stops feeling counter-intuitive.

Two functions that look identical at the call site:

package main

type User struct {
	Name string  // 16 bytes (string header)
	Age  int     // 8 bytes
}

// "Fast" — but the pointer forces u onto the heap.
func NewUserPointer(name string, age int) *User {
	u := User{Name: name, Age: age}
	return &u
}

// "Slow" — but u stays on the stack, often inside CPU registers.
func NewUserValue(name string, age int) User {
	u := User{Name: name, Age: age}
	return u
}

func main() {
	user1 := NewUserPointer("Alice", 30)
	user2 := NewUserValue("Bob", 25)
	_ = user1; _ = user2
}

Now ask the compiler what it actually did. The -l flag disables inlining so the escape decisions are easier to read on a tiny example like this one:

$ go build -gcflags="-m -l" main.go
./main.go:9:21:  leaking param: name
./main.go:10:2:  moved to heap: u
./main.go:15:19: leaking param: name to result ~r0 level=0

That moved to heap: u line is the cost. The pointer version forced u onto the heap, so every call now allocates and the GC has to track the result. The value version doesn't get a "moved to heap" line at all — u stays on the stack, and since Go 1.17 it often never touches memory at all: the fields ride caller → callee → caller through CPU registers.

(The leaking param: name lines describe the string argument, not the User struct. They're noise for our purposes.)

Escape Analysis: The Compiler's Lifetime Detective

Escape analysis is a compile-time pass that asks one question for every variable:

Does this value's lifetime escape the function that allocated it?

If the answer is no, the compiler emits a stack allocation: a single instruction that bumps the stack pointer. No GC, no fragmentation, no cache miss. When the function returns, the frame vanishes and the memory is "freed" for free.

If the answer is yes, the value must live on the heap, where the GC tracks it for as long as anything reaches it.

The pass lives in cmd/compile/internal/escape and runs after type-checking. It walks the function body, builds a graph of pointer relationships, and propagates "escapes" through assignments, returns, captures, and channel sends.

What actually makes a value escape?

Heap allocation is triggered in more places than most developers realize:

  1. Returning a pointer to a local. return &localVar — the variable must outlive the frame. This is the case from the code above.
  2. Storing a pointer to a local in a heap-allocated structure. cache[key] = &localVar or someSlice[i] = &localVar drag the local along with the container.
  3. Capturing in a closure that itself escapes. A closure stored in a struct field, returned from a function, or sent on a channel pulls its captured variables onto the heap.
  4. Sending a pointer or large value through a channel. The receiving goroutine may outlive the sender.
  5. Storing a value in an interface{} (or any). Interface values are a (type, pointer) pair; the concrete value is boxed onto the heap unless it fits a single-word optimization the compiler can prove.
  6. Calling fmt.Println(x) and friends. fmt takes ...any, which boxes every argument — a surprising source of allocations in hot paths.
  7. Slices that grow beyond a compile-time-known capacity. make([]T, 4) with no further append may stay on the stack; an unbounded append forces the backing array to the heap.
  8. Variables larger than the per-function stack budget (~64 KB on amd64). Big locals are heap-promoted automatically.

Inspecting your own code

go build -gcflags="-m" .          # one-line escape decisions
go build -gcflags="-m -m" .       # verbose — shows the reasoning chain

The output is noisy, but actionable. If a hot-path function reports moved to heap, you have an allocation you may not need. The fix is usually one of: return by value, accept by value, or stop pinning the local into a longer-lived container.

The Foundation: Why It Matters

The cost difference only makes sense once you ground it in the two memory regions the compiler picks between.

The Stack 📚

Fast, predictable memory tied to a function's lexical scope. When a function is called, a "stack frame" is pushed. When it returns, the frame is invalidated.

  • Allocation cost: A single CPU instruction (move the stack pointer).
  • Cleanup: Implicit on function return. The Garbage Collector never looks at it.
  • Cache locality: Tightly packed data almost always lives in the CPU's L1/L2 cache.

The Heap 🏗️

A global pool for values that must outlive their creator.

  • Allocation cost: A trip through the runtime allocator (mcache/mcentral/mheap) — orders of magnitude slower than a stack bump.
  • Access cost: Pointer indirection into RAM. If the cache line isn't hot, that's ~100 ns per miss.
  • Cleanup cost: GC has to scan everything reachable, mark it live, and sweep what isn't. The mutator pays in CPU share (~25% target) and occasional STW pauses.
  • Fragmentation: Over time, free space gets scattered; the allocator works harder to find contiguous blocks.
THE STACK — fast, lexically scoped
[ main() frame ]
userVal: User{Name, Age}
userPtr → heap
[ calculate() frame ]
tempResult: int
[ fmt.Println() frame ]
THE HEAP — global, GC-managed
[ fragmented gap ]
[ fragmented gap ]
User{Name, Age}
[ active object ]
[ active object ]
[ fragmented gap ]
pointer (8 bytes)
✓ pop on return. No GC, single-instruction allocation, hot in L1/L2 cache.
⚠ tracked by GC. Allocation costs a runtime trip; access risks a cache miss.

Stack frames vanish on function return — no GC needed. Heap objects must be tracked, scanned, and swept by the GC, and the pointer from userPtr is what keeps the User{Name, Age} reachable across function boundaries.

The Register-Based ABI Bonus

Since Go 1.17, the compiler uses a register-based ABI. On amd64, arguments and results flow through 9 integer registers (RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11) and 15 floating-point registers (X0–X14).

For small values that escape analysis keeps off the heap, this is doubly free: the value never touches main memory at all. Caller fills the registers, callee reads them, callee writes the result registers, caller reads them back. By contrast, the "cheap 8-byte pointer" alternative forces the CPU to dereference into RAM (a cache miss in the worst case) and forces the GC to track the allocation forever.

That's why the "expensive 64-byte stack copy" intuition is wrong: on the stack — or in registers — there is no copy in the C-sense. The CPU is just rewriting register state.

When Pointers Are Still the Right Call

The "return values" rule is for small values. Pointers earn their keep when:

  • The struct is large. Past a threshold (~80 bytes is a reasonable rule of thumb; the exact number depends on register availability), copying through the ABI spills to memory and the locality benefit collapses.
  • You need shared mutable state. Methods that modify their receiver need pointer receivers; a value receiver mutates a copy.
  • You need nil semantics. A pointer can be absent; a value cannot. Optional fields, lazily initialized caches, "not found" returns — all need pointers (or sentinels).
  • The type satisfies an interface with pointer-receiver methods. Interface dispatch boxes the concrete type; if the methods are on *T, you must hand the interface a *T.
  • You explicitly want the heap. Long-lived caches, shared state across goroutines, anything where stack-bound lifetime is wrong by design.

Rule of Thumb

For a small, immutable struct returned from a constructor: return the value. The compiler will move it to the heap only if it actually has to, and "the right thing" is usually faster than the pointer version that feels cheaper.

If you reach for *T, do it because the data is large, mutable, optional, or behind an interface — not because pointers feel like an optimization. They almost always aren't.

📚 Source references


3. Memory Layout, Alignment, and Padding

Start With the Puzzle

A struct with three fields totalling 10 bytes of actual data:

type BadStruct struct {
    A bool   // 1 byte
    B int64  // 8 bytes
    C bool   // 1 byte
}

How big is it in memory?

24 bytes. Reorder to B, A, C and it's 16 bytes — same fields, same data, 33% smaller. Where did the 14 missing bytes come from, and why does reordering give them back?

The answer is two CPU-imposed rules the Go compiler has to obey: data is fetched in 64-byte chunks called cache lines, and aligned types want to start at addresses that are multiples of their size. To make that work, the compiler injects invisible, unused bytes called padding to align the fields.

Working Code Example: Struct Padding

package main

import (
	"fmt"
	"unsafe"
)

// BadStruct takes 24 bytes of RAM.
// Layout: 1 byte (A) + 7 bytes (PADDING) + 8 bytes (B) + 1 byte (C) + 7 bytes (TRAILING PADDING)
type BadStruct struct {
	A bool   // 1 byte
	B int64  // 8 bytes
	C bool   // 1 byte
}

// GoodStruct takes 16 bytes of RAM.
// Layout: 8 bytes (B) + 1 byte (A) + 1 byte (C) + 6 bytes (TRAILING PADDING)
type GoodStruct struct {
	B int64  // 8 bytes
	A bool   // 1 byte
	C bool   // 1 byte
}

func main() {
	fmt.Printf("BadStruct size: %d bytes\n", unsafe.Sizeof(BadStruct{}))
	fmt.Printf("GoodStruct size: %d bytes\n", unsafe.Sizeof(GoodStruct{}))
}

Output:

BadStruct size: 24 bytes
GoodStruct size: 16 bytes

By simply reordering fields from largest to smallest, you minimize padding and drastically reduce the memory footprint of your application.

Extreme False Sharing Benchmark

Padding doesn't only save memory. When concurrent goroutines write to fields that live on the same 64-byte cache line, the cores end up fighting over that line — every write invalidates the other cores' caches, and they all stall. This is false sharing, and it's one of those bugs you'll never spot from reading the code.

Here's a benchmark you can drop into a falsesharing_test.go file and run yourself:

package falsesharing

import (
	"sync"
	"sync/atomic"
	"testing"
)

// NoPad packs three counters onto the same 64-byte cache line.
type NoPad struct {
	a, b, c uint64
}

// Pad pushes each counter onto its own cache line with 56 bytes of filler.
type Pad struct {
	a uint64
	_ [7]uint64
	b uint64
	_ [7]uint64
	c uint64
	_ [7]uint64
}

// 24 goroutines hammer the struct: 8 for a, 8 for b, 8 for c.
// Each ns/op is roughly the cost of one atomic increment under contention.
func BenchmarkNoPad_Extreme(b *testing.B) {
	var s NoPad
	per := b.N / 24
	if per == 0 { per = 1 }
	var wg sync.WaitGroup
	wg.Add(24)
	for i := 0; i < 8; i++ {
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.a, 1) } }()
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.b, 1) } }()
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.c, 1) } }()
	}
	wg.Wait()
}

func BenchmarkPad_Extreme(b *testing.B) {
	var s Pad
	per := b.N / 24
	if per == 0 { per = 1 }
	var wg sync.WaitGroup
	wg.Add(24)
	for i := 0; i < 8; i++ {
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.a, 1) } }()
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.b, 1) } }()
		go func() { defer wg.Done(); for j := 0; j < per; j++ { atomic.AddUint64(&s.c, 1) } }()
	}
	wg.Wait()
}

go test -bench=. -benchmem -benchtime=3s on my Intel i5-8350U:

goos: linux
goarch: amd64
cpu: Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz
BenchmarkNoPad_Extreme-8   	130770412	    27.77 ns/op	   0 B/op	   0 allocs/op
BenchmarkPad_Extreme-8     	372422640	     9.48 ns/op	   0 B/op	   0 allocs/op

On this CPU the padded version is roughly 3× faster — and re-running on the same machine wobbles the ratio between about 2.5× and 3×. Your numbers will land somewhere different: the gap depends on core count, cache topology, frequency scaling, and even background load. The point isn't the exact multiplier; it's that the speedup is real and substantial on any modern multi-core machine. The reason is the MESI protocol: when one core writes to a cache line, MESI invalidates that line in every other core's cache. Without padding, the cores spend their time bouncing the same 64-byte line back and forth instead of doing work.

Rule of Thumb

Order struct fields largest to smallest. The Go compiler will not reorder them for you (the language spec mandates declaration order so that unsafe.Sizeof/Offsetof stay stable), but reordering yourself can cut struct size by 30–50% with zero behavior change. For fields written from concurrent goroutines, pad them onto separate 64-byte cache lines — the MESI protocol punishes false sharing far more than the wasted bytes cost.

📚 Source references


4. Slice and Map Internals

Two of the data structures you reach for every day in Go — slices and maps — have surprisingly clever internals once you peek under them. Let's unpack what's actually happening when you append to a slice or read a key out of a map.

Slice Mechanics and Expansion

A slice isn't an array, even though it looks like one. It's a 24-byte header (on a 64-bit system) that describes a window into a hidden underlying array. The header has three fields:

  1. Pointer (8 bytes): Points to the first element in the backing array.
  2. Length (8 bytes): The number of elements currently accessible.
  3. Capacity (8 bytes): The total allocated space in the backing array.

Because slices are just 24-byte headers, passing a 1GB slice to a function performs exactly zero data copies of the underlying array.

When you append to a slice and exceed its capacity, Go dynamically allocates a new backing array, copies the data, and updates the header. The growth algorithm lives in growslice / nextslicecap, and since Go 1.18 it uses a threshold = 256 smoothing formula — not the old 1024 rule you still see quoted in older articles:

  1. If the requested length is greater than 2× the old capacity, the new capacity becomes the requested length directly.
  2. If the old capacity is < 256, capacity doubles.
  3. Otherwise, capacity grows iteratively using newcap += (newcap + 3*256) >> 2 — a smooth curve that starts near 2× for small slices and converges toward 1.25× for large ones, avoiding aggressive memory bloat.

The pre-1.18 rule (< 1024 doubles, >= 1024 grows 25%) was replaced in CL 347917 with the threshold = 256 smoothing approach to produce a more continuous curve.

Slice — header + backing array + growth
›Initial slice: len=3, cap=4. Click + append to fill the tail; watch what happens when len catches cap.
Slice header (24 bytes)
ptr0xc0001a0
len3
cap4
three 8-byte machine words
Backing arraycontiguous, heap-allocated
A
B
C
·
0
1
2
3
len = 3
1 unused
Growth historyeach grow allocates a new backing array and copies len elements
no grows yet — keep appending past cap
Real Go (1.18+, runtime/slice.go): if cap < 256 → double; else iteratively newcap += (newcap + 3*256) >> 2 (smooth ~1.25× curve for large slices). This sim follows the same rule — though to keep cells visible the demo only crosses the 256 threshold at very large append counts.

Map Expansion

Since Go 1.24, Go's map is implemented as a Swiss Table — an open-addressed hash table partitioned into fixed-size groups of 8 slots (abi.MapGroupSlots = 8). This replaces the older chained-bucket design that shipped in runtime/map.go for many years.

Current invariants:

  • Each group holds up to 8 slots. Collisions within a group are handled by in-group probing — there are no overflow chains anymore.
  • Growth triggers when used + tombstones > capacity × maxAvgGroupLoad / 8, where maxAvgGroupLoad = 7. In other words, the table grows once it crosses roughly 87.5% fullness.
  • Tombstones from deletes consume the growthLeft budget and are only swept during a grow.
  • Up to maxTableCapacity the table doubles; beyond that, it splits.

The classic load factor of 6.5 (loadFactorNum=13, loadFactorDen=2) and the same-size expansion triggered by too many overflow buckets both belong to the pre-Swiss-Tables implementation and no longer apply on Go 1.24+. The legacy constants still exist in runtime/map.go but only for tests.

Rule of Thumb

Pre-size your collections when you know the count: make([]T, 0, n) and make(map[K]V, n). Every missed presize is a future grow-and-copy at runtime, which costs CPU and (for slices) burns temporary memory while the old backing array waits for GC. For hot paths, allocating once at the right size beats appending into a small slice that doubles itself five times — the asymptotic O(n) is the same, but the constant factor and the allocation count are not.

📚 Source references


5. Low-Level Concurrency and Safety

Channels are great for coordination — sending a value or signalling a state change between goroutines. But for the cases where you just need to bump a counter or flip a flag, channels are massive overkill, and sync.Mutex is more weight than the operation deserves. That's where atomics and (carefully) unsafe come in.

Atomics and Hardware Locks

A contended sync.Mutex spins briefly and, if still blocked, parks the Goroutine through runtime.semacquire → gopark. Even though the goroutine switch itself is cheap, the full park-and-wake round trip lands in the hundreds of nanoseconds to low microseconds range — dominated by scheduler work and cache effects, not the register save. For simple counters, sync/atomic bypasses the scheduler entirely. It uses hardware instructions — see the amd64 assembly in internal/runtime/atomic/atomic_amd64.s, which contains LOCK XADDQ, LOCK CMPXCHGQ, and friends — to lock the cache line for a few clock cycles. This prevents multiple cores from modifying the same cache line simultaneously and typically runs 5–10x faster than a contended Mutex for hot counters.

The Dangers of unsafe Pointer Arithmetic

Go enforces strict type safety and memory boundaries. The unsafe package allows developers to step around these guarantees, offering C-like memory control.

A major trap is Invalid Pointer Conversion. In Go, a memory address is represented by uintptr (an integer). However, the Garbage Collector only tracks true unsafe.Pointer types. If you store a memory address in a uintptr variable, the GC assumes it is just a number. If a GC sweep occurs before you convert it back, the GC might reclaim the memory, causing catastrophic, non-deterministic program crashes.

Working Code Example: Safe Pointer Arithmetic

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	arr := [3]int{10, 20, 30}
	
	// Get a pointer to the first element
	p := unsafe.Pointer(&arr)
	
	// BAD (Invalid Pointer Conversion):
	// u := uintptr(p) 
	// If the GC runs right here, it might move or delete 'arr' because 'u' is just a number!
	// p = unsafe.Pointer(u + unsafe.Sizeof(arr)) 
	
	// GOOD: Pointer arithmetic in a single, atomic expression.
	// The GC knows 'p' is a pointer throughout the entire operation.
	pNext := unsafe.Pointer(uintptr(p) + unsafe.Sizeof(arr[0]))
	
	fmt.Printf("Second element via pointer math: %d\n", *(*int)(pNext))
}

Output:

Second element via pointer math: 20

Similarly, CGO introduces extreme complexity. Go's runtime cannot monitor memory managed by C. To maintain safety, Go enforces strict rules prohibiting Go code from passing memory containing Go pointers to C code. The Go toolchain injects cgoCheckPointer at runtime to verify this. A common critical issue occurs when cgoCheckPointer falsely triggers a panic because it conservatively scans the entire memory span of an unsafe.Pointer, incorrectly identifying valid Go pointers and crashing the application.

Rule of Thumb

Reach for sync/atomic for counters, flags, and pointer swaps. Reach for sync.Mutex for compound critical sections that touch multiple fields. Channels are higher-level coordination, not a synchronization primitive — under the hood they use the same runtime semaphore as Mutex. And if you must use unsafe.Pointer arithmetic, do it in a single expression: never park an address in a uintptr while the GC could run, because the GC won't see it as a live reference and may reclaim the memory under your feet.

📚 Source references


6. Stack Growth Benchmark

Start With the Surprise

A recursive function with zero heap allocations, zero I/O, zero syscalls — just integer math and a function call — runs ~18× slower than its iterative twin. No GC pressure, no cache misses you can blame, no contended locks. Where does the time go?

The answer is invisible by design: every goroutine starts with a tiny ~2 KB stack, and when a function call would push it past the current limit, the runtime allocates a new (usually 2×) stack, copies the existing frames, and rewrites every pointer that referenced the old stack. The deeper your recursion, the more often you cross those boundaries — and each crossing is a hidden copy.

Drop this into a stack_test.go to see it yourself:

package stackgrowth

import "testing"

// shallow uses a tiny frame; never crosses the initial 2 KB boundary.
func shallow(n int) int {
	if n == 0 {
		return 1
	}
	return shallow(n - 1)
}

// deep declares a 256-byte local that pushes each frame closer to the budget,
// triggering newstack + copystack as the recursion deepens.
func deep(n int) int {
	var pad [256]byte
	pad[0] = byte(n)
	if n == 0 {
		return int(pad[0])
	}
	return deep(n - 1)
}

func BenchmarkStackGrowth(b *testing.B) {
	b.Run("Shallow_Stack", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = shallow(8)
		}
	})
	b.Run("Deep_Stack", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = deep(64)
		}
	})
}

go test -bench=. -benchmem -benchtime=2s on my Intel i5-8350U:

goos: linux
goarch: amd64
cpu: Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz
BenchmarkStackGrowth/Shallow_Stack-8   209946092   10.62 ns/op   0 B/op
BenchmarkStackGrowth/Deep_Stack-8       11390772   197.0 ns/op   0 B/op

On this CPU, roughly an order of magnitude slower (≈18× here, with zero heap allocations). The exact ratio shifts with CPU model, Go version, and machine load — what stays constant is the shape: a deep recursion that repeatedly crosses the goroutine's stack budget pays newstack + copystack work each time, and the compiler can't optimize the copies away because real pointers from the old stack to itself have to be rewritten in place. This is why idiomatic Go prefers iterative solutions on hot paths, or bounded recursion when iteration would be too awkward.

Click your way through it to see the cost build up:

Goroutine Stack — push, grow, copy
›Initial goroutine stack: 2 KB cap, 3 frames pushed (~768 B used). Click ▶ Call deeper to push more.
Stack framestop = newest
free (256 B)
free (256 B)
free (256 B)
free (256 B)
free (256 B)
SP →
deep(n=3)pad[256] · ret
deep(n=2)pad[256] · ret
deep(n=1)pad[256] · ret
★ base0xc0001a0
Stack capacity
2 KB
2048 B
Used
768 B
38% of cap
Frames pushed
3
peak 3 historic
Stack copies
0
0 B copied total
Growth historyevery grow = newstack + copystack — bytes scale with current depth
no grows yet — keep calling deeper to fill the stack
Real Go (runtime/stack.go): goroutines start with a 2 KB stack. When a function call would overflow, the runtime calls newstack to allocate a 2× stack and copystack to memcpy every existing frame, then rewrites every pointer that referenced the old stack. Deep recursion crosses these boundaries repeatedly — that's the entire ~18× cost in the §6 benchmark.

Rule of Thumb

Bound recursion depth on hot paths, or convert recursion to iteration. Stack growth (~2 KB → 4 KB → 8 KB → ...) is invisible to the developer but adds copy + pointer-fixup work every time the boundary is crossed, and a deeply recursive function will pay that cost over and over. The first benchmark that shows a sudden order-of-magnitude slowdown for "just one more level of recursion" is almost always stack growth.

📚 Source references


7. The Garbage Collector (GC)

This is the part most developers spend the least time thinking about — and that's actually a good thing. Go's GC is designed to be ignored: it runs concurrently with your program, takes about 25% of CPU when it's working, and pauses the world for less than a millisecond on most workloads. The cost of having an automatic memory manager has been pushed about as low as you can get without giving up correctness.

Under the hood it's a Concurrent Tri-color Mark and Sweep algorithm, optimized for ultra-low latency pauses (typically under 1 millisecond).

  1. Mark Phase 🖍️: The GC stops all cores briefly (Stop-The-World) to activate a Write Barrier. The Write Barrier is injected code that "colors" new pointers as they are created, ensuring the GC doesn't accidentally delete an object modified during the scan. The GC then resumes the program, using ~25% of CPU capacity to follow pointers and mark objects as Live (Black) or Unreachable (White).
  2. Sweep Phase 🧽: The GC reclaims the memory of all White (unreachable) objects in the background without pausing the application.
READY
›Click ▶ Run GC to begin a mark-and-sweep cycle, or ⏭ Step to walk through one step at a time.
stack[0]
stack[1]
globals
Awhite
free
Bwhite
Cwhite
Dwhite
free
Ewhite
Fwhite
Gwhite
free
Hwhite
Iwhite
Worklist (gray queue)
(empty — no marking yet)
white — unmarked, may be garbage
gray — on worklist
black — scanned, definitely live
free / fragmented
root (stack / globals)
▶How close is this to real Go GC? (click to expand)

The overall flow matches runtime/mgc.go: roots go gray first, each gray object is popped from the worklist, scanned, and turned black. Its white children are grayed and pushed onto the worklist. Anything left white after the trace is swept. The tri-color invariant (a black object never points directly to a white one) is preserved by the mark order.

Simplifications for clarity:

  • No write barrier animation. Real Go uses a hybrid (Dijkstra + Yuasa) write barrier so the mutator can keep running during mark without losing reachable objects. Here the mutator is paused for the trace.
  • Single worklist, no parallel scan. Real Go uses multiple marker goroutines pulling from per-P workbufs. This diagram walks a single FIFO queue.
  • No spans / mcache / mcentral. Real Go allocates in size-classed mspans; sweep is span-local and lazy (triggered by the next allocation). Here the heap is a flat strip and sweep is eager.
  • No pacer or assist credit. Real Go's pacer ramps GC CPU share (~25% target) and charges mutator goroutines "assist" work when they out-allocate the marker. Here one click runs the full cycle.
  • Fixed topology. No allocations during mark, no generational separation — just a static reachability graph per cycle. Use ✂ Cut to mutate the graph between cycles.

Tuning the GC

Developers can tune the GC's aggressiveness using the GOGC environment variable.

  • GOGC=100 (Default): The GC runs when the heap size doubles (e.g., at 4 MB in use → GC again when reaching 8 MB).
  • GOGC=50: The GC runs when the heap grows by 50%. This saves RAM but burns more CPU cycles.
  • GOMEMLIMIT: Introduced in Go 1.19, this sets a soft memory limit. When the heap approaches the limit, the runtime ignores GOGC and runs GC more aggressively to stay below it — but it will still exceed the limit rather than starve the program. It is explicitly documented as a soft limit in runtime/extern.go.

Rule of Thumb

Set GOMEMLIMIT for memory-bounded environments (containers, lambdas) and leave GOGC=100 unless profiling proves otherwise. Avoid object pools and other "outsmart the GC" patterns until your benchmark says they pay off — the modern pacer with hybrid write barriers handles most workloads without help, and pools are a frequent source of subtle bugs (stale references, type confusion, race conditions on Get/Put). When you do need to reduce allocations, the leverage is almost always in escape analysis (§2), not GC tuning.

📚 Source references


Test Your Knowledge

Knowledge Check

1/15

What is the initial stack size of a Goroutine?

Conclusion

You don't need to memorize any of this to write Go. The language is designed so that most of the time, the obvious code is also the fast code — and that's a real achievement. But when you do hit a performance wall, the fix is almost always at one of these layers: an extra heap allocation that escape analysis was warning you about, a struct that fights itself across cache lines, a goroutine blocked behind a long syscall, a recursion that keeps copying its own stack.

The biggest takeaway, for me, is that "low-level" thinking in Go isn't about replacing the high-level code. It's about knowing when to look one layer down — and having a mental map of what's there when you do.

If any of this changed how you read your own code, that's the goal. Run the benchmarks, poke at go build -gcflags="-m" on your own packages, and see what the compiler tells you. Most of the wins are sitting there waiting to be found.

0 claps5 remaining

Share this article