Skip to main content

Command Palette

Search for a command to run...

Concurrency in Go: From Goroutines to Production-Grade Patterns

Published
8 min read

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

  1. Mental model

  2. Goroutines (lifecycle, panics, leaks)

  3. Channels (unbuffered vs buffered, closing rules)

  4. select (timeouts, cancellation, non-blocking)

  5. Timers & Tickers (timeouts, heartbeats, backoff)

  6. Context & cancellation (propagation, deadlines)

  7. Synchronization (WaitGroup, Mutex/RWMutex, Cond, Once, Pool, atomics)

  8. Structured concurrency (errgroup), semaphores, bounded parallelism

  9. Core patterns (fan-out/fan-in, pipelines, worker pools, parallel map)

  10. Performance (scheduler, GOMAXPROCS, backpressure)

  11. Testing & debugging (race detector, pprof, trace)

  12. Production checklist

  13. 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.Context as first param in request-scoped functions.

  • Don’t store large objects inside context values; they’re for request-scoped data only.

  • Use WithCancel in producers; consumers should select on ctx.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 Add before launching the goroutine to avoid racy zero-count waits.

Mutex / RWMutex

  • Locks protect in-process shared state; prefer small critical sections.

  • RWMutex helps 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.Sleep for 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)

  1. Every background goroutine has a cancellation path (ctx.Done() or done).

  2. WaitGroup: Add before go, always Done via defer.

  3. Channels: only senders close; receivers range until closed.

  4. Timers: reuse time.Timer in loops; Stop then drain if needed.

  5. Tickers: always Stop; decide whether to drop/merge late ticks.

  6. Bounded concurrency: semaphore/worker pool, not unbounded goroutines.

  7. Context: first arg; propagate; set deadlines for I/O.

  8. Metrics: queue depth, in-flight, successes/failures, latencies, backoff counts.

  9. Tests: -race; fuzz edge cases; simulate cancellations/timeouts.

  10. 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).