Skip to content
AgileLogics
8 min read

Concurrency Patterns

Prerequisites: The context package

Goroutines and channels are primitives. A handful of patterns built from them cover most real concurrent code. Each one answers the same questions: who closes which channel, and how does everything stop?

Worker pool

A fixed number of goroutines pull jobs from a shared channel. This bounds concurrency — useful when each job hits a rate-limited API or a database with a limited connection pool.

package main

import (
    "fmt"
    "sort"
    "sync"
)

func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for n := range jobs {
        results <- n * n
    }
}

func main() {
    jobs := make(chan int)
    results := make(chan int)

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

    // Close results once every worker has finished.
    go func() {
        wg.Wait()
        close(results)
    }()

    // Feed jobs, then close jobs so workers' range loops end.
    go func() {
        for i := 1; i <= 5; i++ {
            jobs <- i
        }
        close(jobs)
    }()

    var squares []int
    for sq := range results {
        squares = append(squares, sq)
    }
    sort.Ints(squares)
    fmt.Println(squares)
}

Expected output:

[1 4 9 16 25]

Results arrive in whatever order workers finish, so the program sorts them before printing. The ownership rule: the sender closes. The feeder closes jobs; a separate goroutine closes results after wg.Wait(), because no single worker knows when it's the last one.

Pipeline

A pipeline is a chain of stages, each a goroutine that reads from one channel and writes to the next. Each stage owns and closes its output channel.

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 square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

func main() {
    for v := range square(square(generate(1, 2, 3))) {
        fmt.Println(v)
    }
}

Expected output:

1
16
81

With a single goroutine per stage, order is preserved.

Fan-out, fan-in

Fan-out starts several goroutines reading from the same input channel. Fan-in merges their output channels into one:

func merge(chans ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, c := range chans {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

// Fan out: two square stages share one input. Fan in: merge them.
// in := generate(1, 2, 3, 4)
// for v := range merge(square(in), square(in)) { ... }

Merged output order is not deterministic.

In real code each stage should also take a ctx and select on ctx.Done() when sending, so a consumer that stops early doesn't leave upstream goroutines blocked forever.

errgroup

Hand-rolling a WaitGroup plus error collection plus cancellation gets repetitive. The golang.org/x/sync/errgroup package (maintained by the Go team, but a separate module) packages it up:

go get golang.org/x/sync/errgroup
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(4) // at most 4 in flight

    for _, url := range urls {
        g.Go(func() error {
            req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
            if err != nil {
                return err
            }
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return err
            }
            return resp.Body.Close()
        })
    }
    return g.Wait() // first non-nil error; ctx is cancelled when one fails
}

Since Go 1.22 each loop iteration gets its own url variable, so capturing it in the closure is safe.

Common mistakes

  • Closing a channel from the receiving side, or from more than one sender — sending on a closed channel panics.
  • Forgetting to close a channel that someone ranges over — the loop never ends and the program deadlocks or leaks a goroutine.
  • Starting one goroutine per item for an unbounded input (millions of rows). Use a worker pool or SetLimit.
  • Reaching for channels when a mutex is simpler. Channels are for passing ownership of data; a mutex is fine for protecting a shared counter or map.

Practice

Using a worker pool of 4 goroutines, compute the total length of these words concurrently and print it: "go", "channels", "select", "mutex", "context".

Show solution
package main

import (
    "fmt"
    "sync"
)

func main() {
    words := []string{"go", "channels", "select", "mutex", "context"}
    jobs := make(chan string)
    lengths := make(chan int)

    var wg sync.WaitGroup
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for w := range jobs {
                lengths <- len(w)
            }
        }()
    }
    go func() {
        for _, w := range words {
            jobs <- w
        }
        close(jobs)
    }()
    go func() {
        wg.Wait()
        close(lengths)
    }()

    total := 0
    for n := range lengths {
        total += n
    }
    fmt.Println(total)
}

Expected output:

28