Introduction

Go was built for concurrency. Goroutines are cheap enough to spawn thousands of them. Channels are a first-class language feature. The go keyword is two characters. The runtime scheduler handles everything underneath.

That ease is exactly the trap. Concurrency bugs — races, deadlocks, goroutine leaks — are among the hardest to reproduce and the most expensive to debug in production. The Go toolchain gives you -race and a good profiler, but they catch problems after the fact. The goal is to not introduce them.

This article covers the core primitives and patterns you'll use in real services: goroutines, channels, select, WaitGroup, and the worker pool pattern. By the end, you'll know how to safely parallelize work, drain results, and cancel in-flight operations cleanly.

Goroutines

A goroutine is a function executing concurrently with other goroutines in the same address space. You launch one with the go keyword:

go
func main() {
    go sayHello("world")
    time.Sleep(100 * time.Millisecond) // wait — we'll fix this shortly
}

func sayHello(name string) {
    fmt.Printf("hello, %s
", name)
}

Goroutines are not OS threads. The Go runtime multiplexes thousands of goroutines onto a much smaller number of OS threads using an M:N scheduler. Starting a goroutine costs around 2–8 KB of stack space, which grows and shrinks as needed. You can have a million goroutines in a process that would grind to a halt with a million OS threads.

The key thing to understand: goroutines are anonymous to the runtime. Once you launch one with go, you have no handle on it. You can't cancel it directly, wait for it directly, or get its return value directly. All coordination happens through channels or sync primitives — never through shared mutable state.

Note

Don't pass loop variables into goroutines

The classic bug: for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() }. By the time goroutines run, i has advanced. Capture the variable: go func(n int) { fmt.Println(n) }(i). Go 1.22+ changed loop variable semantics to prevent this, but you'll still encounter it in older codebases.

Channels

Channels are typed conduits for communication between goroutines. The Go proverb says it best: don't communicate by sharing memory; share memory by communicating.

A channel is created with make, sent to with <-, and received from with <-:

go
ch := make(chan string)   // unbuffered channel

// Sender goroutine
go func() {
    ch <- "result"           // blocks until someone receives
}()

// Receiver (in main goroutine)
msg := <-ch                  // blocks until something is sent
fmt.Println(msg)             // "result"

An unbuffered channel forces synchronisation: the sender blocks until the receiver is ready, and the receiver blocks until the sender sends. This is a rendezvous — both goroutines must be at the channel simultaneously.

You can also receive a second boolean value that tells you whether the channel is still open:

go
msg, ok := <-ch
if !ok {
    // channel was closed and drained
}

Channels can be directional. When you pass a channel to a function, narrow its type to communicate intent and prevent misuse:

pipeline.go
// produce can only send
func produce(out chan<- int) {
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

// consume can only receive
func consume(in <-chan int) {
    for n := range in {
        fmt.Println(n)
    }
}

func main() {
    ch := make(chan int)
    go produce(ch)
    consume(ch)
}

Notice close(out) in the producer. Closing a channel signals to receivers that no more values will be sent. The range loop in the consumer exits automatically when the channel is closed and drained. This is the idiomatic pipeline pattern in Go.

⚠ Gotcha

Only the sender should close a channel

Closing a channel that has already been closed panics. Sending to a closed channel also panics. The rule is simple: the goroutine that owns the channel — the one responsible for sending — is the one that closes it. Receivers never close.

Buffered channels

A buffered channel has a capacity. Sends don't block until the buffer is full, and receives don't block until the buffer is empty:

go
ch := make(chan int, 3)  // capacity 3

ch <- 1   // doesn't block
ch <- 2   // doesn't block
ch <- 3   // doesn't block
ch <- 4   // blocks — buffer is full

fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2

Buffered channels are useful when you know the volume of work upfront and want producers to run ahead of consumers without deadlocking. They're also the right tool for collecting results from a fixed number of goroutines:

fanout.go
func fetchAll(urls []string) []string {
    results := make(chan string, len(urls))

    for _, url := range urls {
        go func(u string) {
            resp, err := http.Get(u)
            if err != nil {
                results <- fmt.Sprintf("error: %v", err)
                return
            }
            defer resp.Body.Close()
            results <- fmt.Sprintf("%s -> %d", u, resp.StatusCode)
        }(url)
    }

    out := make([]string, len(urls))
    for i := range out {
        out[i] = <-results
    }
    return out
}

Because the channel has capacity equal to the number of URLs, every goroutine can send without blocking regardless of order. The collection loop receives exactly len(urls) results. No WaitGroup needed.

The select statement

select lets a goroutine wait on multiple channel operations simultaneously. It picks whichever case is ready first. If multiple are ready, it chooses at random.

go
select {
case msg := <-ch1:
    fmt.Println("from ch1:", msg)
case msg := <-ch2:
    fmt.Println("from ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
}

The time.After case is how you add timeouts to channel operations without external dependencies. Add a default case to make select non-blocking — it fires immediately if no channel is ready:

go
select {
case msg := <-ch:
    process(msg)
default:
    // nothing ready, continue
}

The most important use of select in real services is listening on both a work channel and a done/cancel channel. This is the foundation of graceful shutdown:

worker.go
func worker(jobs <-chan Job, done <-chan struct{}) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return // jobs channel closed
            }
            process(job)
        case <-done:
            return // shutdown signal received
        }
    }
}

sync.WaitGroup

When you need to fire multiple goroutines and wait for all of them to finish, sync.WaitGroup is the right tool. It keeps a counter: Add increments it, Done decrements it, and Wait blocks until it hits zero.

concurrent.go
func processAll(items []Item) {
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            process(it)
        }(item)
    }

    wg.Wait()
    fmt.Println("all items processed")
}

Always call wg.Add(1) before launching the goroutine, not inside it. If the goroutine starts and calls Add after Wait has already been called by another goroutine, you have a race.

When you also need to collect results or errors, combine WaitGroup with a channel:

collect.go
type Result struct {
    Item  Item
    Error error
}

func processAll(items []Item) []Result {
    results := make(chan Result, len(items))
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            err := process(it)
            results <- Result{Item: it, Error: err}
        }(item)
    }

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

    var out []Result
    for r := range results {
        out = append(out, r)
    }
    return out
}

The closer goroutine — go func() { wg.Wait(); close(results) }() — is a common pattern. It waits for all workers to finish, then closes the channel so the range loop in the collector exits cleanly.

Goroutine leaks

A goroutine leak is a goroutine that was started and never exits. It doesn't crash your program immediately — it just consumes memory and scheduler overhead forever. In a long-running service, goroutine leaks accumulate until the process OOMs.

The most common cause is a goroutine blocked on a channel receive that will never get a value:

go
// Leak: if the HTTP request errors, results never gets a value
// and this goroutine blocks forever
func fetchUser(id string) <-chan User {
    ch := make(chan User)
    go func() {
        user, err := httpClient.GetUser(id)
        if err != nil {
            return // channel never closed or sent to — goroutine hangs
        }
        ch <- user
    }()
    return ch
}

Fix it by always sending something — either close the channel or send a zero value on error. Better still, use context so the goroutine can exit when the caller gives up:

go
func fetchUser(ctx context.Context, id string) <-chan User {
    ch := make(chan User, 1) // buffered: sender won't block even if caller is gone
    go func() {
        user, err := httpClient.GetUser(id)
        if err != nil {
            close(ch)
            return
        }
        select {
        case ch <- user:
        case <-ctx.Done(): // caller cancelled, don't block
        }
    }()
    return ch
}

✦ Tip

Use goleak in tests

The goleak package (uber-go/goleak) detects goroutine leaks in tests. Add TestMain or a per-test defer goleak.VerifyNone(t) and it will fail the test if any goroutines are still running after cleanup. It catches leaks before they reach production.

Worker pools

Spawning one goroutine per task is fine for small, bounded workloads. For large or unbounded inputs — processing a queue, handling uploads, sending emails — you need a worker pool: a fixed number of goroutines that pull work from a shared channel.

Here's a complete, production-ready worker pool:

pool/pool.go
package pool

import (
    "context"
    "sync"
)

type Job[T any] struct {
    Input T
}

type Result[T, R any] struct {
    Input  T
    Output R
    Err    error
}

func Run[T, R any](
    ctx context.Context,
    concurrency int,
    inputs []T,
    fn func(context.Context, T) (R, error),
) []Result[T, R] {
    jobs := make(chan Job[T], len(inputs))
    results := make(chan Result[T, R], len(inputs))

    var wg sync.WaitGroup
    for range concurrency {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                select {
                case <-ctx.Done():
                    results <- Result[T, R]{Input: job.Input, Err: ctx.Err()}
                default:
                    out, err := fn(ctx, job.Input)
                    results <- Result[T, R]{Input: job.Input, Output: out, Err: err}
                }
            }
        }()
    }

    for _, input := range inputs {
        jobs <- Job[T]{Input: input}
    }
    close(jobs)

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

    var out []Result[T, R]
    for r := range results {
        out = append(out, r)
    }
    return out
}

Call it like this — fetch 200 user profiles with 10 concurrent requests:

go
results := pool.Run(ctx, 10, userIDs, func(ctx context.Context, id string) (*User, error) {
    return userService.GetUser(ctx, id)
})

for _, r := range results {
    if r.Err != nil {
        log.Printf("failed to fetch user %s: %v", r.Input, r.Err)
        continue
    }
    // use r.Output
}

The key design choices in this pool:

  • The jobs channel is pre-filled before workers start. No separate producer goroutine needed.
  • Each worker checks ctx.Done() before processing. If the context is already cancelled, it skips the work and records the cancellation error.
  • The results channel is buffered to len(inputs) so workers never block sending results.
  • The closer goroutine waits for all workers, then closes results so the collection loop exits.

Note

Tune concurrency to your bottleneck

For CPU-bound work, set concurrency to runtime.NumCPU(). For I/O-bound work (HTTP calls, DB queries), you can go much higher — 50–200 is common — but watch your downstream service's rate limits and connection pool size. There's no universal number; benchmark under realistic load.

Context and cancellation

Every goroutine that does work should respect context cancellation. context.Context is the standard mechanism in Go for propagating deadlines, timeouts, and cancel signals across goroutine boundaries. If you're writing a function that spawns goroutines, it should take a ctx context.Context as its first argument.

service/batch.go
func (s *Service) ProcessBatch(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    errs := make(chan error, len(items))
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            if err := s.processOne(ctx, it); err != nil {
                select {
                case errs <- err:
                default: // don't block if buffer is already full
                }
            }
        }(item)
    }

    wg.Wait()
    close(errs)

    for err := range errs {
        if err != nil {
            return err // return first error
        }
    }
    return nil
}

When the context times out or is cancelled, ctx.Err() returns non-nil and ctx.Done() closes. Any function that takes a context — database calls, HTTP clients, gRPC stubs — will return immediately with a context error. Your goroutines exit naturally rather than hanging.

The defer cancel() after WithTimeout is mandatory. If your function returns early (success or error), cancel releases the timeout's resources immediately rather than waiting for the deadline. Always defer it.

Incoming requests should create a context. Outgoing calls should respect one. If a goroutine doesn't take a context, it can't be cancelled — which means it can't be part of a graceful shutdown.

Summary

Concurrency in Go is powerful and the primitives are genuinely elegant. But power without discipline produces data races, deadlocks, and goroutine leaks that show up months after you ship. The rules that prevent most problems:

  • Launch goroutines with a clear answer to: how does this goroutine exit?
  • Share data via channels, not via shared memory guarded by mutexes — unless the sharing is simple and short-lived.
  • Only the sender closes a channel. Receivers never close.
  • Always capture loop variables explicitly when using them in goroutines.
  • Use sync.WaitGroup to wait for goroutines; combine with a buffered channel to collect results.
  • For bounded parallelism over large inputs, use a worker pool with a fixed concurrency.
  • Pass context to everything that does I/O. Respect ctx.Done() in long-running goroutines.
  • Run tests with -race. Add goleak to catch goroutine leaks before production.