Skip to content
8 min read·Lesson 4 of 8

Goroutines, Channels, and Context

Concurrency the Go way: goroutines for cheap parallelism, channels for communication, and context.Context for cancellation and deadlines.

Concurrency is one of Go's marquee features. The model — goroutines, channels, and the context package — is small and composes into surprisingly powerful patterns.

Goroutines

A goroutine is a function executing concurrently with the rest of the program. Start one with the go keyword:

func say(msg string) {
    fmt.Println(msg)
}

func main() {
    go say("hello")     // runs concurrently
    say("world")
    time.Sleep(time.Millisecond)  // crude wait; we'll do better
}

Goroutines are not OS threads — they are scheduled by the Go runtime on a small pool of OS threads (default: one per CPU core). A goroutine costs about 2-4 KB of stack initially. You can run hundreds of thousands of them on a single machine.

Channels

A channel is a typed pipe through which goroutines send and receive values. They are synchronisation primitives, not just data carriers.

ch := make(chan int)            // unbuffered
go func() { ch <- 42 }()        // send (blocks until received)
v := <-ch                        // receive
fmt.Println(v)                  // 42

// Buffered channel — sends block only when buffer is full
buf := make(chan string, 3)
buf <- "a"; buf <- "b"; buf <- "c"  // no block
close(buf)                            // signal no more sends

for s := range buf {
    fmt.Println(s)              // a b c
}

Closing a channel signals "no more values" to receivers. Receiving from a closed channel returns the zero value immediately (with v, ok := <-ch, ok is false).

WaitGroup: Wait for Many Goroutines

var wg sync.WaitGroup
urls := []string{"a", "b", "c"}

for _, u := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(u)              // pass u as argument — see closure gotcha below
}
wg.Wait()

Closure capture gotcha: in Go < 1.22, the loop variable is reused across iterations; capturing u by reference inside the goroutine would print the same value many times. Pass as argument, or in 1.22+, the loop variable is per-iteration (problem solved by the language).

Mutexes for Shared State

When goroutines must access the same memory, protect it with a mutex:

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

For maps specifically, sync.Map is sometimes more appropriate. For simple counters, sync/atomic primitives are faster than mutexes.

The Mantra

Do not communicate by sharing memory; share memory by communicating.

Channels first, mutexes only when channels don't fit. In practice:

  • Channels for pipelines, fan-out/fan-in, signalling, request/response across goroutines.
  • Mutexes for protecting a few fields in a struct that multiple goroutines mutate.

select

select is to channels what switch is to values. It waits for whichever channel is ready first:

select {
case msg := <-results:
    handle(msg)
case <-time.After(5 * time.Second):
    return fmt.Errorf("timeout")
case <-ctx.Done():
    return ctx.Err()
}

Add a default case to make the select non-blocking (poll). Without default, the select blocks until one case fires.

context.Context

The context package is the standard way to propagate cancellation, deadlines, and request-scoped values down a call tree. Every function that does I/O or might be long-running should take a ctx context.Context as its first argument.

func handleRequest(ctx context.Context, id string) error {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    user, err := db.GetUser(ctx, id)
    if err != nil {
        return err
    }
    return notify(ctx, user)
}

If the context is cancelled (timeout, parent cancellation, request aborted), any function checking ctx.Done() returns immediately. The HTTP server cancels the request context when the client disconnects. The database driver returns an error if it sees ctx.Done() while waiting for a result.

Common context constructors

  • context.Background() — the root; use at the top of main or test setup
  • context.TODO() — same as Background but a marker that you haven't decided what to do yet
  • context.WithCancel(parent) — derive with manual cancellation; remember to call cancel()
  • context.WithTimeout(parent, duration) — auto-cancel after a deadline
  • context.WithDeadline(parent, time) — auto-cancel at a specific time
  • context.WithValue(parent, key, value) — attach request-scoped data (request ID, user ID); use sparingly

Context rules

  • Pass context as the first argument, never store it in structs
  • Don't pass nil; use context.Background() if you have nothing
  • Don't put optional parameters in context — that is what arguments are for
  • Always call cancel (use defer cancel()) even after timeout fires, to release resources

The Fan-Out / Fan-In Pattern

func process(ctx context.Context, items []Item) ([]Result, error) {
    jobs := make(chan Item)
    results := make(chan Result, len(items))

    // Fan out — start N workers
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                r, err := work(ctx, item)
                if err != nil { continue }
                results <- r
            }
        }()
    }

    // Feed jobs
    go func() {
        defer close(jobs)
        for _, item := range items {
            select {
            case jobs <- item:
            case <-ctx.Done():
                return
            }
        }
    }()

    // Wait, then close results
    go func() { wg.Wait(); close(results) }()

    out := []Result{}
    for r := range results {
        out = append(out, r)
    }
    return out, ctx.Err()
}

Ten workers process items concurrently; the parent collects results in order of completion; cancellation propagates via the shared context.

Common Pitfalls

  • Goroutine leaks. A goroutine that blocks on a channel that is never written or closed lives forever. Always have an exit path — select with ctx.Done() is the standard one.
  • Forgotten cancel. Every WithCancel/WithTimeout needs a matching defer cancel().
  • Channel direction errors. Sending to a closed channel panics; receiving from a closed channel returns the zero value. Closing twice panics.
  • Race detector. Run go test -race in CI. The data race detector is built in and finds most concurrent-access bugs.

Master goroutines + channels + context and you have most of Go's concurrency story. The pattern library is small enough to fit on a postcard and powerful enough to drive Kubernetes' control plane.

Key Takeaways

  • A goroutine is a cheap concurrent function call — start tens of thousands without issue.
  • Channels are the canonical communication primitive: "do not communicate by sharing memory; share memory by communicating."
  • sync.WaitGroup, sync.Mutex, and the select statement cover most concurrency patterns.
  • context.Context propagates cancellation, deadlines, and request-scoped values down call stacks.
  • Every function that does I/O should take a Context as its first argument.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →