Concurrency in Go: From Goroutines to Production-Grade Patterns
Go makes concurrency feel simple without hiding the sharp edges. This post builds a solid mental model, then walks through patterns, pitfalls, timers/tickers, and how to ship reliable concurrent systems in production.
Table of Contents
Mental model
Goroutines (lifecycle, panics, leaks)
Channels (unbuffered vs buffered, closing rules)
select (timeouts, cancellation, non-blocking)
Timers & Tickers (timeouts, heartbeats, backoff)
Context & cancellation (propagation, deadlines)
Synchronization (WaitGroup, Mutex/RWMutex, Cond, Once, Pool, atomics)
Structured concurrency (errgroup), semaphores, bounded parallelism
Core patterns (fan-out/fan-in, pipelines, worker pools, parallel map)
Performance (scheduler, GOMAXPROCS, backpressure)
Testing & debugging (race detector, pprof, trace)
Production checklist
Closing thoughts
1) Mental model
Goroutines are lightweight, multiplexed on OS threads by Go’s scheduler.
Channels synchronize and communicate; sends/receives establish happens-before relationships.
Prefer “share memory by communicating” (channels) over locks—unless contention or structure makes locks clearer/faster.
2) Goroutines
Launch & lifecycle
go func() {
// work
}()
A goroutine ends when its function returns (or calls
runtime.Goexit()).If no other goroutine is alive, the program exits—even if a goroutine is mid-work.
Avoid goroutine leaks
Leaks happen when a goroutine can no longer make progress (e.g., waiting to send on a channel no one reads). Always provide a cancellation path (context or a done channel).
Handling panics inside goroutines
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v", r)
}
}()
doWork()
}()
Panics in background goroutines won’t crash your program unless they reach the top of the stack—decide whether to crash early or isolate.
3) Channels
Unbuffered vs buffered
Unbuffered: send blocks until a receiver is ready (great for hand-off & pacing).
Buffered: allows queueing up to capacity (great for decoupling) — but don’t use unbounded buffers to hide backpressure.
Close rules
Only the sender should close a channel.
Close to signal “no more values.” Receivers iterate with
for v := range ch.
jobs := make(chan int)
go func() {
defer close(jobs)
for i := 0; i < 10; i++ { jobs <- i }
}()
for j := range jobs {
fmt.Println("got", j)
}
4) select
Multiplex multiple channel ops; add default for non-blocking behavior.
select {
case v := <-in:
use(v)
case <-ctx.Done():
return
default:
// no data right now; do something else or sleep
}
Use select for: timeouts, cancellation, fan-in, heartbeats, rate limiting.
5) Timers & Tickers (Time-based concurrency)
Quick timeout
select {
case res := <-workCh:
_ = res
case <-time.After(800 * time.Millisecond):
log.Println("timed out")
}
Use time.After sparingly in tight loops—it allocates a new timer each time. Prefer a reusable time.Timer.
Cancellable/resettable timer
t := time.NewTimer(2 * time.Second)
defer t.Stop()
select {
case <-t.C:
fmt.Println("fired")
case <-ctx.Done():
if !t.Stop() { <-t.C } // drain if already fired
}
Backoff & retries with a timer
backoff := 100 * time.Millisecond
for attempt := 1; attempt <= 5; attempt++ {
if err := do(); err == nil { break }
if backoff > time.Second { backoff = time.Second }
timer := time.NewTimer(backoff)
<-timer.C
timer.Stop()
backoff *= 2
}
Debounce with time.AfterFunc
var (
mu sync.Mutex
deb *time.Timer
)
debounce := func(d time.Duration, fn func()) {
mu.Lock()
defer mu.Unlock()
if deb != nil && deb.Stop() {}
deb = time.AfterFunc(d, fn)
}
Tickers: periodic triggers
tick := time.NewTicker(500 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-tick.C:
poll()
case <-ctx.Done():
return
}
}
Gotcha: Ticker.C is a 1-slot channel. If your work overruns the interval, ticks accumulate at most one; you may drop or coalesce ticks.
Dropping late ticks:
select {
case <-tick.C:
doWork()
default:
// still busy; skip
}
Rate limiting via ticker:
lim := time.NewTicker(20 * time.Millisecond) // ~50/sec
defer lim.Stop()
for j := range jobs {
<-lim.C
process(j)
}
6) Context & Cancellation
Context carries deadlines, timeouts, and cancellation signals through call graphs.
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
select {
case <-do(ctx):
case <-ctx.Done():
return ctx.Err()
}
Always accept a
context.Contextas first param in request-scoped functions.Don’t store large objects inside context values; they’re for request-scoped data only.
Use
WithCancelin producers; consumers shouldselectonctx.Done().
7) Synchronization toolbox
WaitGroup
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() { defer wg.Done(); work() }()
}
wg.Wait()
Call
Addbefore launching the goroutine to avoid racy zero-count waits.
Mutex / RWMutex
Locks protect in-process shared state; prefer small critical sections.
RWMutexhelps when reads dominate and sections are long enough to amortize lock overhead.
Cond (advanced coordination)
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := 0
go func() {
mu.Lock()
for queue == 0 { cond.Wait() }
queue--
mu.Unlock()
}()
mu.Lock()
queue++
cond.Signal()
mu.Unlock()
Once, Pool, Atomics
sync.Once—lazy init.sync.Pool—temporary object reuse; contents can vanish at any time.sync/atomic—lock-free counters/flags. Newer Go versions have typed atomics:
var hits atomic.Int64
hits.Add(1)
n := hits.Load()
Happens-before hints
Unlock happens-before a subsequent Lock on the same Mutex.
Send on a channel happens-before the corresponding receive.
8) Structured concurrency & bounded parallelism
errgroup (structured concurrency)
Spawns sibling goroutines tied to a context; cancels peers on first error.
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
u := u
g.Go(func() error {
return fetch(ctx, u)
})
}
if err := g.Wait(); err != nil { return err }
Semaphore for max concurrency (no external deps)
sem := make(chan struct{}, 8) // allow 8 concurrent jobs
for _, job := range jobs {
job := job
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func(){ <-sem }()
_ = process(job)
}()
}
wg.Wait()
Prefer semaphores/worker pools over “goroutine per item” to control CPU/memory and upstream load.
9) Core patterns
Fan-out / Fan-in
in := make(chan Task)
out := make(chan Result)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for t := range in {
out <- do(t)
}
}
wg.Add(4)
for i := 0; i < 4; i++ { go worker() }
go func() {
wg.Wait()
close(out)
}()
// feed
go func() { defer close(in); for _, t := range tasks { in <- t } }()
for r := range out { use(r) }
Pipeline with backpressure
Each stage pulls when ready, naturally propagating pressure upstream via blocking sends.
stage1 := func(in <-chan int) <-chan int {
out := make(chan int, 8)
go func() {
defer close(out)
for v := range in { out <- f(v) }
}()
return out
}
Parallel map with context and bounded concurrency
func ParallelMap[T any, R any](ctx context.Context, in []T, max int, fn func(context.Context, T) (R, error)) ([]R, error) {
type item struct{ i int; r R; e error }
sem := make(chan struct{}, max)
out := make(chan item, len(in))
var wg sync.WaitGroup
for i, v := range in {
i, v := i, v
select { case <-ctx.Done(): return nil, ctx.Err(); default: }
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func(){ <-sem }()
r, err := fn(ctx, v)
out <- item{i, r, err}
}()
}
go func(){ wg.Wait(); close(out) }()
res := make([]R, len(in))
for it := range out {
if it.e != nil { return nil, it.e }
res[it.i] = it.r
}
return res, nil
}
10) Performance & scheduler realities
GOMAXPROCS defaults to number of CPUs. Changing it affects parallelism, not concurrency.
Don’t fight the scheduler; avoid busy-loops. Use channels/Cond or
time.Sleepfor polling.Minimize contention (hot locks, global maps). Use sharding or reduce shared state.
Backpressure beats buffering. Let queues fill upstream and shed load predictably.
11) Testing & debugging concurrency
Race detector
go test -race ./...
go run -race ./cmd/myapp
Catches data races at runtime—including ones you didn’t think you had.
Profiles (pprof)
Expose pprof in dev:
import _ "net/http/pprof"
// then run an HTTP server; visit /debug/pprof
Analyze CPU, heap, goroutine dumps:
go tool pprof http://localhost:6060/debug/pprof/profile
Execution trace
go test -run TestX -trace trace.out
go tool trace trace.out
Great for visualizing goroutines, blocking, scheduler events.
Goroutine leaks & deadlocks
Use
pprof.Lookup("goroutine")to snapshot goroutine stacks.Deadlock symptoms: all goroutines asleep, no progress; look for cycles and sends on closed channels.
12) Production checklist (clip & ship)
Every background goroutine has a cancellation path (ctx.Done() or done).
WaitGroup: Add before go, always Done via defer.
Channels: only senders close; receivers range until closed.
Timers: reuse time.Timer in loops; Stop then drain if needed.
Tickers: always Stop; decide whether to drop/merge late ticks.
Bounded concurrency: semaphore/worker pool, not unbounded goroutines.
Context: first arg; propagate; set deadlines for I/O.
Metrics: queue depth, in-flight, successes/failures, latencies, backoff counts.
Tests: -race; fuzz edge cases; simulate cancellations/timeouts.
Profiles in staging; verify no leaks under load.
13) Closing thoughts
Go’s concurrency model is powerful because it’s composable. Goroutines, channels, select, and time primitives snap together into pipelines, pools, and schedulers that stay readable. Add structured concurrency (errgroup), bounded parallelism, and careful cancellation—and you’ll ship systems that are fast and kind to your infrastructure.
If you want, I can split this into a series (intro → patterns → timers/tickers → testing & perf) or tailor examples to your stack (e.g., Kafka consumers, HTTP servers, or database workers).