Go Concurrency: Goroutines and Channels

BackendGoConcurrencyPerformance
Share on LinkedIn

I shipped a Go service that spawned a goroutine per incoming webhook—fine at 100 RPS, OOM at 10,000. Goroutines are not threads you allocate like candy; they are cooperative workers that need lifecycle boundaries. Channels carry data and signals between them; used together they replace most callback spaghetti.

Go runs concurrent work with goroutines—functions executing independently on the runtime scheduler. Channels are typed conduits for sending values between goroutines with synchronization.

Starting goroutines

go func() {
    if err := processJob(job); err != nil {
        log.Printf("job failed: %v", err)
    }
}()

Always know how the goroutine exits. Fire-and-forget without supervision leaks under error paths.

Channels basics

ch := make(chan Result)        // unbuffered — sync handoff
buffered := make(chan Task, 10) // async up to capacity

go func() { ch <- compute() }()
result := <-ch

Unbuffered send blocks until receive—rendezvous semantics.

Close channels when no more sends—receivers drain with for v := range ch:

close(jobs)

Only sender closes. Receiving from closed channel yields zero value and ok == false.

Worker pool pattern

func workerPool(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
    out := make(chan Result)

    var wg sync.WaitGroup
    wg.Add(n)

    worker := func() {
        defer wg.Done()
        for {
            select {
            case <-ctx.Done():
                return
            case job, ok := <-jobs:
                if !ok {
                    return
                }
                out <- process(ctx, job)
            }
        }
    }

    for i := 0; i < n; i++ {
        go worker()
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

Fixed workers bound parallelism—CPU count or small multiple for I/O.

Fan-out, fan-in

Fan-out: multiple goroutines read same channel (Go distributes). Fan-in: merge outputs with one goroutine per input channel or sync.WaitGroup + single writer.

func merge(ctx context.Context, cs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    output := func(c <-chan int) {
        defer wg.Done()
        for v := range c {
            select {
            case out <- v:
            case <-ctx.Done():
                return
            }
        }
    }

    wg.Add(len(cs))
    for _, c := range cs {
        go output(c)
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

select for multiplexing

select {
case msg := <-messages:
    handle(msg)
case err := <-errors:
    return err
case <-ctx.Done():
    return ctx.Err()
case <-time.After(5 * time.Second):
    return fmt.Errorf("timeout")
}

Default case makes non-blocking attempts—use sparingly to avoid busy loops.

Context cancellation

Pass context.Context into goroutines; exit on <-ctx.Done():

go func() {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            tick()
        }
    }
}()

HTTP handlers inherit request context—cancel propagates on client disconnect.

sync package when channels fit poorly

var mu sync.Mutex
mu.Lock()
cache[key] = val
mu.Unlock()

sync.WaitGroup waits for goroutine batches. errgroup.Group combines wait + first error return—excellent for parallel steps with shared cancel.

g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return step1(ctx) })
g.Go(func() error { return step2(ctx) })
if err := g.Wait(); err != nil { ... }

Common mistakes

Rate limiting with buffered channel

Token bucket via buffered channel of struct{}:

limiter := make(chan struct{}, 10)
for _, req := range requests {
    limiter <- struct{}{}
    go func(r Request) {
        defer func() { <-limiter }()
        handle(r)
    }(req)
}

Caps concurrent handlers without unbounded goroutines.

Pipeline cancellation

Always pass ctx into pipeline stages—cancel closes upstream when downstream fails:

select {
case out <- result:
case <-ctx.Done():
    return ctx.Err()
}

Debugging races

go test -race ./...

CI must run race detector on packages with concurrency—flakes indicate real bugs.

GOMAXPROCS

Go scheduler respects GOMAXPROCS—in Kubernetes, align with CPU limits; avoid oversubscription causing thrashing on CPU-bound worker pools.

semaphore pattern with context

func acquire(ctx context.Context, sem chan struct{}) error {
    select {
    case sem <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Combine with worker pool for bounded concurrency respecting cancellation.

Closing channels safely

Only close from sender side; receivers use for range—closing twice panics. Use sync.Once to close output channel when all workers done.

Production incident patterns

Goroutine leak from blocked channel send—metrics on runtime.NumGoroutine() alert when growth monotonic over hours.

Testing concurrency

Use deterministic channel sizes in tests to force ordering; -race on CI mandatory for packages importing sync and goroutines.

context.AfterFunc Go 1.21

Use context.AfterFunc for cleanup when ctx cancelled instead of separate goroutine select in simple cases—reduces boilerplate in connection watchdog code.

Rollout guidance

Goroutine leak detector metric alert threshold tuned after two weeks baseline production—avoid alert fatigue day one threshold zero goroutines impossible production idle state.

Team practices

Shipping Go Concurrency Goroutines Channels in production taught our team that documentation beats hero demos. We wrote runbooks covering failure modes, on-call steps, and rollback triggers before the second release, not after the first outage.

When reviewing PRs touching Go Concurrency Goroutines Channels, we ask for measurable outcomes: latency, crash rate, or support ticket volume—not subjective feels faster. If metrics are unavailable, the PR includes a DevTools trace or load test snippet proving the change.

Onboarding engineers paste a checklist into their first Go Concurrency Goroutines Channels PR description: environment setup verified, tests added or updated, accessibility spot-checked, and release notes drafted for user-visible changes.

We keep a living FAQ in the repo wiki for Go Concurrency Goroutines Channels questions repeated in Slack more than twice. FAQ entries link to source files and Slack threads, reducing tribal knowledge and repeated explanations in code review.

Resources

Frequently asked questions

When should I use a channel vs sync.Mutex?

Channels coordinate goroutines and pass ownership of data—good for pipelines and signaling. Mutexes protect shared state in place—good for caches and counters. Prefer clear ownership; do not use channels as a mutex replacement for simple increments.

How many goroutines is too many?

Goroutines are cheap (kilobyte stacks) but not free. Millions of blocked goroutines consume memory. Use worker pools for CPU-bound work; unbounded goroutines per incoming request can exhaust RAM under load.

What causes goroutine leaks?

Blocked sends/receives on unbuffered channels nobody reads, missing context cancellation, WaitGroups with wrong Add/Done counts. Always tie long-lived goroutines to context.Context cancellation.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →