Overview

Go's concurrency is sold as simple, and it is, once you stop trying to map it onto threads. The confusion usually comes from treating goroutines like threads and channels like queues. They're neither, and the differences matter more than the similarities.

Here's the mental model that's served me, plus the patterns I use and the bugs I've written.

A goroutine is not a thread

OS threadGoroutine
Starting cost~1MB stack, microseconds to start~2KB, nanoseconds to start
Managed byOS schedulerGo runtime
Practical limitThousandsMillions
Blocking a syscallBlocks the threadRuntime moves other goroutines off
CommunicationShared memory + locksChannels (usually)

The last row is the one that matters. When a goroutine makes a blocking syscall, the Go runtime doesn't stall — it detaches the goroutine from the OS thread and runs other goroutines on that thread. This is why you can write blocking-looking code and have it scale.

The cost of a goroutine is small enough that the mental model "just start a goroutine" is usually correct. You don't need a pool.

Channels: the two rules

ch := make(chan int)        // unbuffered
ch := make(chan int, 10)    // buffered, capacity 10

An unbuffered send blocks until a receiver is ready. This is a synchronization point, not a queue. A buffered send blocks only when the buffer is full.

Most bugs come from mixing these up. A common one:

ch := make(chan int)   // unbuffered
ch <- 1                // blocks forever, no receiver

This is a deadlock and Go detects it at runtime with "fatal error: all goroutines are asleep - deadlock!" — but only if the main goroutine is the one blocked. If it's a worker goroutine, the leak is silent.

Closing: the rule that matters

Only the sender closes a channel. Never the receiver. Closing a channel twice panics. Sending on a closed channel panics.

// Wrong — receiver closing
go func() {
    for v := range ch {
        process(v)
    }
    close(ch)   // panic if a sender is still active
}()

// Right — sender closes when done sending
go func() {
    defer close(ch)
    for _, item := range items {
        ch <- item
    }
}()

The receiving side detects closure with the two-value form:

for v := range ch {
    // loop ends automatically when ch is closed
}

// or explicitly
v, ok := <-ch
if !ok {
    // channel is closed
}

The range form is what you want 95% of the time. It exits cleanly when the channel closes and there's nothing left.

select: waiting on several channels

select {
case msg := <-messages:
    handle(msg)
case err := <-errors:
    log.Fatal(err)
case <-time.After(5 * time.Second):
    log.Println("timeout")
}

select blocks until one case can proceed, then runs it. If multiple are ready, one is chosen at random — which is a feature, not a bug, because it prevents starvation.

The default case makes it non-blocking:

select {
case msg := <-ch:
    handle(msg)
default:
    // nothing available, do something else
}

And the empty select blocks forever, which is occasionally useful:

select {}   // deadlock, by design

Cancellation with context

The context package is the standard way to propagate cancellation. Any goroutine that might outlive its caller should take a context.

func worker(ctx context.Context, jobs <-chan Job) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case job, ok := <-jobs:
            if !ok {
                return nil
            }
            if err := process(ctx, job); err != nil {
                return err
            }
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := worker(ctx, jobs); err != nil {
        log.Fatal(err)
    }
}

The defer cancel() is not optional. Even if the context times out naturally, not calling cancel leaks the timer until it fires. go vet catches this, but only if you run it.

Worker pools: the pattern that shows up everywhere

func processAll(ctx context.Context, items []Item, workers int) error {
    jobs := make(chan Item)
    results := make(chan Result)
    errs := make(chan error, 1)

    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                result, err := process(ctx, item)
                if err != nil {
                    select {
                    case errs <- err:
                    default:
                    }
                    return
                }
                results <- result
            }
        }()
    }

    go func() {
        defer close(jobs)
        for _, item := range items {
            select {
            case jobs <- item:
            case <-ctx.Done():
                return
            }
        }
    }()

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

    for range results {
        // consume results
    }

    select {
    case err := <-errs:
        return err
    default:
        return nil
    }
}

This is a lot of boilerplate, and it's the kind of thing that exists because the standard library doesn't provide a pool. In practice, most teams write this once and reuse it. Some reach for golang.org/x/sync/errgroup, which is cleaner for the common case:

import "golang.org/x/sync/errgroup"

func processAll(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10)

    for _, item := range items {
        item := item
        g.Go(func() error {
            return process(ctx, item)
        })
    }

    return g.Wait()
}

That's the whole worker pool in ten lines. g.SetLimit(10) bounds concurrency. g.Wait() returns the first non-nil error and cancels the context. For anything short of a custom scheduling policy, errgroup is what you want.

Data races: the bug that only shows up in production

counter := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++   // data race
    }()
}
wg.Wait()
fmt.Println(counter)   // not reliably 1000

Run the test with the race detector:

go test -race ./...
go run -race main.go

It catches this. It's about 5–10x slower, so you don't run it in production, but you should absolutely run it in CI. Every Go project I've worked on that didn't run -race in CI had latent races that showed up months later.

Fix the counter with a mutex or, better, an atomic:

var counter atomic.Int64

go func() {
    defer wg.Done()
    counter.Add(1)
}()

Atoms are faster than mutexes when the operation is simple. For anything more complex than incrementing or swapping, use a mutex.

"Share memory by communicating"

The Go proverb is often quoted and often misapplied. It doesn't mean channels are always right and mutexes are always wrong. It means the default should be channels, because the synchronization is built into the data flow.

In practice:

UseWhen
ChannelsPassing ownership of data between goroutines, coordinating work, signaling
MutexProtecting a struct that's accessed by multiple methods, caches, counters
AtomicSingle-value operations on integers and pointers

A cache with a sync.RWMutex is cleaner than a cache implemented with channels. A pipeline of processing stages is cleaner with channels. The choice is about which one expresses the intent more directly.

What I'd tell someone learning this

Write the -race flag into your Makefile now. It's the single most useful concurrency tool Go has and the hardest to remember to use.

Then learn errgroup before you learn to write your own worker pool. Nine times out of ten, errgroup is what you actually needed.