Back

How are Goroutines Lightweight

• 11 min read

Goroutines · Go · Threads

Go’s concurrency model is often introduced through a deceptively simple statement: a goroutine is a lightweight thread managed by the Go runtime. While this description is useful pedagogically, it conceals most of the mechanisms that make goroutines fundamentally different from conventional operating-system(OS) threads.

A goroutine is not an operating-system thread. It is a runtime-managed execution context that consists of a function to execute, its stack, scheduling state, and associated runtime metadata. The Go runtime multiplexes potentially enormous numbers of goroutines over a comparatively small number of operating-system threads. This multiplexing is coordinated by the Go scheduler, which operates using the runtime’s G-M-P model.

Understanding goroutines therefore requires examining several layers simultaneously: the language-level concurrency model, the runtime scheduler, operating-system threads, dynamically managed stacks, synchronization primitives, the network poller, and the garbage collector. The performance characteristics of a Go program emerge from the interaction of all of these components rather than from goroutines in isolation.

Maybe calling it a lightweight thread isn’t bad after all.

1. From Threads to Goroutines

Traditional concurrent programs commonly associate one execution context with one operating-system thread. Creating a thread therefore involves cooperation with the OS, which must allocate kernel-managed resources, establish scheduling metadata, and provide a stack and execution context.

Goroutines change this relationship.

Consider:

go process()

The go statement does not ask the OS to create a new thread. Instead, it asks the Go runtime to create a new goroutine representing the invocation of process.

The operating system ultimately executes machine instructions on threads, but the Go runtime determines which goroutine those threads execute.

Conceptually, the hierarchy is:

flowchart TD
    subgraph App["Application"]
        subgraph G1["Goroutine"]
            St["Go stack"]
            Es["execution state"]
            Fn["function"]
            Mt["runtime metadata"]
        end
        G2["Goroutine"]
        G3["Goroutine"]
    end
    App --> Sched["Go Scheduler"]
    Sched --> OST["Operating-System Threads"]
    OST --> CPU["CPU Cores"]

This distinction is essential.

An operating-system thread is a kernel-visible scheduling entity. A goroutine is primarily a user-space runtime scheduling entity.

Consequently, the runtime can maintain thousands or millions of goroutines without requiring the operating system to maintain an equivalent number of kernel threads.

This is one of the fundamental reasons Go can express highly concurrent server architectures without requiring developers to manually construct thread pools or asynchronous state machines.

2. The G-M-P Scheduler Model

The scheduler is built around three entities:

  • G — a goroutine.
  • M — an OS thread.
  • P — a processor resource, the scheduling context that an M must hold to run Go code. A P is not a CPU core; it is per-thread state (notably the local run queue) that keeps scheduling cheap.
flowchart TD
    P["P (processor resource)"] --> LRQ["Local Run Queue"]
    LRQ --> G1["G"]
    LRQ --> G2["G"]
    LRQ --> G3["G"]
    G1 --> M["M (OS thread)"]
    M --> OST["OS Thread"]
    OST --> CPU["CPU"]

The number of Ps is set by GOMAXPROCS (default: available CPUs).

runtime.GOMAXPROCS(4)

That does not cap the number of OS threads. The runtime can spawn extra Ms — for example, when a goroutine enters a blocking system call — so M count may exceed P count. A goroutine only runs when an M holds a P.

3. Goroutine Scheduling and Work Stealing

Each P keeps its own local run queue of runnable goroutines, so the common case (enqueue/dequeue from the same P) is lock-free and cheap. This avoids a single contended global queue.

flowchart LR
    subgraph P0["P0"]
        Q0["G1 G2 G3 G4"]
    end
    subgraph P1["P1 (idle)"]
        Q1["idle"]
    end
    Q0 -. "work stealing" .-> Q1

When a P runs out of work, it steals goroutines from another P’s queue instead of going idle. This balances locality against fairness: scheduling stays local and cheap when there’s work, and idle processors don’t stay idle when work exists elsewhere.

A running goroutine moves through states — runnable, running, waiting, dead — and the runtime makes it runnable again when the condition it waited on (a channel, synchronization primitive, network operation, or timer) is satisfied.

4. Why Goroutines Are Cheap

A goroutine’s low cost is not “no memory.” It is that the stack is small and dynamic instead of a large fixed reservation. A new goroutine starts with roughly 2 KB; OS thread stacks are typically megabytes. When a goroutine’s call depth grows, the runtime enlarges the stack on demand — since Go 1.3 by copying it to a larger contiguous region, with compiler cooperation keeping it safe.

flowchart TD
    New["New goroutine<br/>(~2 KB)"] --> Small["Plenty for shallow calls"]
    Small --> Overflow["Stack overflow"]
    Overflow --> Grow["Runtime copies to a larger stack"]
    Grow --> Bigger["Grows on demand only"]

The practical result: instead of each goroutine reserving megabytes it will never use, memory is only committed as a goroutine actually needs it.

5. Stacks and Escape Analysis

A goroutine’s stack holds more than bytes — the garbage collector must know which words are pointers. The compiler emits stack maps describing pointer positions at each program point, which lets the collector walk stacks safely without guessing.

func create() *int {
    x := 42
    return &x
}

Returning the address of x means its lifetime outlives the call. Escape analysis decides where an object lives:

flowchart TD
    Local["Local variable"] --> EA["Compiler escape analysis"]
    EA -->|"does not escape"| Stack["Stack (no GC cost)"]
    EA -->|"escapes"| Heap["Heap (GC-managed)"]

This matters for goroutines because they frequently exchange references through channels, closures, and shared data. The compiler decides placement; the GC reclaims whatever lands on the heap.

6. Garbage Collection

Go uses a concurrent tracing collector. An object is live while reachable from a set of roots:

flowchart TD
    Roots["GC Roots"] --> Globals["Global variables"]
    Roots --> Stacks["Goroutine stacks"]
    Roots --> RTS["Runtime structures"]
    Globals --> Heap["Heap objects"]
    Stacks --> Heap
    RTS --> Heap

Because goroutine stacks are GC roots, the runtime cannot treat goroutines as opaque. Scheduling and garbage collection are therefore inseparable: the collector must pause or inspect a goroutine’s stack, and the compiler must supply the metadata that makes that safe.

7. Concurrent Collection and Write Barriers

Most marking and sweeping happens concurrently with application goroutines (“mutators”). Only short synchronization phases stop the world — the goal is short, bounded pauses, not zero pauses.

Concurrency creates a correctness problem: if the collector is tracing the object graph while a mutator writes a pointer, the collector could miss a new reference. A write barrier makes the runtime intercept relevant pointer stores, keeping the collector aware of mutations:

flowchart TD
    Mut["Mutator: obj.child = newObj"] --> WB["Write barrier"]
    WB --> Iter["GC aware of the new reference"]

8. Goroutine Preemption

A scheduler that can only switch when a goroutine voluntarily yields would let an infinite loop starve a P forever. Modern Go uses asynchronous preemption: the runtime can interrupt a running goroutine at a safe point, which matters for fairness and for stopping goroutines so the GC can inspect their stacks.

flowchart TD
    G1["G1 running"] --> Continue["Execution continues..."]
    Continue --> Decide["Scheduler needs G1 stopped"]
    Decide --> Safe["G1 interrupted at a safe point"]
    Safe --> G2["G2 runs"]

9. Blocking Does Not Waste a Thread

When a goroutine blocks on a channel, mutex, or network operation, the runtime parks the goroutine, not the thread. The M is free to run other runnable goroutines:

flowchart TD
    subgraph M["M (OS thread)"]
        W["G1 waiting on channel"]
        R2["G2 runnable"]
        R3["G3 runnable"]
    end
    R2 --> Sched["Scheduler"]
    Sched --> Exec["Execute G2"]

The number of goroutines therefore doesn’t dictate how many threads burn CPU.

10. Goroutines and the Network Poller

A thread-per-connection server with thousands of connections would need thousands of threads. Go instead routes network operations through a runtime network poller built on the platform’s event mechanism (epoll, kqueue, IOCP). A goroutine awaiting I/O does not pin a thread; when the operation is ready, the poller wakes the goroutine and schedules it.

flowchart TD
    G["Goroutine"] --> Op["Network operation"]
    Op --> NP["Network poller"]
    NP --> OS["epoll / kqueue / IOCP"]
    OS --> Ready["Readiness notification"]
    Ready --> Run["Goroutine becomes runnable"]

11. Channels

Goroutines become particularly powerful when combined with channels. An unbuffered channel is a synchronization handoff: the send completes only when a receiver is present.

ch := make(chan int)

go func() {
    ch <- 42
}()

value := <-ch
sequenceDiagram
    participant G1
    participant Ch as Channel
    participant G2
    G1->>Ch: ch <- 42
    Ch->>G2: value = 42
    Note over G1,G2: send and receive synchronize

Channels do two things at once — communicate values and establish happens-before — and the second is often the more important one. A buffered channel adds an intermediate queue (make(chan int, 3)): sends block when full, receives block when empty, and blocked goroutines are parked and rescheduled by the runtime.

12. Scheduling Is Not Synchronization

The scheduler decides which goroutine runs; it makes no memory-visibility promises. This program has a data race — x = 42 and fmt.Println(x) have no synchronization relationship:

var x int

go func() {
    x = 42
}()
fmt.Println(x)
flowchart TD
    S["Scheduling<br/>(which goroutine runs)"] -- "not the same as" --> M["Synchronization<br/>(ordering)"]
    M -- "not the same as" --> MM["Memory model<br/>(visibility guarantees)"]

Correct concurrent Go relies on sync.Mutex, sync.RWMutex, sync.WaitGroup, atomic operations, and channels — never on scheduler timing.

13. Mutexes and Atomics

A mutex makes the critical section exclusive and encodes the invariant explicitly:

sequenceDiagram
    participant G1
    participant Mx as Mutex
    participant G2
    G1->>Mx: Lock()
    G1->>Mx: modify shared state
    G2->>Mx: Lock() (parks until ready)
    G1->>Mx: Unlock()
    Mx->>G2: proceeds
var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

atomic.AddInt64(&counter, 1) avoids the mutex, but atomic code is often harder to reason about — invariants become implicit state machines rather than guarded regions. Prefer the mutex unless measurement says otherwise.

14. Goroutine Leaks and Context Cancellation

Because goroutines are cheap, they’re easy to start and forget:

func start() {
    go func() {
        for {
            process()
        }
    }()
}

Without a termination condition, every call to start adds a permanently running goroutine. A leaked goroutine is not garbage-collected — the runtime’s scheduler holds a reference to it while it is runnable or blocked — and it retains its stack and everything reachable from it:

flowchart TD
    G["Goroutine (retained by scheduler)"] --> SC["Stack / closure"]
    SC --> RO["Referenced objects"]
    RO --> H["Heap graph (retained)"]

The fix is ownership of lifetime: every goroutine should know what stops it. context.Context is the standard mechanism:

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            process()
        }
    }
}

15. Concurrency Versus Parallelism

Concurrency is a program structure (multiple independent activities); parallelism is simultaneous execution. They differ — goroutines can be interleaved on one processor or spread across many:

flowchart LR
    subgraph Interleaved["Concurrent (1 core)"]
        direction LR
        I1["G1"] --> I2["G2"] --> I3["G3"]
    end
    subgraph Parallel["Parallel (N cores)"]
        direction LR
        P1["G1"] 
        P2["G2"]
        P3["G3"]
    end

Goroutines don’t create CPU capacity. An infinite loop of expensiveComputation() across hundreds of goroutines just multiplexes the same cores. For I/O-bound work, many goroutines waiting on network is fine — the poller and scheduler keep cores busy with whatever is runnable.

16. Goroutine Creation Is Not Free

for i := 0; i < 1_000_000; i++ { go worker() } is not automatically good. Each goroutine needs stack capacity and scheduling metadata, and runnable queues add scheduling work. High allocation rates also add GC pressure:

flowchart TD
    Alloc["High allocation"] --> Growth["Heap growth"] --> Mark["GC marking"] --> Cpu["GC CPU consumption"] --> Free["Memory reclamation"]

Two counterintuitive facts matter here: not every variable is heap-allocated (escape analysis keeps many on the stack), and GC does not manage external resources. File descriptors, connections, and goroutines themselves must be released deterministically — often via defer:

f, err := os.Open("file.txt")
if err != nil {
    return err
}
defer f.Close()

17. The Runtime as an Integrated System

Goroutines are the visible interface to a tightly integrated subsystem set: a scheduler with work stealing, per-goroutine dynamic stacks, a concurrent collector that walks those stacks via compiler-generated metadata, a network poller, an allocator, and a memory model.

flowchart TD
    Program["Go Program"] --> Goroutines["Goroutines"]
    Goroutines --> Sched["Scheduler"]
    Sched --> RT["Go Runtime"]
    RT --> GC["GC"]
    RT --> NP["Network poller"]
    RT --> Alloc["Allocator"]
    GC --> OS["Operating System"]
    NP --> OS
    Alloc --> OS
    OS --> CPU["CPU"]

Conclusion

go worker() looks trivial, but it sits atop real systems work: scheduling theory, OS threads, tracing GC, compiler analysis, synchronization, and asynchronous I/O. Goroutines feel “lightweight” not because they’re simpler than threads, but because Go moved an enormous amount of concurrency machinery from the kernel and the programmer into a runtime that manages it for you.

This took me a week to write and I’m happy that it’s finally done