Channels: The Unbuffered Handshake

A channel is a typed conduit and a synchronization point at once. Why an unbuffered send blocks until a receiver is ready, the real deadlock fatal error you get when nobody can receive, channel direction types in function signatures, and why a nil channel blocks forever. Compiled and run against Go 1.26.5.

The previous chapter ended on a problem: main returns before a goroutine finishes, and the work is lost. The fix is to wait for the event rather than the clock, and the primary tool for that is the channel. A channel is a typed conduit you send values into and receive values out of. That much you could guess from the name. The part that matters, the part that makes channels the backbone of Go concurrency, is that a channel is also a synchronization point. Passing a value through one makes two goroutines meet at the same instant. Communication and coordination are the same operation.

You make one with make, giving the element type:

ch := make(chan int) // a channel of ints
ch <- 42             // send: the arrow points into the channel
x := <-ch            // receive: the arrow points out of it

The <- operator does both jobs, and which one is clear from where the channel sits relative to the arrow. This chapter is about the unbuffered channel, the kind you get from make(chan int) with no size. Its defining behavior is the handshake, and understanding it is the whole game.

The handshake: send blocks until receive

An unbuffered channel has no room to hold a value. So a send cannot simply drop a value in and move on; there is nowhere to drop it. An unbuffered send blocks until some other goroutine is ready to receive, and a receive blocks until some other goroutine is ready to send. The value passes from one to the other in a single synchronized step, and both goroutines proceed from that point. Neither runs ahead of the other. That is the handshake.

The immediate consequence is that a channel operation needs a partner, and the partner must be a different goroutine. Here are two goroutines shaking hands correctly:

package main

import "fmt"

func main() {
	ch := make(chan string)
	done := make(chan struct{})

	go func() {
		fmt.Println("sender:   about to send")
		ch <- "ping" // blocks here until main is ready to receive
		fmt.Println("sender:   send returned")
		close(done)
	}()

	msg := <-ch // blocks here until the sender is ready to send
	fmt.Println("receiver: got", msg)
	<-done // wait for the sender's final line
}

Output, stable across every run I tried:

sender:   about to send
sender:   send returned
receiver: got ping

Trace the blocking. The goroutine prints its first line and then blocks on ch <- "ping", because nobody is receiving yet. main reaches <-ch and blocks too, because nobody has sent yet. Now both sides are ready, the handshake completes, and the value moves across. From that instant both goroutines are runnable again. Which of the two following lines prints first is the scheduler’s call; in my runs the sender consistently printed send returned before main printed its line, but you should not lean on that ordering. The done channel at the end is a second, smaller handshake whose only purpose is to keep main alive until the sender has printed. Notice we never used time.Sleep. We waited for the event.

That empty-struct channel, chan struct{}, is the idiomatic “signal, no data” channel. struct{} occupies zero bytes, so the channel carries pure timing and nothing else. You will see it constantly for done-signals and quit-signals.

When there is no possible receiver: deadlock

So a send needs a receiver. What if there cannot be one? Consider a send with no other goroutine in the program:

package main

import "fmt"

func main() {
	ch := make(chan int)
	ch <- 1 // no other goroutine can ever receive
	fmt.Println("this line is never reached")
}

main blocks on the send, waiting for a receiver. But main is the only goroutine, and it is stuck on this very line, so no receiver can ever appear. The runtime detects that every goroutine is blocked with no way forward and aborts the program:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/.../c2-02-deadlock/main.go:7 +0x28

Read that message carefully, because you will meet it often. It is a fatal error, not a panic. That distinction is real and has teeth: a panic can be caught with recover (as the errors chapter covered), but a fatal error cannot. There is no recovering from a deadlock; the runtime has proven that the program can make no further progress and pulls the plug. The process exits with status code 2, which I confirmed by building the binary and running it directly rather than through go run (which wraps the child’s exit code in its own reporting). The [chan send] annotation tells you the stuck goroutine was blocked on a send, and the stack trace points at the exact line. When you see this error, the question to ask is always the same: who was supposed to be on the other end, and why weren’t they?

The deadlock detector is a gift, but do not over-trust it. It fires only when all goroutines are asleep. A program where one goroutine is wedged forever while others keep running is also broken, and that one the runtime will not catch for you. We return to those leaks later in the series.

Direction types: channels that only send or only receive

A plain chan int can both send and receive. But when you pass a channel to a function, you usually want that function to do only one of the two. Go lets you say so in the type. chan<- int is a send-only channel and <-chan int is a receive-only channel; the arrow points the way the values are allowed to flow.

package main

import "fmt"

// produce may only send on out (chan<- int).
func produce(out chan<- int, n int) {
	for i := 1; i <= n; i++ {
		out <- i
	}
	close(out)
}

// sum may only receive from in (<-chan int).
func sum(in <-chan int, result chan<- int) {
	total := 0
	for v := range in {
		total += v
	}
	result <- total
}

func main() {
	nums := make(chan int)
	result := make(chan int)
	go produce(nums, 5)
	go sum(nums, result)
	fmt.Println("sum 1..5 =", <-result)
}
sum 1..5 = 15

main makes ordinary bidirectional channels and hands them to the two functions, which each get a restricted view. Go converts a bidirectional channel to a directional one automatically at the call, so the caller writes no ceremony; the function signature is where the direction is declared. This is not just documentation. The compiler enforces it. If produce tried to receive on its send-only channel, the build fails:

invalid operation: cannot receive from send-only channel chan<- int

Use direction types in every signature that takes a channel. They turn “this function is a producer” from a comment into a fact the compiler checks, and they make a whole pipeline readable at a glance: you can see which end of each channel a stage owns without reading its body.

The nil channel blocks forever

One last sharp edge, because it looks like nothing and behaves like a wall. A channel variable that was never initialized with make is nil, and operations on a nil channel block forever — send and receive alike, with no partner able to ever unblock them.

package main

import "fmt"

func main() {
	var ch chan int // nil: never made with make
	fmt.Println("about to receive on a nil channel...")
	<-ch // blocks forever; no goroutine can ever send
	fmt.Println("unreachable")
}
about to receive on a nil channel...
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive (nil chan)]:
main.main()
	/.../c2-02-nil/main.go:8 +0x53

Because this is the only goroutine and it blocks permanently, the deadlock detector catches it, and the stack helpfully labels the cause (nil chan). In a larger program with other goroutines running, a stray nil channel does not trip the detector; it just quietly parks one goroutine forever, which is a much harder bug to find. The usual cause is a channel field or variable you forgot to make. It is worth knowing this looks like a hang, not a crash. And it is not purely a footgun: blocking-forever on nil turns out to be a genuinely useful trick in a select statement, where you disable a case by setting its channel to nil, but that is a tool for a later chapter.

Final thoughts

An unbuffered channel is a conduit and a synchronization point in one: a send blocks until a receiver is ready, a receive blocks until a sender is ready, and the value crosses in a single step that rendezvouses two goroutines. A send that can never be received produces fatal error: all goroutines are asleep - deadlock!, an unrecoverable exit with status 2, not a catchable panic. Declare channel direction in your function signatures so the compiler enforces who produces and who consumes, and remember that a nil channel blocks forever, which is a bug when accidental and a feature when deliberate. Next we give the channel some room to hold values, which changes the timing, and we learn how to signal that no more values are coming.

Next: buffered channels, closing, and ranging — capacity, close, and draining a channel with range.

Comments