Skip to main content
Go is a statically typed, compiled language built for simplicity and high-concurrency backend services. Its runtime scheduler, lightweight goroutines, and built-in channel primitives let you write concurrent code that scales to millions of simultaneous tasks without the overhead of OS threads. This page covers the internals you need to understand to write correct, performant Go services—from how the GMP scheduler works to garbage collection, generics, and idiomatic patterns.

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() {
    fmt.Println("hello from goroutine")
}()
  1. 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).
  2. The owning P’s M dequeues G and runs it.
  3. If M blocks (e.g., on a syscall), the runtime detaches P from M and schedules P on another M.
  4. 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 the go keyword. The goroutine runs concurrently with the calling code; the call does not block.
f()    // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait
When 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 with make:
ch := make(chan int)    // unbuffered channel
ch := make(chan int, 3) // buffered channel with capacity 3
Send and receive with the <- operator:
ch <- 42     // send 42 into ch
x := <-ch    // receive from ch into x
<-ch         // receive and discard
Channels are reference types—copying a channel copies only the pointer, so both copies refer to the same underlying object.

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.
func main() {
    done := make(chan struct{})
    go func() {
        fmt.Println("work done")
        done <- struct{}{} // signal main
    }()
    <-done // wait for signal
}

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.
ch := make(chan string, 3)
ch <- "A"
ch <- "B"
fmt.Println(cap(ch)) // 3
fmt.Println(len(ch)) // 2
fmt.Println(<-ch)    // "A"
Goroutine leaks — if you use an unbuffered channel and no goroutine ever reads from it, the sender is blocked forever. Always ensure every goroutine can exit.

Directional channels

You can restrict a channel to send-only or receive-only at the type level:
func producer(out chan<- int) { // send-only
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) { // receive-only
    for v := range in {
        fmt.Println(v)
    }
}

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.
select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case ch2 <- 99:
    fmt.Println("sent 99 to ch2")
default:
    fmt.Println("no channel ready")
}
A common timeout pattern:
select {
case result := <-work:
    fmt.Println(result)
case <-time.After(5 * time.Second):
    fmt.Println("timed out")
}

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:
ColorMeaning
WhiteNot yet visited — potential garbage
GrayVisited but children not yet scanned
BlackFully scanned — definitely reachable
The algorithm proceeds as follows:
  1. All objects start white.
  2. Root objects (stack variables, globals) are marked gray.
  3. Each gray object is scanned: it turns black, and any white children become gray.
  4. 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

  1. Mark setup (STW) — enables write barriers.
  2. Marking (concurrent) — tri-color scan runs alongside your code.
  3. Mark termination (STW) — disables write barriers, finalizes the mark.
  4. 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:
go build -gcflags="-m" ./...
Variables escape to the heap when:
  • 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.
Closures in particular cause their captured variables to be heap-allocated:
func adder(x int) func(int) int {
    // x escapes to the heap because the returned closure captures it
    return func(y int) int {
        return x + y
    }
}

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.
// Any comparable type
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

// Usage
fmt.Println(Contains([]int{1, 2, 3}, 2))       // true
fmt.Println(Contains([]string{"a", "b"}, "c")) // false

Union constraints

You can define a constraint that is the union of several concrete types:
type Number interface {
    int | int64 | float64
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

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.
func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3
The captured variable 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 as sort.SliceStable:
func SliceStable(slice interface{}, less func(i, j int) bool)
A simple example:
func apply(x int, transform func(int) int) int {
    return transform(x)
}

square := func(n int) int { return n * n }
fmt.Println(apply(5, square)) // 25

Interface composition

Go interfaces are implicit—a type satisfies an interface simply by implementing its methods. You can compose larger interfaces from smaller ones:
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
    Reader
    Writer
}
This pattern underpins the entire io package. Design narrow interfaces (one or two methods) and let callers compose them:
// Accept any Writer—file, buffer, network conn, etc.
func writeJSON(w io.Writer, v any) error {
    return json.NewEncoder(w).Encode(v)
}
A nil pointer wrapped in a non-nil interface is a common pitfall:
var buf *bytes.Buffer // nil pointer
var w io.Writer = buf // non-nil interface (has type *bytes.Buffer, value nil)
if w != nil {
    w.Write([]byte("hello")) // panics! underlying value is nil
}
Prefer declaring the interface type directly to avoid this:
var w io.Writer // truly nil
if debug {
    w = new(bytes.Buffer)
}