Go Goroutines and Channels: A Practical Concurrency Guide

Backend2026-09-30TryQuickToolBox

You've probably written Go code that works fine until it doesn't—maybe a data race crashes your service under load, or a deadlock freezes your API. Concurrency is one of Go's biggest selling points, but it's also where many developers get stuck. Goroutines and channels are the building blocks, but using them correctly requires understanding their behavior and common pitfalls.

This guide walks through practical patterns for using goroutines and channels, with code examples and advice you can apply immediately.

What Are Goroutines?

A goroutine is a lightweight thread managed by the Go runtime. You create one by prefixing a function call with go:

go doSomething()

Goroutines start with a small stack (a few kilobytes) that grows as needed. This makes it cheap to spawn thousands or even millions of them. The Go scheduler multiplexes goroutines onto OS threads, so you don't manage threads directly.

But spawning a goroutine without coordination is a recipe for chaos. You need a way to wait for them, pass data, or cancel them—that's where channels come in.

Channels: Communication Between Goroutines

Channels are typed conduits for sending and receiving values. They provide synchronization: a send blocks until a receiver is ready, and a receive blocks until a value is available. This makes channels ideal for coordinating goroutines.

Create a channel with make(chan T):

ch := make(chan int)
go func() {
    ch <- 42 // send
}()
value := <-ch // receive

By default, channels are unbuffered, meaning sends and receives happen in lockstep. Buffered channels allow a limited number of sends without a receiver:

ch := make(chan int, 3) // buffer size 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 would block until someone receives

Buffered channels can smooth out bursts but don't eliminate the need for coordination.

Common Concurrency Patterns

1. Worker Pool

Distribute tasks across a fixed number of workers to limit resource usage:

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for i := 1; i <= 5; i++ {
        jobs <- i
    }
    close(jobs)

    for r := 1; r <= 5; r++ {
        <-results
    }
}

This pattern is great for CPU-bound or IO-bound tasks where you want to control parallelism.

2. Fan-Out, Fan-In

Fan-out means multiple goroutines read from the same channel. Fan-in means merging results from multiple channels into one:

func fanIn(chs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(chs))
    for _, ch := range chs {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Use this when you have multiple producers and want a single stream of results.

3. Select for Multiplexing

The select statement lets a goroutine wait on multiple channel operations:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case ch2 <- 42:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

Use select with a default case for non-blocking operations, or with a timeout to avoid waiting forever.

Pitfalls and How to Avoid Them

Best Practices

Comparison: Channels vs. Mutexes

AspectChannelsMutexes
Use casePassing data, coordinationProtecting shared state
ComplexityHigher for simple tasksLower for simple tasks
PerformanceOverhead from synchronizationUsually faster for short critical sections
RiskDeadlocks, leaksDeadlocks if not careful

FAQ

When should I use a buffered channel?

Use a buffered channel when you want to decouple senders and receivers to handle bursts, or when you know the exact number of sends and want to avoid blocking. However, unbuffered channels provide stronger synchronization guarantees and are often easier to reason about.

How do I avoid goroutine leaks?

Ensure every goroutine has a clear exit path. Use context.Context to signal cancellation, close channels when no more values will be sent, and avoid blocking indefinitely on channel operations without a timeout or default case.

Can I use channels for everything?

No. While channels are powerful, they add overhead and complexity. For simple shared state, a mutex is often more straightforward and efficient. The Go proverb says: "Don't communicate by sharing memory; share memory by communicating," but that doesn't mean channels are always the answer.

If you're debugging concurrency issues or analyzing logs from your Go services, the Nginx Log Analyzer can help you spot patterns and errors quickly.