The GMP Scheduler
Go’s concurrency model sits on top of three abstractions: G (goroutine), M (OS thread / machine), and P (processor / logical CPU). Understanding why all three exist requires a brief history.Evolution: N:1, 1:1, and M:N
Early coroutine systems mapped N user-space coroutines onto a single OS thread (N:1). Switching was cheap—no kernel context switch—but one blocking call froze every coroutine in the process and the program could never use more than one CPU core. The 1:1 model solved both problems by giving each goroutine its own OS thread. The kernel handled scheduling, but thread creation and destruction became expensive, and each thread consumed several megabytes of stack. Go uses the M:N model: M goroutines are multiplexed across N OS threads. A fixed pool of logical processors (P) sits between the two layers. A goroutine can only run when it is bound to a P, and an OS thread can only run goroutines when it holds a P. This design delivers kernel-level parallelism while keeping goroutine overhead at a few kilobytes.G, M, and P in detail
- G (Goroutine) — the unit of work. Each goroutine starts with a tiny stack (a few KB) that grows and shrinks automatically. A goroutine’s state is one of: runnable, running, blocked, or dead.
- M (Machine / OS thread) — the actual executor. M must hold a P before it can run goroutines. When M blocks on a syscall, the runtime detaches M from P so P can be picked up by another thread.
- P (Processor) — a scheduling context. Each P has its own local run queue. The number of Ps is set by
GOMAXPROCS(defaults to the number of CPU cores).
Design strategies
Work stealing — when a P’s local run queue is empty, it steals half the goroutines from another P’s queue rather than going idle. This keeps all Ps busy without global coordination. Hand-off — when M blocks in a syscall, the runtime detaches P from M and hands P to a sleeping or newly created M. When the syscall returns, the original M tries to reacquire a P; if none is free, the goroutine is put on the global queue and M parks. Preemption — unlike traditional coroutines that yield voluntarily, Go goroutines are preempted after roughly 10 ms. This prevents a CPU-hungry goroutine from starving others. Global run queue — a fallback queue shared by all Ps. When work stealing fails, M checks the global queue. Without the global queue, burst creation of goroutines could cause starvation if they all land on one P.go func() scheduling flow
go func()creates a new G and appends it to the current P’s local queue (or the global queue if the local queue is full).- The owning P’s M dequeues G and runs it.
- If M blocks (e.g., on a syscall), the runtime detaches P from M and schedules P on another M.
- When the syscall finishes, G tries to get a free P. If none is available, G goes to the global queue and M parks in the idle thread pool.
Goroutines & Channels
Creating goroutines
You launch a goroutine with thego keyword. The goroutine runs concurrently with the calling code; the call does not block.
main returns, all goroutines are cancelled immediately—the runtime does not wait for them to finish. Use channels or sync.WaitGroup to coordinate.
Channels
A channel is a typed conduit between goroutines. You create one withmake:
<- operator:
Unbuffered channels (synchronous)
An unbuffered channel forces a rendezvous: the sender blocks until a receiver is ready, and the receiver blocks until a sender is ready. This guarantees that the value was delivered before either side continues.Buffered channels
A buffered channel has an internal queue. Sends only block when the queue is full; receives only block when the queue is empty.Directional channels
You can restrict a channel to send-only or receive-only at the type level:The select statement
select lets a goroutine wait on multiple channel operations. If several cases are ready simultaneously, one is chosen at random. default runs immediately if no case is ready.
Garbage Collection
Go uses a concurrent, tri-color mark-and-sweep garbage collector. Most GC work runs in parallel with your program, keeping stop-the-world (STW) pauses short.Three-color marking
The GC classifies every object as one of three colors:| Color | Meaning |
|---|---|
| White | Not yet visited — potential garbage |
| Gray | Visited but children not yet scanned |
| Black | Fully scanned — definitely reachable |
- All objects start white.
- Root objects (stack variables, globals) are marked gray.
- Each gray object is scanned: it turns black, and any white children become gray.
- Repeat until no gray objects remain. All remaining white objects are garbage and are swept.
Write barriers
Because goroutines mutate the heap concurrently with the GC, a pointer change could make the collector miss a live object. Go uses a write barrier—a small code hook that fires whenever a pointer is written—to re-color affected objects gray, keeping the invariant intact without a full STW pause.GC cycle phases
- Mark setup (STW) — enables write barriers.
- Marking (concurrent) — tri-color scan runs alongside your code.
- Mark termination (STW) — disables write barriers, finalizes the mark.
- Sweeping (concurrent) — reclaims white objects in the background.
Memory escape analysis
The compiler performs escape analysis at build time to decide whether a variable lives on the stack (fast, no GC involvement) or escapes to the heap (managed by GC). You can inspect escape decisions:- Their address is returned from a function.
- They are captured by a closure.
- They are assigned to an interface.
- They are too large for the stack.
Generics
Go 1.18 introduced type parameters, allowing you to write functions and types that work across multiple types without losing static type safety.Type parameters and constraints
A type parameter list follows the function or type name inside square brackets. The constraint after the type parameter name specifies what operations are allowed on values of that type.Union constraints
You can define a constraint that is the union of several concrete types:When to use generics
Use generics when you are writing container types (trees, queues, sets) or utility functions (map, filter, reduce) that genuinely need to work across types. Avoid generics when an interface or simple function suffices—the extra complexity is not worth it for most business logic.Useful Patterns
Closures
A closure is a function that captures variables from its enclosing scope. In Go, closures are how you create stateful functions.n lives on the heap because the closure outlives the enclosing call.
Callback functions
Because functions are first-class values in Go, you pass them as arguments to decouple callers from implementations—the same pattern assort.SliceStable:
Interface composition
Go interfaces are implicit—a type satisfies an interface simply by implementing its methods. You can compose larger interfaces from smaller ones:io package. Design narrow interfaces (one or two methods) and let callers compose them: