Go Concurrency
Concurrency is built into Go at the language level. Where other languages bolt threads, callbacks, or promises onto a runtime, Go gives you goroutines and channels as first-class primitives. The result is a programming model that scales from a handful of background tasks to hundreds of thousands of concurrent operations without collapsing under the weight of OS threads.
This guide covers the full Go concurrency stack: spawning goroutines, communicating through channels, multiplexing with select, synchronizing with sync, propagating deadlines with context, and the patterns that keep large codebases readable and correct. It also catalogs the mistakes that still trip up experienced Go engineers in production.
info
A goroutine is a lightweight thread managed by the Go runtime. Prefix any function or method call with the go keyword and it runs concurrently with the caller. Goroutines start with a small stack that grows and shrinks as needed, so you can spawn hundreds of thousands of them without exhausting system memory.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func say(prefix string, count int) { |
| 9 | for i := 0; i < count; i++ { |
| 10 | fmt.Printf("%s: %d\n", prefix, i) |
| 11 | time.Sleep(50 * time.Millisecond) |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | func main() { |
| 16 | go say("worker", 3) |
| 17 | say("main", 3) |
| 18 | |
| 19 | // Without this, main exits before the goroutine finishes. |
| 20 | time.Sleep(300 * time.Millisecond) |
| 21 | } |
The example above cheats with time.Sleep. In real programs you synchronize completion with channels or a sync.WaitGroup. Sleeping to wait for goroutines is brittle and should not survive a code review.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | ) |
| 7 | |
| 8 | func main() { |
| 9 | var wg sync.WaitGroup |
| 10 | |
| 11 | for i := 1; i <= 3; i++ { |
| 12 | wg.Add(1) |
| 13 | go func(id int) { |
| 14 | defer wg.Done() |
| 15 | fmt.Printf("worker %d done\n", id) |
| 16 | }(i) // pass loop variable to avoid closure capture bug |
| 17 | } |
| 18 | |
| 19 | wg.Wait() |
| 20 | fmt.Println("all workers done") |
| 21 | } |
warning
Channels are typed conduits for communication between goroutines. They support sending and receiving values with the <- operator. A channel can be unbuffered, which enforces synchronous handoff, or buffered, which allows a limited number of sends to proceed without an immediate receiver.
| 1 | package main |
| 2 | |
| 3 | import "fmt" |
| 4 | |
| 5 | func main() { |
| 6 | ch := make(chan int) // unbuffered |
| 7 | |
| 8 | go func() { |
| 9 | ch <- 42 // blocks until main receives |
| 10 | }() |
| 11 | |
| 12 | value := <-ch |
| 13 | fmt.Println(value) // 42 |
| 14 | } |
Buffered channels decouple producers and consumers up to their capacity. They are useful for bounded queues and backpressure, but they do not eliminate synchronization — they merely defer it.
| 1 | package main |
| 2 | |
| 3 | import "fmt" |
| 4 | |
| 5 | func main() { |
| 6 | ch := make(chan string, 2) |
| 7 | ch <- "first" |
| 8 | ch <- "second" |
| 9 | |
| 10 | fmt.Println(<-ch) |
| 11 | fmt.Println(<-ch) |
| 12 | |
| 13 | close(ch) |
| 14 | |
| 15 | // Closed channels yield the zero value; use comma-ok to detect closure. |
| 16 | if v, ok := <-ch; !ok { |
| 17 | fmt.Println("channel closed", v) |
| 18 | } |
| 19 | } |
| Channel kind | Behavior | When to use |
|---|---|---|
| Unbuffered | Synchronous send/receive handshake | Point-to-point coordination, mutual exclusion |
| Buffered | Asynchronous up to capacity | Bounded queues, smoothing burstiness |
| Closed | Signals no more values; ok = false on receive | Producer completion broadcast |
| nil | Send and receive block forever | Useful for disabling select cases |
best practice
The select statement lets a goroutine wait on multiple channel operations simultaneously. It is the control structure for timeouts, cancellation, non-blocking sends, and multiplexing inputs.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func main() { |
| 9 | ch1 := make(chan string) |
| 10 | ch2 := make(chan string) |
| 11 | |
| 12 | go func() { |
| 13 | time.Sleep(100 * time.Millisecond) |
| 14 | ch1 <- "from ch1" |
| 15 | }() |
| 16 | |
| 17 | go func() { |
| 18 | time.Sleep(200 * time.Millisecond) |
| 19 | ch2 <- "from ch2" |
| 20 | }() |
| 21 | |
| 22 | for i := 0; i < 2; i++ { |
| 23 | select { |
| 24 | case msg := <-ch1: |
| 25 | fmt.Println(msg) |
| 26 | case msg := <-ch2: |
| 27 | fmt.Println(msg) |
| 28 | } |
| 29 | } |
| 30 | } |
A select with a default case becomes non-blocking. This is useful for polling, dropping work when a channel is full, or implementing rate limiters.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func main() { |
| 9 | ch := make(chan string) |
| 10 | |
| 11 | select { |
| 12 | case msg := <-ch: |
| 13 | fmt.Println("received", msg) |
| 14 | case <-time.After(200 * time.Millisecond): |
| 15 | fmt.Println("timeout") |
| 16 | default: |
| 17 | fmt.Println("no message available") |
| 18 | } |
| 19 | } |
warning
The sync package provides low-level synchronization primitives. Channels are elegant, but sometimes a mutex is the clearest tool — for example, protecting a small cache or a struct with complex invariants.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | ) |
| 7 | |
| 8 | type Counter struct { |
| 9 | mu sync.Mutex |
| 10 | value int |
| 11 | } |
| 12 | |
| 13 | func (c *Counter) Inc() { |
| 14 | c.mu.Lock() |
| 15 | defer c.mu.Unlock() |
| 16 | c.value++ |
| 17 | } |
| 18 | |
| 19 | func (c *Counter) Value() int { |
| 20 | c.mu.Lock() |
| 21 | defer c.mu.Unlock() |
| 22 | return c.value |
| 23 | } |
| 24 | |
| 25 | func main() { |
| 26 | var wg sync.WaitGroup |
| 27 | c := &Counter{} |
| 28 | |
| 29 | for i := 0; i < 1000; i++ { |
| 30 | wg.Add(1) |
| 31 | go func() { |
| 32 | defer wg.Done() |
| 33 | c.Inc() |
| 34 | }() |
| 35 | } |
| 36 | |
| 37 | wg.Wait() |
| 38 | fmt.Println(c.Value()) // 1000 |
| 39 | } |
Use sync.RWMutex when reads heavily outnumber writes. Use sync.Once for one-time initialization and sync.Pool for reusing temporary objects to reduce allocation pressure.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | ) |
| 7 | |
| 8 | func main() { |
| 9 | var once sync.Once |
| 10 | var result string |
| 11 | |
| 12 | for i := 0; i < 10; i++ { |
| 13 | go func() { |
| 14 | once.Do(func() { |
| 15 | result = "initialized once" |
| 16 | fmt.Println("running once") |
| 17 | }) |
| 18 | }() |
| 19 | } |
| 20 | |
| 21 | // In real code, use a WaitGroup here. |
| 22 | fmt.Println(result) |
| 23 | } |
| Primitive | Purpose | Typical use case |
|---|---|---|
| sync.Mutex | Exclusive access to shared state | Counters, caches, in-memory state |
| sync.RWMutex | Many readers, few writers | Config stores, registries, lookup tables |
| sync.WaitGroup | Wait for a set of goroutines to finish | Fan-out, parallel workers |
| sync.Once | Run initialization exactly once | Singletons, lazy setup |
| sync.Pool | Reuse temporary objects | Buffers, structs in hot paths |
| sync.Map | Concurrent map with lock-free reads | Append-only caches, global registries |
best practice
The context package carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. It is the standard mechanism for making operations cancellable and timeout-aware.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func slowOperation(ctx context.Context) (string, error) { |
| 10 | select { |
| 11 | case <-time.After(2 * time.Second): |
| 12 | return "done", nil |
| 13 | case <-ctx.Done(): |
| 14 | return "", ctx.Err() |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | func main() { |
| 19 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) |
| 20 | defer cancel() |
| 21 | |
| 22 | result, err := slowOperation(ctx) |
| 23 | if err != nil { |
| 24 | fmt.Println("error:", err) |
| 25 | return |
| 26 | } |
| 27 | fmt.Println(result) |
| 28 | } |
Always pass context.Context as the first argument to functions that perform I/O or long-running work. Name the parameter ctx. Call cancel() when leaving a scope to release resources associated with the context.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func worker(ctx context.Context, id int, ch chan<- string) { |
| 10 | for { |
| 11 | select { |
| 12 | case <-ctx.Done(): |
| 13 | fmt.Printf("worker %d stopping\n", id) |
| 14 | return |
| 15 | case <-time.After(200 * time.Millisecond): |
| 16 | ch <- fmt.Sprintf("worker %d tick", id) |
| 17 | } |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | func main() { |
| 22 | ctx, cancel := context.WithCancel(context.Background()) |
| 23 | ch := make(chan string) |
| 24 | |
| 25 | for i := 1; i <= 3; i++ { |
| 26 | go worker(ctx, i, ch) |
| 27 | } |
| 28 | |
| 29 | go func() { |
| 30 | time.Sleep(500 * time.Millisecond) |
| 31 | cancel() |
| 32 | }() |
| 33 | |
| 34 | for msg := range ch { |
| 35 | fmt.Println(msg) |
| 36 | } |
| 37 | } |
warning
A worker pool limits concurrency to a fixed number of goroutines processing jobs from a shared channel. Pools prevent resource exhaustion, provide natural backpressure, and make throughput predictable.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | ) |
| 7 | |
| 8 | func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) { |
| 9 | defer wg.Done() |
| 10 | for j := range jobs { |
| 11 | results <- j * 2 |
| 12 | fmt.Printf("worker %d processed job %d\n", id, j) |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | func main() { |
| 17 | const numWorkers = 3 |
| 18 | const numJobs = 9 |
| 19 | |
| 20 | jobs := make(chan int, numJobs) |
| 21 | results := make(chan int, numJobs) |
| 22 | var wg sync.WaitGroup |
| 23 | |
| 24 | for w := 1; w <= numWorkers; w++ { |
| 25 | wg.Add(1) |
| 26 | go worker(w, jobs, results, &wg) |
| 27 | } |
| 28 | |
| 29 | for j := 1; j <= numJobs; j++ { |
| 30 | jobs <- j |
| 31 | } |
| 32 | close(jobs) |
| 33 | |
| 34 | go func() { |
| 35 | wg.Wait() |
| 36 | close(results) |
| 37 | }() |
| 38 | |
| 39 | for r := range results { |
| 40 | fmt.Println("result:", r) |
| 41 | } |
| 42 | } |
Notice the direction of the channel types in the worker signature: jobs <-chan int for receive-only and results chan<- int for send-only. Directional channel types document intent and catch misuse at compile time.
info
These patterns recur in production Go code. Learn them by name and know when to apply each one.
Fan-out distributes work to multiple workers. Fan-in merges their results into a single channel. This pattern scales naturally and is the backbone of many concurrent pipelines.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | ) |
| 7 | |
| 8 | func producer(nums []int) <-chan int { |
| 9 | out := make(chan int) |
| 10 | go func() { |
| 11 | defer close(out) |
| 12 | for _, n := range nums { |
| 13 | out <- n |
| 14 | } |
| 15 | }() |
| 16 | return out |
| 17 | } |
| 18 | |
| 19 | func square(in <-chan int) <-chan int { |
| 20 | out := make(chan int) |
| 21 | go func() { |
| 22 | defer close(out) |
| 23 | for n := range in { |
| 24 | out <- n * n |
| 25 | } |
| 26 | }() |
| 27 | return out |
| 28 | } |
| 29 | |
| 30 | func merge(cs ...<-chan int) <-chan int { |
| 31 | var wg sync.WaitGroup |
| 32 | out := make(chan int) |
| 33 | |
| 34 | output := func(in <-chan int) { |
| 35 | defer wg.Done() |
| 36 | for n := range in { |
| 37 | out <- n |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | wg.Add(len(cs)) |
| 42 | for _, c := range cs { |
| 43 | go output(c) |
| 44 | } |
| 45 | |
| 46 | go func() { |
| 47 | wg.Wait() |
| 48 | close(out) |
| 49 | }() |
| 50 | |
| 51 | return out |
| 52 | } |
A pipeline chains stages where each stage reads from an inbound channel and writes to an outbound channel. Stages run concurrently and can be composed, tested, and reused independently.
| 1 | package main |
| 2 | |
| 3 | import "fmt" |
| 4 | |
| 5 | func generator(nums ...int) <-chan int { |
| 6 | out := make(chan int) |
| 7 | go func() { |
| 8 | defer close(out) |
| 9 | for _, n := range nums { |
| 10 | out <- n |
| 11 | } |
| 12 | }() |
| 13 | return out |
| 14 | } |
| 15 | |
| 16 | func double(in <-chan int) <-chan int { |
| 17 | out := make(chan int) |
| 18 | go func() { |
| 19 | defer close(out) |
| 20 | for n := range in { |
| 21 | out <- n * 2 |
| 22 | } |
| 23 | }() |
| 24 | return out |
| 25 | } |
| 26 | |
| 27 | func main() { |
| 28 | for v := range double(double(generator(1, 2, 3))) { |
| 29 | fmt.Println(v) // 4, 8, 12 |
| 30 | } |
| 31 | } |
A done channel signals cancellation to a pipeline. Closing it unblocks all receivers so goroutines can exit without leaking. In modern Go, prefer context.Context for this, but the done-channel idiom is still useful for internal stages.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func emit(done <-chan struct{}, nums ...int) <-chan int { |
| 9 | out := make(chan int) |
| 10 | go func() { |
| 11 | defer close(out) |
| 12 | for _, n := range nums { |
| 13 | select { |
| 14 | case out <- n: |
| 15 | case <-done: |
| 16 | return |
| 17 | } |
| 18 | } |
| 19 | }() |
| 20 | return out |
| 21 | } |
| 22 | |
| 23 | func main() { |
| 24 | done := make(chan struct{}) |
| 25 | out := emit(done, 1, 2, 3, 4, 5) |
| 26 | |
| 27 | go func() { |
| 28 | time.Sleep(10 * time.Millisecond) |
| 29 | close(done) |
| 30 | }() |
| 31 | |
| 32 | for v := range out { |
| 33 | fmt.Println(v) |
| 34 | } |
| 35 | } |
Concurrency bugs are notoriously hard to reproduce. The best defense is knowing the common failure modes and writing code that makes them impossible by construction.
| Pitfall | How it happens | Defense |
|---|---|---|
| Data race | Two goroutines access the same variable with at least one write and no synchronization | Use channels, mutexes, or atomic; run with -race |
| Deadlock | Goroutines wait on each other indefinitely | Establish lock ordering; avoid holding locks while sending on channels |
| Goroutine leak | A goroutine blocks forever waiting on a send or receive | Use buffered channels or context cancellation; always have an exit path |
| Sending on closed channel | A producer writes after the channel is closed | Only the sender that closes the channel should send; use sync.Once for close |
| Loop variable capture | Goroutine closure captures a shared loop variable | Pass the variable as an argument |
| Unbounded concurrency | Spawning a goroutine per request without limits | Use worker pools or semaphores to cap parallelism |
| Ignoring ctx.Done() | Long-running loops never check cancellation | Check ctx.Done() or ctx.Err() in select and loops |
danger
Go ships with a dynamic race detector that instruments memory accesses and synchronization events. It reports data races with a stack trace of both conflicting accesses, making it the fastest way to find concurrency bugs.
| 1 | # Run tests with the race detector |
| 2 | go test -race ./... |
| 3 | |
| 4 | # Run a specific program with the race detector |
| 5 | go run -race ./cmd/server |
| 6 | |
| 7 | # Build a race-instrumented binary |
| 8 | go build -race -o server-race ./cmd/server |
The race detector is not a static analyzer; it only reports races that actually occur during execution. Exercise concurrent code paths in tests, ideally under load or with randomized timings, to maximize coverage.
For simple counters and flags, the sync/atomic package provides lock-free primitives that are faster than mutexes. Use them only for single values; do not try to protect complex invariants with atomic alone.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | "sync/atomic" |
| 7 | ) |
| 8 | |
| 9 | func main() { |
| 10 | var counter atomic.Int64 |
| 11 | var wg sync.WaitGroup |
| 12 | |
| 13 | for i := 0; i < 1000; i++ { |
| 14 | wg.Add(1) |
| 15 | go func() { |
| 16 | defer wg.Done() |
| 17 | counter.Add(1) |
| 18 | }() |
| 19 | } |
| 20 | |
| 21 | wg.Wait() |
| 22 | fmt.Println(counter.Load()) // 1000 |
| 23 | } |
info
A semaphore bounds the number of goroutines that can access a resource at once. In Go, a buffered channel is the idiomatic semaphore: sending acquires a slot and receiving releases it. The golang.org/x/sync/semaphore package provides a weighted semaphore when different tasks consume different amounts of capacity.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func main() { |
| 10 | const maxConcurrency = 3 |
| 11 | sem := make(chan struct{}, maxConcurrency) |
| 12 | var wg sync.WaitGroup |
| 13 | |
| 14 | for i := 1; i <= 10; i++ { |
| 15 | wg.Add(1) |
| 16 | go func(id int) { |
| 17 | defer wg.Done() |
| 18 | sem <- struct{}{} // acquire |
| 19 | defer func() { <-sem }() // release |
| 20 | |
| 21 | fmt.Printf("task %d running\n", id) |
| 22 | time.Sleep(100 * time.Millisecond) |
| 23 | }(i) |
| 24 | } |
| 25 | |
| 26 | wg.Wait() |
| 27 | } |
golang.org/x/sync/errgroup extends sync.WaitGroup with error propagation and context cancellation. It is ideal for fan-out tasks where any failure should abort the rest.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "time" |
| 7 | |
| 8 | "golang.org/x/sync/errgroup" |
| 9 | ) |
| 10 | |
| 11 | func fetch(ctx context.Context, url string) error { |
| 12 | select { |
| 13 | case <-time.After(100 * time.Millisecond): |
| 14 | fmt.Println("fetched", url) |
| 15 | return nil |
| 16 | case <-ctx.Done(): |
| 17 | return ctx.Err() |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | func main() { |
| 22 | g, ctx := errgroup.WithContext(context.Background()) |
| 23 | urls := []string{"a", "b", "c"} |
| 24 | |
| 25 | for _, url := range urls { |
| 26 | g.Go(func() error { |
| 27 | return fetch(ctx, url) |
| 28 | }) |
| 29 | } |
| 30 | |
| 31 | if err := g.Wait(); err != nil { |
| 32 | fmt.Println("group failed:", err) |
| 33 | } |
| 34 | } |
best practice
Use this checklist when reviewing concurrent Go code. Most production incidents are caught by the first three items.
| Check | Method | Details |
|---|---|---|
| Data races | go test -race | Run on every package with concurrent code |
| Goroutine leaks | Static review + tests | Every goroutine must have an exit path |
| Channel ownership | Review | Only the owner sends and closes a channel |
| Cancellation | Context propagation | Pass ctx and respect Done() |
| Concurrency limits | Worker pools / semaphores | Avoid spawning unbounded goroutines |
| Lock ordering | Review | Acquire multiple locks in a fixed order |
| Loop variables | Review | Pass loop vars into goroutines as arguments |
| Timeouts | context.WithTimeout | No operation should wait forever |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.