thecodex.expert · The Codex Family of Knowledge
Go

Goroutines and Channels

Thousands of concurrent tasks for the cost of a few kilobytes — Go's concurrency model is built into the language, not bolted on.

Go 1.26 Built-in concurrency Last verified:
Canonical Definition

A goroutine is a lightweight concurrently-executing function multiplexed onto OS threads by the Go runtime — starting one costs ~2KB of stack vs ~1MB for an OS thread; goroutines communicate via typed channels that synchronise and transfer data, embodying the Go philosophy: do not communicate by sharing memory; share memory by communicating.

The central idea

Concurrency in most languages requires threads, which are expensive (~1MB of stack each) and managed by the OS. Go invented goroutines — functions that run concurrently but are managed by the Go runtime, not the OS. You can start a million goroutines for the price of a few gigabytes of RAM. Channels let goroutines talk to each other safely without locks.

Goroutines — the go keyword

Starting a goroutine is as simple as writing go before a function call. The function runs concurrently with the caller — both execute at the same time. The Go runtime schedules goroutines across available CPU cores automatically.

Gogoroutines.go
package main

import (
    "fmt"
    "sync"
)

func greet(name string, wg *sync.WaitGroup) {
    defer wg.Done()          // signal this goroutine is done when function returns
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    var wg sync.WaitGroup    // WaitGroup: wait for a collection of goroutines to finish

    names := []string{"Alice", "Bob", "Charlie", "Diana"}
    for _, name := range names {
        wg.Add(1)            // register one more goroutine to wait for
        go greet(name, &wg)  // launch goroutine — runs concurrently
    }

    wg.Wait()    // block until all goroutines call wg.Done()
    fmt.Println("All done.")
    // Output order is non-deterministic — goroutines run concurrently
}

Channels — typed communication pipes

A channel is a typed conduit that goroutines use to send and receive values. Sending blocks until a receiver is ready; receiving blocks until a sender sends. This synchronisation is the key: channels coordinate goroutines without explicit locks.

Gochannels.go
package main

import "fmt"

func sum(nums []int, result chan int) {
    total := 0
    for _, n := range nums {
        total += n
    }
    result <- total    // send total to channel
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

    result := make(chan int)    // create an unbuffered channel

    // Split work across two goroutines
    go sum(nums[:5], result)   // sum first half
    go sum(nums[5:], result)   // sum second half

    a, b := <-result, <-result   // receive from channel twice (blocks until ready)
    fmt.Println("Total:", a+b)    // 55

    // Buffered channel: can hold N values without a receiver ready
    buffered := make(chan string, 3)
    buffered <- "first"     // does not block — buffer has space
    buffered <- "second"
    buffered <- "third"
    // buffered <- "fourth"  // would block — buffer full

    fmt.Println(<-buffered)  // "first"
    fmt.Println(<-buffered)  // "second"
}

The select statement

The select statement is like a switch for channels — it waits until one of its cases can proceed, then runs that case. If multiple are ready simultaneously, one is picked at random. A default case makes select non-blocking.

Goselect.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "one"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "two"
    }()

    // select waits for either channel to have a value
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("Received from ch1:", msg1)
        case msg2 := <-ch2:
            fmt.Println("Received from ch2:", msg2)
        }
    }
    // Prints "one" after 1s, then "two" after 2s
}

The pipeline pattern

Pipelines chain goroutines together via channels — each stage receives from an upstream channel, processes the data, and sends to a downstream channel. This is the Go idiom for streaming data processing. Each stage can run in parallel with others.

Gopipeline.go
package main

import "fmt"

// generate: produces integers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)    // close signals: no more values to send
    }()
    return out
}

// square: reads from in, sends squares to out
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {    // range on channel reads until close
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    // Set up the pipeline: generate -> square -> print
    c := generate(2, 3, 4, 5)
    out := square(c)

    for n := range out {
        fmt.Println(n)    // 4, 9, 16, 25
    }
}

Fan-out and fan-in

Fan-out: distribute work from one channel to multiple goroutines (parallelism). Fan-in: merge results from multiple channels into one. Together they build work-stealing-style worker pools.

Gofanout.go
package main

import (
    "fmt"
    "sync"
)

// worker receives jobs from a channel and sends results
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        results <- j * j    // compute square
        fmt.Printf("worker %d processed job %d\n", id, j)
    }
}

func main() {
    const numWorkers = 3
    jobs := make(chan int, 10)
    results := make(chan int, 10)

    var wg sync.WaitGroup

    // Fan-out: start 3 workers reading from the same jobs channel
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Send 9 jobs
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)    // closing jobs signals workers to stop ranging

    // Close results once all workers are done
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for r := range results {
        fmt.Println("result:", r)
    }
}

Context: cancellation and deadlines

The context package propagates cancellation, deadlines, and request-scoped values through goroutine trees. Every goroutine doing work on behalf of a request should accept a context.Context as its first argument and check ctx.Done() to know when to stop. This prevents goroutine leaks when requests are cancelled.

Gocontext.go
package main

import (
    "context"
    "fmt"
    "time"
)

func doWork(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
            return
        default:
            fmt.Printf("worker %d doing work...\n", id)
            time.Sleep(300 * time.Millisecond)
        }
    }
}

func main() {
    // WithTimeout: automatically cancels after 1 second
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()    // always call cancel to free resources

    go doWork(ctx, 1)
    go doWork(ctx, 2)

    <-ctx.Done()    // block until context is cancelled/timed out
    fmt.Println("main: context done:", ctx.Err())  // context deadline exceeded
    time.Sleep(100 * time.Millisecond)              // let goroutines print stop messages
}
Commonly confused
Goroutines are not OS threads and not coroutines. The Go runtime uses M:N scheduling — it multiplexes M goroutines onto N OS threads (N = GOMAXPROCS, defaulting to the number of CPU cores). A goroutine starts with ~2KB of stack (which grows dynamically) vs ~1MB for an OS thread. You can have millions of goroutines; you cannot have millions of OS threads.
Unbuffered channels and buffered channels have different synchronisation semantics. An unbuffered channel (make(chan T)) synchronises sender and receiver — the send blocks until someone receives, and the receive blocks until someone sends. A buffered channel (make(chan T, n)) can hold n values; sends only block when the buffer is full. Use unbuffered for synchronisation; buffered for queuing.
Always close channels from the sender, never the receiver. Closing a channel signals "no more values." Receiving from a closed channel returns the zero value immediately. Closing from the receiver side is almost always a design error — use a done channel or context cancellation instead.

The Go scheduler: GMP model

The Go runtime scheduler uses the GMP model: G (goroutine), M (machine = OS thread), and P (processor = a scheduling context that holds a local run queue). The number of Ps defaults to GOMAXPROCS, which defaults to the number of logical CPU cores. Each P has a local run queue of goroutines waiting to be scheduled onto an M. When a P's local queue is empty, it performs work stealing — taking goroutines from another P's queue. This achieves close-to-optimal CPU utilisation without programmer intervention.

Goroutines are preempted at function call sites (cooperative preemption) and, since Go 1.14, via asynchronous preemption using OS signals — a goroutine in a tight CPU-bound loop can be preempted so other goroutines get CPU time. Stack growth is handled automatically: goroutines start with a small stack (2KB in current versions) that is grown by copying when needed (segmented stacks were replaced by copying stacks in Go 1.4).

Channel implementation: hchan and the lock-free path

Internally, a channel is an hchan struct containing: a ring buffer (for buffered channels), send and receive queues of blocked goroutines (sudog structs), and a mutex for synchronisation. When a goroutine sends on a full buffered channel or an unbuffered channel with no receiver, its sudog is enqueued on the channel's send queue and the goroutine is parked (removed from the run queue). When a receiver arrives, it directly copies data from the parked sender's stack, places the sender back on the run queue, and returns — this is the direct send optimisation that avoids copying through the buffer. This means channel communication can be O(1) in the common case and avoids unnecessary memory allocations.

Select: runtime randomisation and netpoller integration

The select statement is compiled into a call to runtime.selectgo, which shuffles the case order randomly before evaluating — this is what produces the "if multiple are ready, one is chosen uniformly at random" guarantee in the specification. Channels involved in select are locked in a consistent order (by channel address) to prevent deadlock from concurrent select statements racing on overlapping channels. The Go runtime's netpoller (based on epoll/kqueue/IOCP) integrates with the scheduler: I/O operations that would block an OS thread instead park the goroutine and wake it via the netpoller when the I/O is ready, allowing one OS thread to service many concurrent network connections.

Specification reference

The Go Team. The Go Programming Language Specification. go.dev/ref/spec. Sections: "Go statements", "Channel types", "Select statements". Donovan, A. A. A. & Kernighan, B. W. (2015). The Go Programming Language. Addison-Wesley. Chapter 8 (Goroutines and Channels), Chapter 9 (Concurrency with Shared Variables). Go Blog: go.dev/blog/concurrency-is-not-parallelism — Rob Pike's canonical talk. go.dev/ref/mem — The Go Memory Model (revised 2022).

Sources

1
The Go Team. The Go Programming Language Specification. go.dev/ref/spec. — Authoritative specification for goroutines, channels, and select.
2
Donovan, A. A. A. & Kernighan, B. W. (2015). The Go Programming Language. Addison-Wesley. — Chapters 8–9, the canonical practical treatment.
3
The Go Team. The Go Memory Model (revised 2022). go.dev/ref/mem. — Defines happens-before guarantees for channels.
4
Pike, R. (2012). Concurrency is not Parallelism. go.dev/blog/waza-talk. — Distinguishes goroutine-based concurrency from multi-core parallelism.
5
Cox-Buday, K. (2017). Concurrency in Go. O'Reilly. — Patterns: pipelines, fan-out/fan-in, done channels, error channels.
Source confidence: High Last verified: Primary source: The Go Language Specification — go.dev/ref/spec