← Engineering Notes

Go Concurrency Patterns

Go and Backend Engineering · last updated Oct 2026

Concept

Go's concurrency model is built around two primitives: goroutines and channels. Goroutines are lightweight, user-space threads managed by the Go runtime. Channels are typed conduits through which goroutines communicate.

The guiding philosophy, from the Go team: "Do not communicate by sharing memory; instead, share memory by communicating." In practice this means passing data through channels rather than using shared variables protected by mutexes. Both approaches are valid; the channel model tends to produce clearer code when the design fits it.

Problem

Concurrent programs are difficult to reason about. Shared mutable state, race conditions, and deadlocks are the canonical failure modes. Go's concurrency primitives are designed to make correct concurrent code easier to write — not easy, but easier than raw threads and locks.

The challenge in backend services is that concurrent work is everywhere: handling multiple HTTP requests simultaneously, processing background jobs, aggregating results from multiple upstream services, or timing out slow operations.

How it works

A goroutine is started with the go keyword:

go func() {
    // runs concurrently
}()

Goroutines are cheap. The Go runtime multiplexes them onto OS threads. You can start thousands of goroutines without the overhead of creating thousands of OS threads.

Channels allow goroutines to pass values to each other safely:

results := make(chan int, 10) // buffered channel

go func() {
    results <- compute()
}()

value := <-results // receive from channel

Common patterns:

  • Fan-out / fan-in — distribute work across multiple goroutines, collect results through a shared channel.
  • Worker pool — a fixed number of goroutines read from a work queue. Prevents unbounded goroutine creation under high load.
  • Pipeline — goroutines connected by channels, each stage processing and forwarding data to the next.
  • Done channel / context cancellation — signal goroutines to stop by closing a channel or cancelling a context.Context.

Trade-offs

Channels are not always the right tool. For protecting a shared data structure with simple read/write access, a sync.Mutex or sync.RWMutex is often clearer and more efficient than coordinating through channels.

Goroutine leaks are a real concern. A goroutine that is never unblocked stays alive for the lifetime of the program. The context package is the standard mechanism for propagating cancellation and ensuring goroutines exit when they are no longer needed.

The Go race detector (go test -race) is invaluable. It detects data races at runtime and should be part of any test suite that involves concurrent code.

Practical application

In Simple Bank, the HTTP server handles each request in its own goroutine automatically — Go's net/http package does this for you. The interesting concurrency work is in the transfer logic, where multiple concurrent transfers to the same accounts must be coordinated through careful lock ordering at the database level rather than in application code.

A worker pool pattern for bounded concurrency:

func workerPool(jobs <-chan Job, n int) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    go func() {
        wg.Wait()
        close(results)
    }()
    return results
}

My understanding

Goroutines are easy to create and easy to misuse. The common mistake is treating them as "fire and forget" and not thinking about when and how they will exit. Every goroutine needs an exit path. If it's waiting on a channel, something must close that channel or send a signal. If it's running a loop, it needs a termination condition.

The concurrency model I've found most reliable: use context.Context for cancellation, use channels for communication, and use mutexes only for protecting shared state that doesn't fit the channel model. Keep goroutines short-lived or give them a clear way to exit.

← Back to Engineering Notes