/user/kayd @ devops :~$ cat go-concurrency.md

Go Concurrency Cheatsheet Go Concurrency Cheatsheet

QR Code linking to: Go Concurrency Cheatsheet
Karandeep Singh
Karandeep Singh
• 7 minutes

Summary

Goroutines, channels, select and context in the shapes I actually use, plus the race detector, worker pools, and the deadlock and goroutine-leak mistakes that are easiest to ship.

Go makes starting a goroutine so cheap that the hard part moves somewhere else: knowing when they stop, and what they are allowed to touch on the way. This sheet is the small set of shapes I reuse — worker pool, select with context, WaitGroup — and the three failures that cost me the most time: the race that prints the right answer, the deadlock, and the leak. The language basics are on the Go Cheatsheet.

tip
Two rules carry most of this sheet. Whoever creates a goroutine is responsible for it ending, and whoever sends on a channel is responsible for closing it. Nearly every hang below is one of those two going unowned.

Goroutines

Docs: Effective Go: concurrency

Expand your knowledge with Go (Golang) Cheatsheet

go doWork()                    // fire and forget — usually a bug
go func() { doWork() }()       // same, with a closure

var wg sync.WaitGroup
for _, host := range hosts {
    wg.Add(1)                  // before the go statement, never inside it
    go func() {
        defer wg.Done()        // defer, so a panic still decrements
        check(host)            // Go 1.22+: loop var is per-iteration
    }()
}
wg.Wait()
warning
Before Go 1.22 the loop variable was shared, so every goroutine saw the final value and you needed go func(h string){...}(host). From 1.22 each iteration gets its own copy and the plain closure is correct. Check the go directive in your go.mod — it is the language version that decides, not the toolchain.

The Race Detector

Docs: Data Race Detector

This is the one to internalise. The program below is wrong, and it prints the right answer anyway:

restarts := 0
var wg sync.WaitGroup
for range 100 {
    wg.Add(1)
    go func() { defer wg.Done(); restarts++ }()   // unsynchronised write
}
wg.Wait()
fmt.Println("restarts:", restarts)
it succeeds, and that is the problem exit 0
$ go run race.gorestarts: 100restarts: 100# Two runs, both correct. Nothing here will ever make you suspicious.$ echo $? → 0
the same binary, built with -race
$ go run -race race.go==================WARNING: DATA RACERead at 0x00c000012168 by goroutine 14:  main.main.func1()      /home/user/fleet/race.go:13 +0x84 Previous write at 0x00c000012168 by goroutine 12:  main.main.func1()      /home/user/fleet/race.go:13 +0x96Found 2 data race(s)exit status 66
caution
A data race is undefined behaviour, not a wrong number you can test for. It can be correct on every run on a laptop and corrupt under load on a 64-core box. Run your tests with -race in CI — go test -race ./... — because that is the only place it will reliably be found.

The fix is a mutex or an atomic, not a bigger channel:

Deepen your understanding in Go Security Cheatsheet

var mu sync.Mutex
mu.Lock(); restarts++; mu.Unlock()

var restarts atomic.Int64     // simpler for a plain counter
restarts.Add(1)
restarts.Load()

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

Channels

Docs: Go tour: channels

Explore this further in Go (Golang) Cheatsheet

ch := make(chan string)       // unbuffered: send blocks until a receive
ch := make(chan string, 10)   // buffered: blocks only when full

ch <- "deploy"                // send
v := <-ch                     // receive
v, ok := <-ch                 // ok is false once closed and drained
close(ch)                     // sender closes. Never the receiver.

for v := range ch { ... }     // ends when the channel is closed
OperationOn a nil channelOn a closed channel
sendblocks foreverpanics
receiveblocks foreverreturns the zero value immediately
closepanicspanics
every goroutine blocked exit 2
$ go run dead.gofatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]:main.main()	/home/user/fleet/dead.go:5 +0x28exit status 2# An unbuffered send with nobody receiving. The runtime can only detect this# when EVERY goroutine is stuck — one leaked goroutine among many is silent.$ echo $? → 2

select

Docs: Go tour: select

select {
case v := <-ch1:          // whichever is ready first
    use(v)
case ch2 <- x:
case <-time.After(5 * time.Second):   // timeout
    return errTimeout
case <-ctx.Done():                    // cancellation
    return ctx.Err()
default:                              // non-blocking: runs if nothing is ready
}

Several ready at once means one is chosen at random, not in source order. An empty select {} blocks forever.

Discover related concepts in Bash CLI One-Liners Cheatsheet

context

Docs: context package

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()                        // always, even on the success path

ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithDeadline(parent, t)
ctx, cancel := context.WithTimeoutCause(parent, d, errSlowRegion)  // 1.21+

context.Context is the first parameter, named ctx, and never stored in a struct. Cancelling a parent cancels every child.

Uncover more details in Go Logging Cheatsheet (log/slog)

a call that outlives its deadline
$ go run ctx.goreturned after 100ms: context deadline exceededis DeadlineExceeded: true
func slowCall(ctx context.Context) error {
    select {
    case <-time.After(300 * time.Millisecond):   // the real work
        return nil
    case <-ctx.Done():
        return ctx.Err()      // DeadlineExceeded, or Canceled
    }
}
warning
A select is the only thing that makes a goroutine cancellable. A goroutine in the middle of a blocking call that does not take a context — a bare time.Sleep, a read on a socket with no deadline — will not stop when the context is cancelled. It keeps running and the caller just stops waiting.

Worker Pool

The shape to reach for when there are more jobs than you want goroutines.

Journey deeper into this topic with Python Threading & Concurrency Cheatsheet

jobs := make(chan string)
results := make(chan string)

var wg sync.WaitGroup
for w := 1; w <= 3; w++ {              // three workers, not one per host
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for h := range jobs {          // ends when jobs is closed
            results <- check(id, h)
        }
    }(w)
}
go func() { wg.Wait(); close(results) }()   // close results once workers end
go func() {
    for _, h := range hosts { jobs <- h }
    close(jobs)                             // the signal to stop
}()

for r := range results { collect(r) }
five hosts, three workers
$ go run pool.gocompleted: 5
caution
Miss the close(jobs) and the workers block in range forever, wg.Wait() never returns, and the program hangs with no error. Miss the go func(){ wg.Wait(); close(results) }() and the final range results hangs instead. Both are silent.

sync

Docs: sync package

Enrich your learning with Bash CLI One-Liners Cheatsheet

var mu sync.Mutex
mu.Lock(); defer mu.Unlock()

var rw sync.RWMutex
rw.RLock()                    // many readers, or one writer
rw.Lock()

var once sync.Once
once.Do(func() { initClient() })      // exactly once, even across goroutines
initClient := sync.OnceFunc(setup)    // 1.21+, same idea as a value

var m sync.Map                        // only for high-contention disjoint keys

The slogan is “share memory by communicating”, which is good advice and not a rule. In practice:

SituationReach for
Protecting a struct field or a countersync.Mutex, or atomic
Handing work between stagesa channel
One value computed once, read by manysync.Once
Waiting for N things to finishsync.WaitGroup
Limiting concurrency to Na buffered channel as a semaphore
sem := make(chan struct{}, 10)        // at most 10 in flight
for _, h := range hosts {
    sem <- struct{}{}                 // acquire; blocks at 10
    go func() {
        defer func() { <-sem }()      // release
        check(h)
    }()
}

A mutex guarding a map is simpler, faster and easier to read than a goroutine owning the map behind a channel. Use the channel when the handoff is the point, not when the shared state is.

Goroutine Leaks

A goroutine blocked forever is never collected, and it holds everything it references. The usual cause is a send on a channel nobody is reading any more:

git diff — fetch.gothe receiver left early
@@ -1,9 +1,11 @@ func first(urls []string) string {-    ch := make(chan string)        // unbuffered+    ch := make(chan string, len(urls))   // room for every sender     for _, u := range urls {         go func() { ch <- get(u) }()     }     return <-ch                    // takes one, returns, abandons the rest }

With an unbuffered channel the other goroutines block on the send forever. Buffering to len(urls) lets them all finish; a context the caller cancels is the other answer.

curl localhost:6060/debug/pprof/goroutine?debug=1   # counts, with stacks
go test -race ./...                                 # in CI, always
Question

Pick a service you run. If its goroutine count climbed slowly all week, would anything tell you — or would you find out when it was OOM-killed?

Gain comprehensive insights from Go (Golang) Cheatsheet

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Go Security Cheatsheet

Go security cheatsheet: exec.Command without a shell, crypto/rand vs math/rand, SQL parameters, path …

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

🍪 We use cookies for analytics. Read the privacy policy