ToolPopToolPop
Go · Lesson 18 of 22

Channel patterns: pipelines, fan-out/in, semaphore, rate limiter

9 min readUpdated 25 Jun 2026

Channels are not just a primitive. They are a vocabulary. Once you internalise the four or five reusable shapes, every concurrency problem starts looking like a combination of them. This lesson is the vocabulary.

Every pattern here is something you will be asked to write on a whiteboard in a senior Go interview, and every one of them has a sharp edge that trips juniors. Pay attention to the close-the-channel rule. It is the single biggest source of panics in real Go code, and the second biggest source is unbounded goroutine spawning, which we will also tackle.

The thread that runs through every pattern is ownership. Who owns the channel, who is allowed to send, who is responsible for closing, who handles cancellation. Get the ownership story right and the code writes itself. Get it wrong and you have either a panic, a leak, or a deadlock waiting for production traffic.

The pipeline pattern

A pipeline is a chain of stages. Each stage is a goroutine that reads from an input channel, does some work, and writes to an output channel. The output of stage N is the input of stage N+1.

go
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}
 
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}
 
func main() {
    for v := range square(square(gen(1, 2, 3, 4))) {
        fmt.Println(v) // 1, 16, 81, 256
    }
}

Notice three things. Each stage owns its output channel and closes it with defer. Each stage uses range on its input, which exits cleanly when the upstream stage closes. And the composition square(square(gen(...))) reads top-down like the data flow itself.

Senior rule: only the SENDER closes a channel. Receivers never close. Multiple senders means you need a coordinator (a WaitGroup or a separate done-channel) so exactly one party calls close.

If two senders both call close, the second call panics with close of closed channel. If a receiver calls close while the sender is still writing, the sender panics with send on closed channel. Both are immediate, fatal, and embarrassing in a code review.

Fan-out, fan-in revisited

Fan-out splits work across N workers. Fan-in merges N output channels into one. Together they parallelise an arbitrary stage of the pipeline.

go
func fanOut(in <-chan int, n int) []<-chan int {
    outs := make([]<-chan int, n)
    for i := 0; i < n; i++ {
        outs[i] = square(in) // each worker reads from the shared input
    }
    return outs
}
 
func fanIn(chs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, c := range chs {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

The WaitGroup is the coordinator that lets exactly one goroutine close out after every fan-in producer is done. Without it, you either close too early (panic on the slow producer) or never close (downstream range hangs forever).

Diagram
rendering diagram...
Pipeline with fan-out/in: gen feeds N squarers, results merge into one channel

The semaphore pattern

A counting semaphore limits how many goroutines can do something at once. The idiomatic Go implementation is a buffered channel of empty structs.

go
func crawlAll(urls []string) {
    sem := make(chan struct{}, 10) // max 10 concurrent
    var wg sync.WaitGroup
    for _, u := range urls {
        wg.Add(1)
        sem <- struct{}{} // acquire, blocks if 10 in flight
        go func(u string) {
            defer wg.Done()
            defer func() { <-sem }() // release
            fetch(u)
        }(u)
    }
    wg.Wait()
}

chan struct{} carries no payload. The send is the acquire, the receive is the release, the buffer size is the concurrency cap. When the buffer is full, the next send blocks until a worker finishes and drains a slot. No mutex, no condition variable, no library.

Interview trap: do not put the sem <- struct{}{} INSIDE the goroutine. If you do, you spawn all N goroutines first and only then throttle, which defeats the bound. Acquire OUTSIDE, release INSIDE.

The semaphore pattern is also how you implement a bounded connection pool, a bounded file-handle pool, or any other "max N at a time" rule without introducing a real pool data structure. The buffered channel IS the pool.

A rate limiter with time.Tick

Rate limiting is different from semaphore-bounded concurrency. Concurrency limits parallel work; rate limits the number of operations per second regardless of duration.

go
func rateLimited(reqs <-chan Request) {
    limiter := time.Tick(200 * time.Millisecond) // 5 per second
    for r := range reqs {
        <-limiter
        go handle(r)
    }
}

time.Tick returns a channel that fires every 200 ms. Receiving from it blocks until the next tick. So your loop processes at most 5 requests per second, evenly spaced.

For burst tolerance, combine a ticker with a buffered channel as a token bucket:

go
func tokenBucket(rate time.Duration, burst int) <-chan struct{} {
    tokens := make(chan struct{}, burst)
    // prefill
    for i := 0; i < burst; i++ {
        tokens <- struct{}{}
    }
    go func() {
        t := time.NewTicker(rate)
        defer t.Stop()
        for range t.C {
            select {
            case tokens <- struct{}{}:
            default: // bucket full, drop the token
            }
        }
    }()
    return tokens
}

Each request does <-tokens to take a token. The bucket refills at the configured rate, capped at burst. This is exactly the algorithm golang.org/x/time/rate uses internally, but in 12 lines so you can write it on a whiteboard.

Context cancellation in every pattern

Every pattern above has the same bug if you stop reading the output channel. The upstream goroutines block forever on the send, holding their stack frames, their captured variables, and any open file or socket they grabbed. Classic goroutine leak.

The fix is to thread a context.Context through every stage and select on ctx.Done() for every send and receive.

go
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

Now if the caller cancels the context, every stage drops what it is doing and returns. The deferred close(out) runs, which lets the downstream range exit, which cascades cancellation through the entire pipeline.

Senior rule: every long-lived goroutine that touches a channel must accept a context.Context and select on ctx.Done(). If you cannot cancel it, you have built a leak.

There is one more subtle trap. The downstream consumer must also drain whatever is left in the channel after cancellation, otherwise the upstream stage can block on a send between the time the context cancels and the time the consumer notices. A practical pattern is to wrap the consumer loop itself in a select that watches both the channel and the context, so a stuck send unblocks when the context fires regardless of the consumer's state.

Channel patterns

pipeline
Chain of goroutines connected by channels. Each stage reads, transforms, writes, then closes its output.
fan-out
Distribute work from one input channel to N worker goroutines in parallel.
fan-in
Merge N output channels into one. Requires a WaitGroup to know when to close the merged channel.
semaphore
Buffered chan struct{} used to cap how many goroutines run a section concurrently. Send = acquire, receive = release.
token bucket
Rate-limit algorithm: a buffered channel refilled by a ticker, drained by each request. Allows bursts up to bucket size.
cancellation cascade
When a context is cancelled, every stage's select unblocks, its defer closes its output, the next stage's range exits, and the cycle propagates downstream.

What we will cover next

The next lesson moves into performance. Once you have channels and context wired up correctly, the next question is "is this actually fast?" That means pprof, benchmarks, and learning to read flame graphs. The patterns here are the building blocks. The profiler tells you which block is actually hot.

Senior rule: write the clear channel-based version first. Profile it. Only reach for sync.Mutex or sync/atomic when pprof tells you the channel itself is the bottleneck. It usually is not.

Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict the final output (sum of doubled 1..5).

    hint

    (1+2+3+4+5)*2 = 30

    show answer
    // already in the editor