← Go, End to End

Go, End to End

Channels

A channel is a typed pipe: one goroutine sends a value in, another receives it out. It's Go's primary tool for goroutines to communicate safely, and it's built into the language itself rather than being a library on top of threads.

Unbuffered vs buffered

An unbuffered channel (`make(chan int)`) has no internal storage. A send blocks until a receiver is ready to take the value, right at that instant, this is called a rendezvous: sender and receiver briefly synchronize. A buffered channel (`make(chan int, 3)`) has room for a fixed number of values, a send only blocks once the buffer is full, and a receive only blocks once the buffer is empty.

go · live, editable, runnable

select

`select` waits on multiple channel operations at once, and proceeds with whichever one is ready first. If more than one is ready simultaneously, Go picks one at random, deliberately, so you don't accidentally rely on an ordering that was never guaranteed.

go · live, editable, runnable

Add a `default` case to a `select`, and it becomes non-blocking: if no channel operation is immediately ready, `default` runs instead of waiting. That's the standard way to write "check this channel, but don't wait around if there's nothing there".

go
select {
case msg := <-ch:
	fmt.Println("got:", msg)
default:
	fmt.Println("nothing ready right now")
}

Closing channels

`close(ch)` signals that no more values will ever be sent. Receivers can keep draining any values still buffered, but once the channel is fully drained, further receives return the zero value immediately instead of blocking. The two-value receive form (`v, ok := <-ch`) tells you which situation you're in: `ok` is `false` once the channel is closed and drained.

go · live, editable, runnable

Only the sender should ever close a channel, never the receiver, and never close a channel more than once (that panics). If multiple goroutines might send on the same channel, closing gets genuinely tricky, and often the cleanest fix is a separate 'done' channel purely for the shutdown signal instead of closing the data channel itself.

A `range` loop over a channel is the idiomatic way to drain it until closed, it does exactly the `v, ok := <-ch; if !ok { break }` dance above, automatically:

go
for v := range ch { // exits automatically once ch is closed and drained
	fmt.Println(v)
}

The nil channel gotcha

An uninitialized channel variable (`var ch chan int`) is `nil`, and both sending to and receiving from a nil channel block forever, not panic, just block, permanently. This is occasionally useful on purpose (disabling a `select` case by nilling out its channel), but it's a real footgun when it happens by accident, usually from forgetting to initialize a channel field with `make` before using it.

go · live, editable, runnable

Choosing between them at a glance

  • Unbuffered: use when you want a hard synchronization point, the sender genuinely should wait until the receiver is ready, not just fire-and-forget.
  • Buffered: use when the sender should be able to get a bit ahead of the receiver without blocking, a small, deliberate amount of slack, not an unbounded queue.
  • Never use an unbounded buffer size as a substitute for proper backpressure, a channel is not a magic infinite queue, and a producer that outpaces its consumer forever will eventually exhaust memory regardless of buffer size.
Try it yourself: fix the nil channel hang

The example above hangs forever. Fix it by initializing the channel with make and sending a value from a goroutine before the receive:

go · live, editable, runnable