|$ curl https://forge-ai.dev/api/markdown?path=docs/go/concurrency
$cat docs/go-concurrency.md
updated Recently·18 min read·published

Go Concurrency

GoConcurrencyAdvanced🎯Free Tools
Introduction

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

Go's motto is "Do not communicate by sharing memory; instead, share memory by communicating." Channels are the default way to coordinate goroutines. Reach for mutexes only when channels make the code harder to reason about.
Goroutines

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.

goroutine-basics.go
GO
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func 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
15func 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.

goroutine-waitgroup.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8func 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

Always pass the loop variable into the goroutine closure as an argument. Before Go 1.22, the loop variable was reused across iterations, so all goroutines could observe the same final value. Even though Go 1.22 fixes this, explicit parameter passing remains clearer and safer.
Channels

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.

unbuffered-channel.go
GO
1package main
2
3import "fmt"
4
5func 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.

buffered-channel.go
GO
1package main
2
3import "fmt"
4
5func 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 kindBehaviorWhen to use
UnbufferedSynchronous send/receive handshakePoint-to-point coordination, mutual exclusion
BufferedAsynchronous up to capacityBounded queues, smoothing burstiness
ClosedSignals no more values; ok = false on receiveProducer completion broadcast
nilSend and receive block foreverUseful for disabling select cases

best practice

Prefer unbuffered channels unless you can name a specific capacity that improves throughput or decoupling. Buffered channels hide latency but can mask backpressure and increase memory usage under load.
Select

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.

select-basics.go
GO
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func 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.

select-timeout.go
GO
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func 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

If multiple cases in a select are ready, Go chooses one pseudo-randomly. Do not rely on order of readiness for correctness. If priority matters, use nested selects or an explicit priority channel.
Sync Primitives

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.

mutex-counter.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8type Counter struct {
9 mu sync.Mutex
10 value int
11}
12
13func (c *Counter) Inc() {
14 c.mu.Lock()
15 defer c.mu.Unlock()
16 c.value++
17}
18
19func (c *Counter) Value() int {
20 c.mu.Lock()
21 defer c.mu.Unlock()
22 return c.value
23}
24
25func 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.

sync-once.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8func 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}
PrimitivePurposeTypical use case
sync.MutexExclusive access to shared stateCounters, caches, in-memory state
sync.RWMutexMany readers, few writersConfig stores, registries, lookup tables
sync.WaitGroupWait for a set of goroutines to finishFan-out, parallel workers
sync.OnceRun initialization exactly onceSingletons, lazy setup
sync.PoolReuse temporary objectsBuffers, structs in hot paths
sync.MapConcurrent map with lock-free readsAppend-only caches, global registries

best practice

Keep mutexes internal to the type that owns the data. Export methods, not the mutex itself. This prevents callers from breaking invariants by forgetting to lock or holding locks across slow operations.
Context

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.

context-timeout.go
GO
1package main
2
3import (
4 "context"
5 "fmt"
6 "time"
7)
8
9func 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
18func 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.

context-cancel.go
GO
1package main
2
3import (
4 "context"
5 "fmt"
6 "time"
7)
8
9func 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
21func 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

Do not store values in context to bypass explicit function parameters. Context values are for request-scoped metadata only: trace IDs, authentication principals, and deadlines. Using context as a hidden argument bag makes code hard to test and reason about.
Worker Pools

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.

worker-pool.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8func 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
16func 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

Size the job channel to match your expected burst. A buffered job channel lets producers enqueue work without blocking on slow workers, while a closed channel cleanly signals workers to terminate after draining remaining jobs.
Common Patterns

These patterns recur in production Go code. Learn them by name and know when to apply each one.

Fan-Out / Fan-In

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.

fanout-fanin.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8func 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
19func 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
30func 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}
Pipeline

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.

pipeline.go
GO
1package main
2
3import "fmt"
4
5func 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
16func 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
27func main() {
28 for v := range double(double(generator(1, 2, 3))) {
29 fmt.Println(v) // 4, 8, 12
30 }
31}
Done Channel

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.

done-channel.go
GO
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func 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
23func 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}
Pitfalls

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.

PitfallHow it happensDefense
Data raceTwo goroutines access the same variable with at least one write and no synchronizationUse channels, mutexes, or atomic; run with -race
DeadlockGoroutines wait on each other indefinitelyEstablish lock ordering; avoid holding locks while sending on channels
Goroutine leakA goroutine blocks forever waiting on a send or receiveUse buffered channels or context cancellation; always have an exit path
Sending on closed channelA producer writes after the channel is closedOnly the sender that closes the channel should send; use sync.Once for close
Loop variable captureGoroutine closure captures a shared loop variablePass the variable as an argument
Unbounded concurrencySpawning a goroutine per request without limitsUse worker pools or semaphores to cap parallelism
Ignoring ctx.Done()Long-running loops never check cancellationCheck ctx.Done() or ctx.Err() in select and loops

danger

Run your tests and integration builds with the race detector enabled: go test -race ./.... The race detector has runtime overhead, but it catches data races deterministically by observing memory access interleavings.
Race Detector

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.

race-detector.sh
Bash
1# Run tests with the race detector
2go test -race ./...
3
4# Run a specific program with the race detector
5go run -race ./cmd/server
6
7# Build a race-instrumented binary
8go 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.

Atomic Operations

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.

atomic-counter.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6 "sync/atomic"
7)
8
9func 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

The atomic package in Go 1.19+ adds typed wrappers such as atomic.Int64 and atomic.Pointer[T]. Prefer these over the older untyped functions to avoid easy-to-miss size and alignment bugs.
Semaphores

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.

semaphore.go
GO
1package main
2
3import (
4 "fmt"
5 "sync"
6 "time"
7)
8
9func 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}
Errgroup

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.

errgroup.go
GO
1package main
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "golang.org/x/sync/errgroup"
9)
10
11func 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
21func 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 errgroup when you need to run a bounded or unbounded set of tasks and care about the first error. Combine it with a semaphore derivative or a separate limiter when you need to cap concurrency.
Review Checklist

Use this checklist when reviewing concurrent Go code. Most production incidents are caught by the first three items.

CheckMethodDetails
Data racesgo test -raceRun on every package with concurrent code
Goroutine leaksStatic review + testsEvery goroutine must have an exit path
Channel ownershipReviewOnly the owner sends and closes a channel
CancellationContext propagationPass ctx and respect Done()
Concurrency limitsWorker pools / semaphoresAvoid spawning unbounded goroutines
Lock orderingReviewAcquire multiple locks in a fixed order
Loop variablesReviewPass loop vars into goroutines as arguments
Timeoutscontext.WithTimeoutNo operation should wait forever
$Blueprint — Engineering Documentation·Section ID: GO-CONCURRENCY·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.