From Sequential Scripts to Concurrent AI Pipelines in Go — Part 6

Part 6 — Buffered Channels and select

Part of the series: Production-Grade Concurrent AI Systems in Go

Full code for this post: github.com/madmmas/go-concurrent-ai-systems/tree/part-06Diff from Part 5: compare/part-05...part-06Run it: go run ./cmd/news-processor -mode=buffered inside arc-1-foundations/part-06-buffered-channels


Part 5 introduced channels as a way to pass results between goroutines without shared memory. We used an unbuffered channel and it worked cleanly — no mutex, no race, one goroutine owned the results slice.

But Part 5 used one specific kind of channel without explaining the choice. There are two kinds:

unbuffered := make(chan model.AIResult)     // send blocks until received
buffered   := make(chan model.AIResult, 10) // send succeeds until buffer full

Part 6 explains both, and introduces a third tool that belongs here: select. It appears naturally in the buffered pipeline's collector — and once you see it, you will recognise it throughout the rest of the series.


Unbuffered: Synchronous Handoff

An unbuffered channel is a rendezvous. The sender blocks until a receiver is ready. The receiver blocks until a sender sends. Neither side proceeds without the other.

resultsCh := make(chan model.AIResult) // unbuffered

// Worker: blocks here until collector receives
resultsCh <- result

// Collector: blocks here until a worker sends
r := <-resultsCh

This creates implicit backpressure: if the collector is slow, workers wait. A slow downstream database write naturally throttles upstream article processing rather than letting results pile up in memory.

Our UnbufferedPipeline from Part 5 uses exactly this pattern — unchanged:

func (p *UnbufferedPipeline) ProcessAll(articles []model.Article) ([]model.AIResult, time.Duration) {
    resultsCh := make(chan model.AIResult) // unbuffered
    // ...workers send, collector ranges until closed...
}

Buffered: Asynchronous Queue

A buffered channel has internal capacity. A send completes immediately as long as the buffer is not full. The sender only blocks when the buffer is at capacity.

resultsCh := make(chan model.AIResult, 10) // buffer of 10

// Succeeds immediately if fewer than 10 results are queued
resultsCh <- result

Workers and collector run independently. A slow collector does not stall fast workers — results queue up in the buffer until the collector drains them. The cost is memory: buffered results sitting in the channel.


select: Wait on Multiple Channels at Once

Here is where the new tool enters. The buffered pipeline's collector could use a plain range:

for r := range resultsCh {
    results = append(results, r)
}

This works — but it blocks forever if a worker hangs and never sends its result or closes the channel. In our simulated pipeline that is unlikely. In production with real LLM calls, it is routine.

select gives the collector an escape hatch. It waits on multiple channel operations simultaneously and runs whichever one fires first:

deadline := time.After(30 * time.Second)
var results []model.AIResult

for {
    select {
    case r, ok := <-resultsCh:
        if !ok {
            // Channel closed — all workers done, all results collected.
            return results, time.Since(start)
        }
        results = append(results, r)

    case <-deadline:
        // No result for 30 seconds — a worker is probably hung.
        // Return whatever we collected rather than waiting forever.
        fmt.Printf("collector: timed out — got %d of %d results\n",
            len(results), len(articles))
        return results, time.Since(start)
    }
}

select here races two channels: resultsCh against time.After(30s). In normal operation, resultsCh keeps firing until it closes and the ok=false branch exits. If something goes wrong and no result arrives for 30 seconds, the deadline branch fires instead and the collector gives up cleanly.

This is the select pattern you will see throughout the rest of the series. Parts 8 and 9 use it to race a timer against ctx.Done(). The shape is always the same: wait on whichever of these things can make progress.


select with default: Non-Blocking

A default case makes select non-blocking — if no case is immediately ready, default runs instantly without waiting.

The ProcessAllDropOnFull method shows this in the news platform: if the buffer is full when a worker finishes, the result is dropped rather than blocking the worker.

select {
case resultsCh <- result:
    // sent successfully
default:
    // buffer full — drop rather than block
    fmt.Printf("[article %d] dropped — buffer full\n", art.ID)
    dropped++
}

This is the lossy pattern: acceptable when dropping a result is better than stalling a worker. In a news pipeline processing thousands of articles per minute, dropping one and noting it in a counter is preferable to having all workers queue up behind a full buffer.

The test verifies the accounting stays clean regardless:

// results + dropped must always equal total articles
if len(results)+dropped != len(articles) {
    t.Errorf(...)
}

Three select patterns to remember

Every select usage in this series falls into one of three shapes:

PatternShapeWhen to use
Wait on either channelcase <-chA: / case <-chB:Collect results with a timeout; race a timer
Non-blocking probecase <-ch: / default:Try to receive without waiting
Non-blocking sendcase ch <- v: / default:Send without blocking; drop if full

Part 5 used range to collect results. Part 6 uses select. Part 8 uses select to race a per-article timeout against the LLM call. Part 9 uses it to check ctx.Done() before processing each job. The pattern is the same in all three — only the channels change.


When the Difference Actually Matters

With simulated LLM latency (500ms–1500ms per call), buffered and unbuffered take similar time. The bottleneck is the calls, not the channel operations. The difference shows up in two real scenarios.

Slow collector. If collecting a result means writing to a database or calling an API, the collector takes real time per result. With an unbuffered channel, every worker blocks waiting for the collector to finish its current result. With a buffered channel, workers keep running and queue their results while the collector catches up.

Burst workloads. Imagine 100 articles where 90 complete in 500ms and 10 take 5 seconds. With an unbuffered channel, the 90 fast workers complete and immediately block at the channel, holding their goroutine stacks in memory until the collector cycles to them. With a buffered channel of 100, they send immediately and exit. Memory is freed as workers complete.


What's Next

Part 6 covers both channel types and introduces select. Part 7 uses both together in a worker pool — a fixed number of long-lived goroutines ranging over a jobs channel, sending results to a buffered results channel, with the closer goroutine wiring them together using the close pattern from Part 4.

See you in Part 7.


This is Part 6 of the series "Production-Grade Concurrent AI Systems in Go." Read Part 5 — Channels and Message Passing or continue to Part 7 — Worker Pools and Bounded Concurrency.