
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.
The primitives come first (goroutines, channels, select, the sync types) as compact reference material. Then the patterns you came here for: fan-out/fan-in, worker pools, pipelines, context cancellation. If you already know the basics, skip to the patterns. If you don't, read from the top.
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. It defaults to the number of logical CPUs. Since Go 1.25, on Linux that default also respects the container's cgroup CPU limit (for example a Kubernetes CPU limit) and the runtime adjusts it if the limit changes. Setting GOMAXPROCS yourself turns that behavior off.
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Hello from %s (iteration %d)\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// Launch two goroutines
go sayHello("Goroutine A")
go sayHello("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:
go func(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"
func fetchData(ch chan string) {
// Simulate fetching data from a database
result := "user_data_from_db"
ch <- result // Send result into the channel
}
func main() {
ch := make(chan string) // Create an unbuffered channel
go fetchData(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(chan int, 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"
)
func fetchFromAPI(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "API response"
}
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "Cache hit"
}
func main() {
apiCh := make(chan string, 1)
cacheCh := make(chan string, 1)
go fetchFromAPI(apiCh)
go fetchFromCache(cacheCh)
// Use select to take whichever responds first
select {
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"
)
func processItem(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)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Increment counter before launching goroutine
go processItem(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.
Go 1.25 added WaitGroup.Go, which does the Add(1) and Done() for you, so this mistake can't happen. On Go 1.25 or newer, prefer it:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
// Go 1.25+: wg.Go calls Add(1) before starting and Done() when the func returns
wg.Go(func() {
fmt.Printf("Processing item %d\n", i)
})
}
wg.Wait()
fmt.Println("All items processed!")
}go vet also ships a waitgroup check since 1.25 that flags misplaced Add calls.
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.
package main
import (
"fmt"
"sync"
)
// Cache is safe for concurrent use. Reads share the lock; writes take it exclusively.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
cache.Set(fmt.Sprintf("user:%d", n), fmt.Sprintf("name-%d", n))
}(i)
}
wg.Wait()
v, ok := cache.Get("user:7")
fmt.Println(v, ok) // name-7 true
}Use defer right after locking so an early return or panic can't leave the lock held. Don't copy a struct that contains a mutex; pass a pointer.
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.
package main
import (
"fmt"
"sync"
)
type Config struct {
DSN string
}
var (
cfg *Config
cfgOnce sync.Once
)
// loadConfig runs the expensive setup exactly once, no matter how many goroutines call it.
func loadConfig() *Config {
cfgOnce.Do(func() {
fmt.Println("loading config...") // Printed once
cfg = &Config{DSN: "postgres://localhost/app"}
})
return cfg
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = loadConfig()
}()
}
wg.Wait()
fmt.Println(loadConfig().DSN)
}Go 1.21 also added sync.OnceValue and sync.OnceFunc wrappers when you just want a lazily computed value.
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.
package main
import (
"fmt"
"sync"
)
// produce sends n values tagged with the producer's name, then closes.
func produce(name string, n int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- fmt.Sprintf("%s-%d", name, i)
}
}()
return out
}
// merge fans in any number of channels into one.
// The output closes only after every input is drained.
func merge(inputs ...<-chan string) <-chan string {
out := make(chan string)
var wg sync.WaitGroup
for _, in := range inputs {
wg.Add(1)
go func(c <-chan string) {
defer wg.Done()
for v := range c {
out <- v
}
}(in)
}
// Close out once all forwarders finish, so the reader's range ends
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
// Fan-out: three independent producers
a := produce("a", 2)
b := produce("b", 2)
c := produce("c", 2)
// Fan-in: one channel, read until it closes
count := 0
for v := range merge(a, b, c) {
fmt.Println(v)
count++
}
fmt.Println("received", count, "values") // received 6 values
}The WaitGroup tracks the forwarding goroutines, and a separate goroutine closes out once they all finish. Output order varies between runs.
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"
)
func worker(id int, jobs <-chan int, 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
}
}
func main() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// Start workers
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // Signal no more jobs
// Wait for workers then close results
go func() {
wg.Wait()
close(results)
}()
// Collect results
for 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.
package main
import (
"context"
"fmt"
)
// gen emits the given numbers, stopping early if ctx is cancelled.
func gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
// sq squares each value it receives.
func sq(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// odd passes through only odd values.
func odd(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if n%2 == 0 {
continue
}
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Stops every stage if main returns early
for v := range odd(ctx, sq(ctx, gen(ctx, 1, 2, 3, 4, 5))) {
fmt.Println(v) // 1, 9, 25
}
}Every send sits in a select with ctx.Done(), so if the consumer stops reading and cancels, upstream stages exit instead of blocking forever.
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"
)
func longRunningTask(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("%s: cancelled (%v)\n", name, ctx.Err())
return
default:
fmt.Printf("%s: working...\n", name)
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
// Create a context that cancels after 500ms
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() // Always call cancel to release resources
go longRunningTask(ctx, "Worker-1")
go longRunningTask(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!
func unsafeCounter() {
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
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 Mutex
func mutexCounter() {
var count int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Mutex count:", count) // Always 1000
}
// BETTER: Fixed with atomic operations
func atomicCounter() {
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count.Add(1) // Lock-free atomic increment
}()
}
wg.Wait()
fmt.Println("Atomic count:", count.Load()) // Always 1000
}
func main() {
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.
Frequently Asked Questions
A goroutine is a lightweight concurrent function managed by the Go runtime, started with the go keyword. Unlike OS threads, goroutines use only a few kilobytes of stack space and are multiplexed onto a smaller number of threads, making it practical to run thousands or even millions concurrently.
Channels are typed conduits that let goroutines send and receive values safely. You create a channel with make(chan Type), send values using ch <- value, and receive with value := <-ch. Channels synchronize execution by blocking until both the sender and receiver are ready.
Unbuffered channels block the sender until a receiver is ready, providing synchronous communication. Buffered channels have a capacity and only block the sender when the buffer is full, allowing asynchronous sends up to the buffer size.
Use context cancellation, done channels, or timeouts to signal goroutines when they should stop. Always ensure every goroutine you launch has a clear exit path, and use select statements to listen for cancellation signals alongside regular work.
Fan-out starts multiple goroutines to process work from a single channel in parallel. Fan-in merges results from multiple goroutines into a single channel. These patterns, along with worker pools and pipelines, help structure concurrent Go programs for efficient parallel processing.