Skip to content

01 · Concurrency Patterns

Level 2 covered goroutines, channels and select in isolation. Real programs combine them into a handful of recurring shapes — worker pools, pipelines, fan-out/fan-in, and one-time initialization. Knowing these patterns by name means you reach for a proven shape instead of improvising synchronization from scratch.

Worker pools: bounded concurrency

Launching one goroutine per unit of work is fine for a handful of items, but for thousands of jobs — each hitting a database or a rate-limited API — you want a fixed number of workers pulling from a shared queue.

package main

import (
    "fmt"
    "sync"
)

type job struct{ n int }
type result struct{ n, square int }

func worker(id int, jobs <-chan job, results chan<- result, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs { // exits automatically when jobs is closed and drained
        results <- result{n: j.n, square: j.n * j.n}
    }
}

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

    var wg sync.WaitGroup
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    for n := 1; n <= 8; n++ {
        jobs <- job{n: n}
    }
    close(jobs) // signal: no more jobs -- workers finish their current item and exit

    go func() {
        wg.Wait()      // wait for every worker to stop sending
        close(results) // only safe to close AFTER all senders are done
    }()

    sum := 0
    for r := range results {
        sum += r.square
    }
    fmt.Println("sum of squares:", sum)
    // Output: sum of squares: 204
}

The number of workers is independent of the number of jobs — three workers happily process eight jobs, each picking up a new one the instant it finishes. Bump numWorkers to control how much concurrent load you put on a downstream resource like a database connection pool.

The trap: closing results before all workers finish sending panics with "send on closed channel". That is why the close happens in its own goroutine gated by wg.Wait() — never close a channel from a goroutine that might still write to it.

Fan-out, fan-in

Fan-out means multiple goroutines reading from one channel to parallelize work; fan-in means multiple channels being merged into one.

package main

import (
    "fmt"
    "sync"
)

func generator(n int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 1; i <= n; i++ {
            out <- i
        }
    }()
    return out
}

// square is a pipeline stage. Running several of these against the SAME
// input channel is what makes this "fan-out".
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

// merge fans multiple channels back into one -- "fan-in".
func merge(cs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(cs))
    for _, c := range cs {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func main() {
    in := generator(10)

    // three squarers pulling from the same channel -- fan-out
    c1 := square(in)
    c2 := square(in)
    c3 := square(in)

    sum := 0
    for v := range merge(c1, c2, c3) { // fan-in
        sum += v
    }
    fmt.Println("sum of squares 1..10:", sum)
    // Output: sum of squares 1..10: 385
}

Because three goroutines pull from in concurrently, work items get distributed across them in whatever order the scheduler grants — the sum is still deterministic, but do not assume any particular item lands on any particular squarer.

Pipelines

A pipeline chains stages together, each stage its own goroutine connected by a channel — the same shape io.Reader/io.Writer chains achieve for data, but for concurrent computation:

package main

import "fmt"

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

func double(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * 2
        }
    }()
    return out
}

func addOne(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n + 1
        }
    }()
    return out
}

func main() {
    // Each stage runs concurrently -- addOne can start on item 1 while
    // double is still working on item 3.
    pipeline := addOne(double(generate(1, 2, 3, 4, 5)))
    for v := range pipeline {
        fmt.Print(v, " ")
    }
    fmt.Println()
    // Output: 3 5 7 9 11
}

Each function takes a channel and returns a channel, so stages compose by nesting calls. Adding a stage is one more wrapping function — no change to the ones on either side.

sync.Once: exactly-once initialization

Lazy, thread-safe initialization that must run exactly once no matter how many goroutines call it concurrently:

package main

import (
    "fmt"
    "sync"
)

var (
    once   sync.Once
    config string
)

func loadConfig() {
    fmt.Println("loading config from disk...") // must print exactly once
    config = "db=prod;timeout=30s"
}

func getConfig() string {
    once.Do(loadConfig) // subsequent calls are no-ops, even from other goroutines
    return config
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            _ = getConfig()
        }()
    }
    wg.Wait()
    fmt.Println("final config:", getConfig())
    // Output:
    // loading config from disk...
    // final config: db=prod;timeout=30s
}

Five goroutines call getConfig() concurrently but "loading config from disk..." prints exactly once. Compare this to a sync.Mutex plus a boolean flag — sync.Once is the idiomatic, race-free version of that pattern and handles the flag itself.

Rate limiting with time.Tick

Throttle how fast you process a channel of work by gating on a ticker:

package main

import (
    "fmt"
    "time"
)

func main() {
    requests := make(chan int, 5)
    for i := 1; i <= 5; i++ {
        requests <- i
    }
    close(requests)

    limiter := time.Tick(50 * time.Millisecond) // one tick every 50ms
    start := time.Now()
    for req := range requests {
        <-limiter // block until the next tick arrives
        fmt.Printf("request %d handled at %v\n", req, time.Since(start).Round(10*time.Millisecond))
    }
    // Output:
    // request 1 handled at 50ms
    // request 2 handled at 100ms
    // request 3 handled at 150ms
    // request 4 handled at 200ms
    // request 5 handled at 250ms
}

time.Tick leaks its underlying ticker forever (it has no way to stop it) — fine for a long-lived limiter in main, but in a function that returns repeatedly, use time.NewTicker and defer ticker.Stop() instead.

Cancelling a worker pool early

None of the patterns above stop workers if the caller loses interest halfway through. Real pools need an exit signal — Module 4 covers context.Context in depth, but the shape to recognize now:

select {
case jobs <- j:
    // sent
case <-ctx.Done():
    return ctx.Err() // caller cancelled; stop trying to send
}

Every blocking channel operation in a long-running goroutine should have a ctx.Done() escape hatch next to it, or that goroutine can outlive the work it was meant to do — a goroutine leak, as introduced in Level 2, Module 2.

How It Actually Works

A sync.WaitGroup is a single 64-bit counter manipulated with atomic compare-and-swap instructions (no OS-level lock) — Add increments it, Done is Add(-1), and Wait spins/parks on a runtime semaphore until the counter reaches zero, which is why calling Add after Wait has already observed zero is a race: there's no synchronization preventing the counter from being read as zero and then incremented again. A worker pool built from a shared channel works because a channel's internal goroutine wait-queues (see level-2/02) act as a fair-ish scheduler: multiple goroutines blocked on <-jobs are woken roughly in FIFO order as values arrive, so each range jobs pulls the next value with no explicit locking in your code — the channel's own internal mutex (a runtime-private lock, cheaper than sync.Mutex) serializes access. The "fan-in" pattern (merging N channels into one) needs its own goroutine per input channel precisely because a select can only multiplex a fixed, known set of channels at compile time — you can't select over a dynamically-sized slice of channels without a loop of goroutines forwarding into one shared channel.

Cheat sheet

Pattern Shape
Worker pool N goroutines range over a shared jobs channel
Fan-out Multiple goroutines reading the same input channel
Fan-in merge() combines several channels into one with a WaitGroup
Pipeline stage2(stage1(source())) — each stage its own goroutine
Exactly-once init var once sync.Once; once.Do(fn)
Rate limit limiter := time.Tick(d); <-limiter before each unit of work
Safe channel close Close only from the sender, only after all sends are done
Cancellable send select { case ch <- v: case <-ctx.Done(): return }
  • Goroutines, channels and select fundamentals: Level 2, Module 2.
  • Cancellation and deadlines with context: Module 4.
  • The Level 3 project uses a worker-pool shaped background job to write logs without blocking requests.

Exercise

Build a pipeline that reads integers 1–20 from a generator, fans them out to four worker goroutines that each check primality (a simple trial-division isPrime is fine), and fans the results back in. Collect the primes into a slice, sort it, and print it — it should be [2 3 5 7 11 13 17 19]. Then add a sync.Once-guarded "starting worker pool..." log message that only prints once no matter how many workers start. Finally, run the whole program with go run -race . and confirm it is clean.