DEV Community

Niraj Maharjan
Niraj Maharjan

Posted on

Understanding Go Concurrency Through Pipelines: Channels, Goroutines, and the Questions Everyone Asks

If you've written a Go pipeline like the one below and felt like something "shouldn't work" but somehow does, you're not alone. This pattern — three stages connected by channels — trips up almost everyone the first time, because it forces you to unlearn the instinct that functions "finish their work" before returning.

func sliceToChannel(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

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

func main() {
    nums := []int{1, 2, 3, 4, 5}
    // Stage 1
    dataChannel := sliceToChannel(nums)
    // Stage 2
    finalChannel := sq(dataChannel)
    // Stage 3
    for nums := range finalChannel {
        fmt.Println(nums)
    }
}
Enter fullscreen mode Exit fullscreen mode

The big picture

This is the pipeline pattern: independent stages, each running in its own goroutine, connected by channels that act as conveyor belts.

sliceToChannel()  --channel-->  sq()  --channel-->  main() prints
   (Stage 1)                  (Stage 2)              (Stage 3)
Enter fullscreen mode Exit fullscreen mode

Each stage does one job and hands its output downstream. No stage needs to know how the others are implemented — it just needs a channel to read from and a channel to write to. That decoupling is the entire point of the pattern.

Let's unpack every piece of confusion this code tends to cause.


1. Why is there a return if two goroutines are supposed to be communicating?

This is the core insight most people miss: a function and the goroutine launched inside it are two different things that finish at completely different times.

func sliceToChannel(nums []int) <-chan int {
    out := make(chan int)   // 1. create the channel
    go func() {              // 2. launch a goroutine — runs concurrently
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out                // 3. function returns IMMEDIATELY
}
Enter fullscreen mode Exit fullscreen mode

Step by step:

  • out := make(chan int) creates an empty pipe. Nobody has sent or received anything yet.
  • go func(){...}() schedules the inner function to run concurrently. Go doesn't wait for it to do anything — it kicks it off in the background and immediately moves to the next line.
  • return out hands back the channel itself — the pipe, not any data flowing through it. The goroutine may not have even started executing yet.

So the return isn't "returning the result of a communication." It's returning a handle to the pipe, so whoever called sliceToChannel() can start receiving whenever they're ready. The goroutine keeps running independently, feeding values into that pipe over time.

Mental model: you're handing someone a garden hose that's about to have water pushed through it. You don't wait for all the water to come out before handing over the hose — you hand it over immediately, and the water flows through later, concurrently, while you both go about your business.


2. What does close actually do?

close(out) does not delete the channel or block anyone from reading it. It's a signal: "no more values are coming."

This matters because of how range and receive operations behave:

for n := range in {
    out <- n * n
}
close(out)
Enter fullscreen mode Exit fullscreen mode

range over a channel keeps pulling values, one at a time, and blocks when the channel is empty. It only exits the loop when the channel is both empty and closed.

If you never call close, range blocks forever after the last value — waiting for a value that will never arrive. That's a deadlock, and we'll trigger one deliberately in a minute so you can see exactly what it looks like.

There's a two-value receive form that shows what's happening under the hood:

n, ok := <-out
Enter fullscreen mode Exit fullscreen mode

ok is false once the channel is closed and drained. range uses this internally to know when to stop.

Two rules worth memorizing:

  • Only the sender should close a channel — never the receiver.
  • Close a channel at most once. Closing an already-closed channel panics.

3. The channel is unbuffered — so why does looping over it make sense?

These are two unrelated concepts, and mixing them up is a common source of confusion.

Buffered vs. unbuffered is about how many values can sit in the channel waiting to be picked up before a send blocks.

  • make(chan int) → unbuffered, capacity 0. A send (out <- n) blocks until another goroutine is simultaneously ready to receive. It's a synchronous handshake.
  • make(chan int, 5) → buffered, capacity 5. A send only blocks once the buffer is full.

range/looping is about how many values total will pass through the channel over its lifetime — completely independent of buffering.

Even with an unbuffered channel, many values can flow through sequentially, one handshake at a time. range just keeps asking "give me the next value" until the channel closes. Buffering only changes whether the sender has to wait for a receiver right now — it doesn't change whether looping is meaningful.

So in sq:

for n := range in {      // receive one value at a time, block if none ready
    out <- n * n          // send one value, block until sq's downstream receives it
}
Enter fullscreen mode Exit fullscreen mode

Each iteration is one handshake in, one handshake out. Nothing is buffered in bulk anywhere — it's a pure pass-through, value by value.


4. How main() actually executes, concurrently

nums := []int{1, 2, 3, 4, 5}
dataChannel := sliceToChannel(nums)   // starts goroutine #1, returns channel immediately
finalChannel := sq(dataChannel)        // starts goroutine #2, returns channel immediately
for nums := range finalChannel {       // main blocks here, pulling values
    fmt.Println(nums)
}
Enter fullscreen mode Exit fullscreen mode

At this point there are three concurrently running things: the sliceToChannel goroutine, the sq goroutine, and main itself. None of them wait for each other to fully finish — they run interleaved, synchronizing only at the moments they send or receive on a shared channel.

A caveat before the trace below: the Go scheduler doesn't guarantee this exact sequence of who-blocks-first. What's guaranteed is that values arrive at main in order (1, 4, 9, 16, 25) because each unbuffered handshake is a strict rendezvous. The blow-by-blow below is one valid way this plays out — useful for building intuition, not a literal scheduler log.

In prose form:

  1. Goroutine 1 tries out <- 1. It blocks — nobody's receiving yet.
  2. Goroutine 2's range in attempts <-in. This matches goroutine 1's send. Handshake: 1 moves across. Goroutine 1 continues and tries out <- 2 (blocks again). Goroutine 2 now has n = 1, computes 1*1 = 1, tries out <- 1 on its own channel — blocks, since main isn't receiving yet.
  3. main's range finalChannel attempts <-finalChannel. Matches goroutine 2's send. 1 arrives in main and gets printed.
  4. This repeats: 2 → 4, 3 → 9, 4 → 16, 5 → 25, printed one at a time.
  5. After the last value, goroutine 1's loop ends and it calls close(out). Goroutine 2's range in sees the channel closed, exits its loop, and calls close(out) on its own channel. main's range finalChannel sees that close too, and its loop ends naturally.
  6. main() finishes normally.

No deadlock, no explicit sync.WaitGroup needed — the close signal cascades down the pipeline and unwinds everything cleanly.


5. Read-only channels and why functions return them

func sliceToChannel(nums []int) <-chan int
Enter fullscreen mode Exit fullscreen mode

<-chan int is a directional channel type — specifically receive-only. The arrow points away from chan, meaning "you can only receive from this, not send into it."

There's also chan<- int (send-only — arrow points into chan) and plain chan int (bidirectional).

Why bother? It's a compile-time safety contract. Inside sliceToChannel, out is an ordinary bidirectional chan int, because the goroutine needs to send on it. But the function's return type is declared <-chan int. Go allows this implicit conversion automatically because it's narrowing (bidirectional → directional), not widening.

The caller then only ever has a receive-only view:

dataChannel := sliceToChannel(nums)   // dataChannel is <-chan int
Enter fullscreen mode Exit fullscreen mode

If you accidentally wrote dataChannel <- 10 somewhere in main, the compiler rejects it — because as far as main is concerned, that channel is receive-only. This prevents a whole class of bugs: a downstream consumer accidentally becoming a producer, or closing a channel it doesn't own.

This is the discipline the whole pipeline pattern relies on:

  • The function that creates and owns a channel is the only one allowed to send on it or close it.
  • Everyone downstream gets a <-chan view — read-only, can't send, can't close, can't mess it up.

6. What happens if you forget to close? (Deliberately breaking it)

Let's trigger the failure mode on purpose. Drop the close(out) call from sliceToChannel:

func sliceToChannelBroken(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        // close(out) — deliberately omitted
    }()
    return out
}
Enter fullscreen mode Exit fullscreen mode

Run this through the same pipeline, and here's what happens:

  1. Values 1 through 5 flow through and get printed exactly as before — the missing close doesn't affect delivery of values that do exist.
  2. After 5 is sent, goroutine 1's for loop ends. It falls off the end of the function without calling close.
  3. Goroutine 2's range in has delivered its last value and now calls <-in again, waiting for the next one. But no more values are coming, and the channel was never closed — so this receive blocks forever.
  4. Because goroutine 2 never finishes, it never closes its own out channel either.
  5. main's range finalChannel is waiting on a channel that will now never close and never receive another value. It blocks forever too.

Since every goroutine is now permanently blocked and none of them can be woken up by anything, the Go runtime detects this and panics:

fatal error: all goroutines are asleep - deadlock!
Enter fullscreen mode Exit fullscreen mode

This is Go's built-in deadlock detector — it notices when the entire program has nothing left to schedule, not just one goroutine. It's one of the few runtime safety nets you get for free with channels: forgetting a close doesn't silently hang forever with no explanation, it (usually) surfaces loudly.

Takeaway: close isn't just tidiness — in a range-based pipeline, it's the only thing that tells downstream consumers "stop waiting, there's nothing left."


7. What changes if you add buffering?

Now let's swap the unbuffered channels for buffered ones and see how the blocking pattern — not the final output — changes.

func sliceToChannelBuffered(nums []int) <-chan int {
    out := make(chan int, 3) // buffer of 3
    go func() {
        for _, n := range nums {
            out <- n
            fmt.Println("sent", n)
        }
        close(out)
    }()
    return out
}
Enter fullscreen mode Exit fullscreen mode

With make(chan int, 3):

  • The first three sends (1, 2, 3) succeed immediately, without waiting for anyone to receive — they just land in the buffer.
  • The fourth send (4) blocks, because the buffer is full and nothing has drained it yet.
  • Once a receiver takes a value out (freeing a slot), the next blocked send can proceed.

Compare the console output pattern between unbuffered and buffered versions:

Unbuffered (capacity 0): sends and receives are tightly interleaved — you'd see sent 1 immediately followed by a receive of 1, then sent 2, and so on, because each send is a synchronous handshake.

Buffered (capacity 3): you'd see sent 1, sent 2, sent 3 fire off in a rapid burst (no receiver needed yet), and only then would sends and receives start pacing each other, once the buffer fills up.

The final printed sequence in main (1, 4, 9, 16, 25) is identical either way — buffering doesn't change what gets delivered or in what order (channels are always FIFO), only how eagerly the sender can get ahead of the receiver before it's forced to wait.

When does this matter in practice?

  • A small buffer can smooth out short bursts of speed mismatch between producer and consumer, reducing context-switch overhead.
  • It does not fix a slow consumer — if the consumer is permanently slower than the producer, the buffer just delays the inevitable blocking, it doesn't eliminate it.
  • An oversized buffer can hide bugs: a pipeline that "deadlocks" with an unbuffered channel might silently limp along with a giant buffer, only to blow up later at a different, less obvious point (or exhaust memory if the producer runs far ahead of a consumer that never shows up).

Unbuffered channels are usually the right default for pipeline stages, precisely because they surface synchronization bugs immediately (like the deadlock above) instead of masking them behind buffer capacity.


Putting it all together — a mental model

Think of each pipeline stage function as a small factory:

  1. Builds a conveyor belt (make(chan int)).
  2. Hires a worker (go func(){...}()) who starts putting items on the belt whenever ready — running independently, forever, until told to stop.
  3. Immediately hands you the belt (return out) so you can start taking things off it, even though the worker might not have placed anything on it yet.
  4. The worker eventually finishes and flips a "no more items" sign (close), which tells anyone looping over the belt to stop waiting and exit.

Every confusing part of this pattern — the early return, the apparent contradiction of "unbuffered but looped," the deadlock when close is missing, the way buffering changes pacing but not order — falls out of that one factory model once it clicks.

Quick reference

Concept Rule of thumb
go func(){...}() Starts running concurrently; caller doesn't wait
return channel Hands back a pipe, not data — synchronization happens later
close(ch) Only the sender closes; signals "no more values"; closing twice panics
Unbuffered channel Send blocks until a matching receive happens — synchronous handshake
Buffered channel Send blocks only once the buffer is full — some slack before syncing
range over a channel Blocks until a value arrives or the channel closes and drains
<-chan T / chan<- T Compiler-enforced read-only / write-only views, prevents misuse
Missing close in a range pipeline Deadlock: fatal error: all goroutines are asleep

Top comments (0)