Skip to main content
Technology & EngineeringGo176 lines

Goroutines Channels

Concurrency patterns using goroutines, channels, select, and sync primitives in Go

Quick Summary18 lines
You are an expert in Go concurrency, helping developers write correct, efficient concurrent code using goroutines, channels, and synchronization primitives.

## Key Points

- Launched with the `go` keyword; cost roughly 2-8 KB of stack (grows as needed).
- Multiplexed onto OS threads by the Go scheduler (M:N scheduling).
- No return value — communicate results via channels or shared state with synchronization.
- **Unbuffered**: `ch := make(chan int)` — sender blocks until receiver is ready.
- **Buffered**: `ch := make(chan int, 10)` — sender blocks only when buffer is full.
- **Directional**: `chan<- int` (send-only), `<-chan int` (receive-only) for API clarity.
- Closing a channel signals "no more values"; receivers get zero-value after drain.
- Multiplexes across multiple channel operations.
- `default` case makes it non-blocking.
- Always ensure every goroutine has a clear exit path; use `context.Context` or done channels.
- Use `sync.WaitGroup` to wait for a group of goroutines to finish.
- Prefer channels for communication, mutexes for protecting shared state.
skilldb get go-skills/Goroutines ChannelsFull skill: 176 lines
Paste into your CLAUDE.md or agent config

Goroutines & Channels — Go Programming

You are an expert in Go concurrency, helping developers write correct, efficient concurrent code using goroutines, channels, and synchronization primitives.

Core Philosophy

Go's concurrency model is rooted in Tony Hoare's Communicating Sequential Processes: instead of sharing memory and protecting it with locks, concurrent processes should communicate by sending messages through channels. The famous Go proverb — "Don't communicate by sharing memory; share memory by communicating" — is not just a slogan but a design principle baked into the language. Goroutines are cheap enough to launch thousands of them, and channels provide the synchronization points where data flows safely between them.

The real art of Go concurrency is not in launching goroutines — that is trivially easy — but in ensuring every goroutine has a clear lifecycle. A goroutine without an exit condition is a resource leak. Before writing go func(), you should be able to answer three questions: What makes this goroutine start? What makes it stop? Who waits for it to finish? If you cannot answer all three, you are creating a goroutine leak that will grow unbounded in a long-running server.

Channels and mutexes are complementary tools, not competitors. Channels excel at transferring ownership of data between goroutines and orchestrating workflows (pipelines, fan-out, fan-in). Mutexes excel at protecting shared state that multiple goroutines read and write concurrently. Using a channel where a simple sync.Mutex would suffice makes code harder to follow; using a mutex where a channel pipeline would be clearer produces tangled, deadlock-prone code. Choose the tool that makes the concurrency structure of your program most obvious.

Anti-Patterns

  • Fire-and-forget goroutines: Launching go doSomething() without any mechanism to wait for completion or detect failure means errors vanish silently and panics crash the process. Always pair goroutine launches with a sync.WaitGroup, errgroup.Group, or a done channel.

  • Closing channels from the receiver side: The sender knows when there is no more data to send; the receiver does not. Closing a channel from the receiver risks a panic if the sender writes to the closed channel. Establish clear ownership: the sender closes, the receiver ranges.

  • Using unbuffered channels as queues: An unbuffered channel is a synchronization point, not a buffer. If your intent is to decouple producer speed from consumer speed, use a buffered channel with an explicit capacity. If your intent is to hand off work synchronously, unbuffered is correct — just be intentional about the choice.

  • Spawning a goroutine per request without bounds: In a server handling thousands of concurrent requests, launching an unbounded number of goroutines for background work can exhaust memory. Use a worker pool or a semaphore (make(chan struct{}, maxConcurrency)) to limit concurrency.

  • Sharing a sync.WaitGroup by value: sync.WaitGroup contains internal state that must not be copied. Passing it by value to a goroutine creates an independent copy, so wg.Done() decrements a different counter. Always pass *sync.WaitGroup or use it via closure.

Overview

Go's concurrency model is built on CSP (Communicating Sequential Processes). Goroutines are lightweight threads managed by the Go runtime, and channels are typed conduits for communication between them. Together with the sync package, they provide the building blocks for all concurrent Go programs.

Core Concepts

Goroutines

  • Launched with the go keyword; cost roughly 2-8 KB of stack (grows as needed).
  • Multiplexed onto OS threads by the Go scheduler (M:N scheduling).
  • No return value — communicate results via channels or shared state with synchronization.

Channels

  • Unbuffered: ch := make(chan int) — sender blocks until receiver is ready.
  • Buffered: ch := make(chan int, 10) — sender blocks only when buffer is full.
  • Directional: chan<- int (send-only), <-chan int (receive-only) for API clarity.
  • Closing a channel signals "no more values"; receivers get zero-value after drain.

Select

  • Multiplexes across multiple channel operations.
  • default case makes it non-blocking.

Implementation Patterns

Fan-out / Fan-in

func fanOut(input <-chan int, workers int) <-chan int {
    results := make(chan int)
    var wg sync.WaitGroup
    wg.Add(workers)

    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for v := range input {
                results <- process(v)
            }
        }()
    }

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

    return results
}

Pipeline

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

// Usage: result := sq(sq(gen(2, 3, 4)))

Worker Pool

func workerPool(ctx context.Context, jobs <-chan Job, results chan<- Result, numWorkers int) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for {
                select {
                case job, ok := <-jobs:
                    if !ok {
                        return
                    }
                    results <- job.Process()
                case <-ctx.Done():
                    return
                }
            }
        }(i)
    }
    go func() {
        wg.Wait()
        close(results)
    }()
}

Timeout with Select

select {
case res := <-ch:
    fmt.Println("received", res)
case <-time.After(3 * time.Second):
    fmt.Println("timed out")
}

Rate Limiting

limiter := time.NewTicker(200 * time.Millisecond)
defer limiter.Stop()

for req := range requests {
    <-limiter.C
    go handle(req)
}

Best Practices

  • Always ensure every goroutine has a clear exit path; use context.Context or done channels.
  • Use sync.WaitGroup to wait for a group of goroutines to finish.
  • Prefer channels for communication, mutexes for protecting shared state.
  • Close channels from the sender side, never from the receiver.
  • Use sync.Once for one-time initialization.
  • Use errgroup.Group (from golang.org/x/sync/errgroup) when goroutines can return errors.

Common Pitfalls

  • Goroutine leaks: forgetting to cancel a context or close a channel leaves goroutines hanging forever.
  • Race conditions: access shared variables without synchronization; use go run -race to detect.
  • Deadlocks: two goroutines waiting on each other; keep channel direction and ownership clear.
  • Closing a closed channel: causes a panic; track ownership so only one goroutine closes.
  • Loop variable capture: in for loops launching goroutines, capture the loop variable explicitly (fixed in Go 1.22+ with loopvar).
  • Sending on a nil channel blocks forever: always initialize channels before use.

Install this skill directly: skilldb add go-skills

Get CLI access →