Introduction
Most Go concurrency bugs come from using concurrency at all. The first question should always be: do you actually need goroutines here?
Seriously. A surprising amount of Go code spawns goroutines because it feels like the Go thing to do, not because there's a measurable bottleneck. Sequential code is easier to read, easier to debug, and has zero chance of a race condition. But when you do need concurrency -- handling thousands of connections, parallelizing independent I/O, processing a work queue -- Go's model is genuinely good.
This starts with the patterns: fan-out/fan-in, worker pools, pipelines, context cancellation. The useful stuff you came here for. Primitives -- channels, select, mutexes -- are covered after, as reference material. If you already know the basics, skip to the patterns. If you don't, scroll down first.
Goroutines: Lightweight Threads
A goroutine starts with a few kilobytes of stack. Not megabytes like OS threads. The Go runtime multiplexes them onto a smaller number of threads, so running hundreds of thousands is normal. GOMAXPROCS controls how many OS threads execute user code simultaneously -- defaults to CPU core count.
package main
import (
"fmt""time"
)
funcsayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Hello from %s (iteration %d)\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}
funcmain() {
// Launch two goroutinesgosayHello("Goroutine A")
gosayHello("Goroutine B")
// Give goroutines time to finish
time.Sleep(500 * time.Millisecond)
fmt.Println("Main function done")
}Nondeterministic output order. That's the point.
When main returns, everything dies. Even running goroutines. The time.Sleep above is a hack -- real code uses channels or sync.WaitGroup.
Anonymous goroutines show up everywhere:
gofunc(msg string) {
fmt.Println(msg)
}("Hello from an anonymous goroutine")The parentheses at the end pass the argument. Forget those inside a loop and every goroutine captures the same loop variable by reference. Classic bug. Go 1.22 fixed this with per-iteration scoping. But older code is full of it.
Channels: Communication Between Goroutines
Typed conduits. Send a value in one goroutine, receive it in another. Safe by design.
Unbuffered Channels
No internal storage. Send blocks until receive is ready. Receive blocks until send is ready. Natural synchronization points.
package main
import"fmt"funcfetchData(ch chanstring) {
// Simulate fetching data from a database
result := "user_data_from_db"
ch <- result // Send result into the channel
}
funcmain() {
ch := make(chanstring) // Create an unbuffered channelgofetchData(ch)
data := <-ch // Receive from the channel (blocks until data arrives)
fmt.Println("Received:", data)
}No time.Sleep. The receive blocks until the send happens. That's it.
Buffered Channels
Internal queue. Sends don't block until the buffer fills.
// Buffered channel with capacity of 3
jobs := make(chanint, 3)
// These sends won't block because the buffer isn't full
jobs <- 1
jobs <- 2
jobs <- 3
// This would block because the buffer is full:// jobs <- 4
fmt.Println("Buffer length:", len(jobs)) // 3
fmt.Println("Buffer capacity:", cap(jobs)) // 3Good for absorbing bursts when producers and consumers run at different speeds.
Directional Channels
Send-only: chan<- string. Receive-only: <-chan string. Compiler-enforced. The function signature tells you exactly how the channel gets used, which matters more than people think when you're reading unfamiliar code at 2 AM.
Closing a channel signals "no more values." Use range over a channel -- the loop exits automatically on close. Or the two-value form: value, ok := <-ch where ok is false when closed.
Select Statement and Multiplexing
A switch for channels. Waits on multiple operations. Whichever is ready first runs. If several are ready simultaneously, one is picked at random.
package main
import (
"fmt""time"
)
funcfetchFromAPI(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "API response"
}
funcfetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "Cache hit"
}
funcmain() {
apiCh := make(chanstring, 1)
cacheCh := make(chanstring, 1)
gofetchFromAPI(apiCh)
gofetchFromCache(cacheCh)
// Use select to take whichever responds firstselect {
case result := <-apiCh:
fmt.Println("Got from API:", result)
case result := <-cacheCh:
fmt.Println("Got from cache:", result)
case <-time.After(1 * time.Second):
fmt.Println("Timeout! Neither responded in time")
}
}Exactly what it looks like.
Adding a default case makes the select non-blocking:
select {
case msg := <-ch:
fmt.Println("Received:", msg)
default:
fmt.Println("No message available, moving on")
}Shows up in event loops and polling.
Sync Primitives
Channels are the idiomatic choice. But sometimes a mutex is simpler and a WaitGroup is all you need. The "everything should be channels" advice is wrong. Use the simplest tool that works.
WaitGroup
A counter. Add increments, Done decrements, Wait blocks until zero.
package main
import (
"fmt""sync""time"
)
funcprocessItem(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when function returns
fmt.Printf("Processing item %d...\n", id)
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
fmt.Printf("Item %d done\n", id)
}
funcmain() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Increment counter before launching goroutinegoprocessItem(i, &wg)
}
wg.Wait() // Block until all goroutines call Done()
fmt.Println("All items processed!")
}Calling wg.Add(1) inside the goroutine instead of before launching it is a race condition. Passes tests most of the time. Fails under load. Always call Add before the go statement.
Mutex and RWMutex
sync.Mutex gives exclusive access. sync.RWMutex allows multiple concurrent readers but only one writer.
Shared counter or map? Mutex. Coordinating a workflow or passing data between stages? Channels.
sync.Once
Guarantees a function runs exactly once. Lazy initialization. Config loading. Database connection setup. First caller runs it, everyone else blocks until it's done, then returns immediately.
Common Concurrency Patterns
These are the patterns you'll actually use. Everything above is reference material for when the specifics get fuzzy.
Fan-Out / Fan-In
Fan-out: distribute work across goroutines. Fan-in: merge results into a single channel. List of URLs to fetch? Fan-out to goroutines, fan-in through one results channel. The main goroutine reads until everything arrives.
Sounds simple. But the hard part is knowing when you're done. Forget to close the results channel and the reader blocks forever.
Worker Pool
Fan-out but bounded. Fixed number of workers pulling from a shared job channel. You want this instead of unbounded fan-out because launching 10,000 goroutines that all hit the same database will just take it down.
package main
import (
"fmt""sync""time"
)
funcworker(id int, jobs <-chanint, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(100 * time.Millisecond) // Simulate work
results <- job * 2 // Send result
}
}
funcmain() {
const numWorkers = 3
const numJobs = 10
jobs := make(chanint, numJobs)
results := make(chanint, numJobs)
// Start workersvar wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
goworker(w, jobs, results, &wg)
}
// Send jobsfor j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // Signal no more jobs// Wait for workers then close resultsgofunc() {
wg.Wait()
close(results)
}()
// Collect resultsfor result := range results {
fmt.Println("Result:", result)
}
}Pipeline
Stages connected by channels. Each stage reads from inbound, processes, writes to outbound. Generate numbers, square them, filter odds, print. Each stage runs independently.
Pipelines are elegant in theory. In practice, error propagation across stages is the part that gets ugly. When stage three fails, how do stages one and two find out? Context cancellation, usually. Which brings us to the most important pattern.
Context Cancellation
Every goroutine you launch needs a way to be told "stop." Without this, you leak goroutines. A service handles a request, spawns three goroutines to fetch data from different backends, the client disconnects -- but the goroutines keep running, doing work nobody will ever read. Multiply this across thousands of requests and your service slowly eats all available memory. The context package solves this. A context carries cancellation signals, deadlines, and request-scoped values. Three constructors: context.WithCancel for manual cancellation, context.WithTimeout for a duration, context.WithDeadline for a wall clock time. Always defer the cancel function.
package main
import (
"context""fmt""time"
)
funclongRunningTask(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("%s: cancelled (%v)\n", name, ctx.Err())
returndefault:
fmt.Printf("%s: working...\n", name)
time.Sleep(200 * time.Millisecond)
}
}
}
funcmain() {
// Create a context that cancels after 500ms
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defercancel() // Always call cancel to release resourcesgolongRunningTask(ctx, "Worker-1")
golongRunningTask(ctx, "Worker-2")
// Wait for context to expire
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // Give workers time to print cancellation
fmt.Println("All workers stopped")
}Every HTTP handler gets a context. Every database query should accept one. So when a client disconnects, downstream goroutines stop doing pointless work. Skip the ctx.Done() check in a background loop and you leak goroutines. Slowly at first. Then your monitoring dashboard turns red on a Tuesday afternoon.
Race Conditions and How to Avoid Them
Two goroutines. Same data. No synchronization. The result depends on timing, and the bugs only appear under load in production, never in your test suite on a quiet Wednesday.
package main
import (
"fmt""sync""sync/atomic"
)
// BAD: Race condition!funcunsafeCounter() {
var count intvar wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
count++ // DATA RACE: multiple goroutines write without sync
}()
}
wg.Wait()
fmt.Println("Unsafe count:", count) // Could be anything < 1000
}
// GOOD: Fixed with a MutexfuncmutexCounter() {
var count intvar mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Mutex count:", count) // Always 1000
}
// BETTER: Fixed with atomic operationsfuncatomicCounter() {
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
count.Add(1) // Lock-free atomic increment
}()
}
wg.Wait()
fmt.Println("Atomic count:", count.Load()) // Always 1000
}
funcmain() {
unsafeCounter()
mutexCounter()
atomicCounter()
}The unsafe version almost never prints 1000. The mutex version fixes it with locking. The atomic version is faster for simple numeric operations -- no lock overhead.
Go ships with a race detector. go test -race ./... or go run -race main.go. It instruments code at compile time and catches races found at runtime. Not perfect -- only catches races on executed paths -- but it finds a surprising amount. Put it in CI and never take it out.
Avoiding races:
- Pass ownership through channels. One goroutine owns the data at a time.
- Protect shared state with mutexes.
- Use atomic operations for counters and flags. A full mutex is overkill.
sync.Mapfor concurrent map access when keys are stable and reads dominate.- Immutable data needs no synchronization. Design for it when you can.
Conclusion
If your first instinct is go func(), stop. Write it synchronously first. Add concurrency only when you can measure the bottleneck. Most Go code doesn't need it.
And when you do add it, the choice between channels and mutexes is simpler than people make it. Passing data? Channels. Protecting state in place? Mutex. Don't force one tool into the other's job.