Dev Skills

Channels and select

Passing values between goroutines safely, and waiting on several at once.

Topic 40 of 98 · 5 min to read · 1 h to build
After this topic you can
  • Write a producer that sends on a channel and closes it, and a consumer that ranges over it.
  • Explain why an unbuffered send blocks until someone receives.
  • Write a select loop that reads from two channels until both are closed.

What problem does a channel solve?

A channel lets goroutines hand each other values safely, and tells the receiver exactly when a value is ready. Goroutines run at the same time, so they need a safe way to hand each other results. Writing to a shared variable is a data race unless you add a lock, and even then the reader does not know *when* the value is ready.

A channel fixes both. One goroutine sends a value, another receives it, and the receive waits until the value is there. The handoff is the synchronisation: everything the sender did before the send is visible to the receiver after the receive.

Channels: a typed pipe between goroutines

channels.go
jobs := make(chan int) // unbuffered: a send waits for a receiver
queue := make(chan int, 5) // buffered: holds 5 before a send blocks
queue <- 42 // send (fine here: the buffer has room)
v := <-queue // receive
close(queue) // "no more values are coming"
for v := range queue { } // receives until the channel is closed and empty
v, ok := <-queue // ok is false once closed and drained
`jobs <- 42` on its own would block forever, because nobody is receiving yet. An unbuffered send needs a receiver in another goroutine.

What is the difference between unbuffered and buffered channels?

An unbuffered channel makes the sender wait for a receiver; a buffered channel lets the sender continue until the buffer is full. An unbuffered channel is a rendezvous. The sender blocks until a receiver takes the value, so both goroutines know the handoff happened. That synchronisation is often the point, not a cost.

A buffered channel is a queue. The sender carries on until the buffer is full. Use one when you have a specific reason: a known number of results, or a signal that must never block the sender. A buffer size picked by feel hides backpressure: when the consumer falls behind, the buffer absorbs it, and you only find out later, under load.

Channel behaviour, all of it

Operationnil channelopen, unbufferedopen, bufferedclosed
sendblocks foreverblocks until a receiver takes itproceeds if the buffer has room, else blockspanics
receiveblocks foreverblocks until a sender arrivesproceeds if the buffer has a value, else blocksbuffered values first, then zero value, ok=false
closepanicssucceeds; waiting receivers wake up, waiting senders panicsucceeds; buffered values can still be receivedpanics
Watch out: The sender closes, never the receiver, and only one goroutine may close. Closing tells receivers "nothing more is coming", and every receiver sees it, so it works as a broadcast. You do not have to close a channel at all; the garbage collector reclaims unreferenced ones. Close only when receivers need to know it ended.

select — wait on several channels at once

select.go
select {
case v := <-ch1:
fmt.Println("from ch1:", v)
case ch2 <- 1:
fmt.Println("sent to ch2")
case <-time.After(2 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready right now") // makes select non-blocking
}
If several cases are ready, `select` picks one at random, so do not rely on case order for priority. With no `default`, it blocks until one becomes ready.
The short version
  • Unbuffered channels synchronise; buffered ones queue. Default to unbuffered.
  • The sender closes, never the receiver, and only one goroutine may close.
  • Sending on a closed channel panics; receiving drains what is left, then gives the zero value and ok=false.
  • A nil channel blocks forever, which is how you disable a select case.
Remember: Know the channel behaviour table cold. Most channel deadlocks and panics are a row in it.
Build this before moving on
Build a worker pool: three workers reading from a jobs channel, results to a results channel, closed by the sender.
Coding challenge · checked by tests
Write SumBoth(a, b): receive every value from both channels until both are closed, and return the total. The hidden tests use unbuffered channels fed by one goroutine that alternates between them, so ranging over a and then b deadlocks. Use select, and set a channel to nil once it is closed so select stops picking it. Example: b sends 1, a sends 2, a closes, b sends 3, b closes: the result is 6.

Tab indents. Press Esc, then Tab, to leave the editor.

Then try these in your own editor
  1. 1Send on an unbuffered channel with no receiver and read the deadlock message.
  2. 2Close a channel then receive twice, checking ok each time.
  3. 3Write a select with a time.After timeout and make it fire.
  4. 4Set a channel variable to nil inside a select loop and watch that case switch off.
  5. 5Predict the output: in main, ch := make(chan int), then ch <- 1, then fmt.Println(<-ch). Then change it to make(chan int, 1) and predict again.
  6. 6Predict the output: var a chan int (nil), b := make(chan int, 1), b <- 7, then select { case v := <-a: fmt.Println("a", v); case v := <-b: fmt.Println("b", v) }. Run it ten times.
  7. 7Predict the output: ch := make(chan int, 2), send 1 and 2, close(ch), then do v, ok := <-ch three times and print each.
Check yourselfTake the Concurrency test