> series: learn go from 0 to 100 | article 12 of 19
Last article our goroutines flew solo and we waited for them with time.Sleep, which is like synchronizing watches by yelling out the window. Today the real tool arrives: Go channels, the typed pipes goroutines pass data through and, as a bonus, synchronize with.
Channels: send, receive, block
A channel is created with make and it’s typed: only strings travel through a chan string (A Tour of Go, channels):
ch := make(chan string)
go func() {
ch <- "hello from the goroutine" // send
}()
msg := <-ch // receive: BLOCKS until something arrives
fmt.Println(msg)
// Unbuffered: send and receive wait for each other.
// It's a date, not a mailbox.
The <- arrow is everything: toward the channel sends, from the channel receives. And the detail that changes everything: on an unbuffered channel both operations block until the other side shows up. That mutual wait IS the synchronization: when the receive completes, you know the send happened. No eyeballed sleep.
Buffer, close and range
With capacity, the channel goes from date to mailbox (A Tour of Go, buffered channels, range and close and spec, close):
// Buffered: send only blocks when full.
mailbox := make(chan int, 3)
mailbox <- 1
mailbox <- 2 // nobody receives and that's fine, there's room
// close announces no more sends are coming.
close(mailbox)
// range consumes until the channel is closed and drained:
for v := range mailbox {
fmt.Println(v)
}
// Comma ok lives here too:
v, ok := <-mailbox // ok is false: closed and empty
House rules that prevent panics: the SENDER closes, never the receiver. Sending on a closed channel panics. Closing twice, also panics. Receiving from a closed one returns zero values with ok false, which is exactly what range uses to know when to stop.
Signatures can declare direction, and the compiler enforces it:
// Directions in the signature: the compiler watches.
func produce(out chan<- int) { // send only
out <- 42
}
func consume(in <-chan int) { // receive only
fmt.Println(<-in)
}
select: listening to several channels at once
select is a switch over channel operations: it waits until one of its cases can proceed, and picks randomly if several are ready (A Tour of Go, select and spec):
select {
case msg := <-ch1:
fmt.Println("came through ch1:", msg)
case msg := <-ch2:
fmt.Println("came through ch2:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timeout: nobody answered")
}
// With default it stops blocking:
select {
case msg := <-ch1:
fmt.Println(msg)
default:
fmt.Println("nothing pending, moving on")
}
Both patterns in the example are wardrobe staples: timeout via time.After to avoid waiting forever, and default for non-blocking checks. If you’ve used Promise.race in JS the idea rings a bell, but this works with any number of operations in both directions.

The full plumbing
With Go channels and select you can now build last article’s slogan: instead of sharing the counter and guarding it, a single goroutine owns the data and the rest send it messages (Effective Go, channels). Last challenge’s racing counter gets fixed exactly like that: one goroutine adds up what arrives through a channel and nobody else touches the variable.
Weekly challenge: put Go channels to work
- Ping pong: two goroutines, one channel. One sends “ping”, the other answers “pong”, ten rounds and done.
- Fix article 11‘s counter with a channel: 1000 goroutines send 1 and one accumulator adds. Verify with -race that the race is gone.
- Simulate a slow query with a random time.Sleep and give it a one second timeout using select.
- Bonus: a producer sending five numbers then closing. A consumer with range. Add a second producer and discover why closing gets tricky with several senders (hint for article 14).
Go channels: level complete.
Next article: concurrency patterns. Worker pools, fan-in, fan-out and pipelines, the professional plumbing blueprints.
> exit