Right, unbuffered channels. Let’s cut through the academic fluff. Think of these not as a “queue” or a “pipe,” but as a handshake. A very strict, very demanding, and perfectly synchronous handshake. I’m talking about two goroutines meeting at a specific point in time to exchange a value. One goroutine extends its hand with a value, and it will stand there, hand out, frozen in time, until another goroutine reaches out and takes it. Conversely, a goroutine that reaches out to take a value will stand there, hand out, until another goroutine is ready to give it one. This isn’t a mailbox; it’s a meeting point.

The core principle is this: every send operation on an unbuffered channel must have a corresponding receive operation ready to go at the same instant, and vice-versa. This is what we call “synchronous rendezvous.” The two operations are coupled. They happen at the same time. If that handshake isn’t possible, the goroutine blocks (pauses its execution) until it is.

Here’s the simplest, most perfect illustration. If you run this, the main goroutine will block forever on the send operation, and you’ll get the classic fatal error: all goroutines are asleep - deadlock!. Why? Because there’s no other goroutine anywhere that’s ready to receive from ch. It’s like trying to high-five a ghost.

func main() {
    ch := make(chan int) // Unbuffered. The default.
    ch <- 42             // Main goroutine blocks here, forever alone.
    fmt.Println(<-ch)    // This line is never reached.
}

To make it work, you need another participant. The most common way is to fire off a goroutine to handle one side of the transaction.

func main() {
    ch := make(chan int)

    go func() {
        value := <-ch // The goroutine blocks here, waiting for the send.
        fmt.Println("Received:", value)
    }()

    fmt.Println("Sending...")
    ch <- 42 // The main goroutine can now send. The handshake occurs.
    fmt.Println("Sent!")
    time.Sleep(time.Second) // (Wait for the print to finish)
}
// Output:
// Sending...
// Received: 42
// Sent!

Notice the order of the prints. "Sending..." prints first. Then, the ch <- 42 line executes. At that exact moment, the handshake happens: the value is transferred from the main goroutine to the anonymous one, which then prints "Received: 42". Finally, the main goroutine is unblocked and prints "Sent!". The send and receive were two sides of the same, single event.

Why This Seems Inefficient (And Why It’s Not)

Your first thought might be, “Blocking? Waiting? This sounds horribly inefficient.” And in a preemptive multitasking world, you’d be right. But this is Go. The magic is in the scheduler. When a goroutine blocks on a channel operation, it’s not burning CPU cycles in a busy loop. It’s politely stepping aside so the scheduler can immediately run another goroutine that is ready to work on an OS thread. This is the cornerstone of Go’s concurrency model: efficiently waiting for events rather than wasting resources polling for them.

The Most Important Consequence: Execution Ordering

This blocking behavior isn’t a bug; it’s the primary feature. We use it explicitly to synchronize goroutines and control the order of execution. It’s a much cleaner synchronization primitive than mutexes for many use cases. Look at this example, which ensures the “worker” has finished its work before the main function proceeds.

func worker(done chan bool) {
    fmt.Print("working...")
    time.Sleep(time.Second)
    fmt.Println("done")
    done <- true // Signal that we're finished
}

func main() {
    done := make(chan bool)
    go worker(done)
    <-done // Block here until we receive the signal from the worker
}

The <-done receive is a gate. The main function cannot finish until the worker sends its completion signal. This is a fundamental pattern for coordinating goroutines.

Common Pitfalls and The Deadlock

The deadlock error is your friend. It’s the compiler and runtime screaming that your goroutines are all permanently blocked, waiting for each other in a cycle that can never be resolved. The classic beginner mistake is trying to use an unbuffered channel within the same goroutine.

func main() {
    ch := make(chan int)
    ch <- 42          // Send blocks, waiting for a receiver...
    fmt.Println(<-ch) // ...but this receive is never reached.
}

This creates a Catch-22 of blocking. The solution, as shown before, is concurrency. You must have at least two goroutines involved for an unbuffered channel to work. If you find yourself needing to use a channel within a single goroutine without a second one, you’re almost certainly using the wrong tool—perhaps a buffered channel or a different data structure entirely.

So, when do you reach for an unbuffered channel? When the semantics of a handshake are what you actually want. When you need to know that the other party has received the value at the moment you send it. When you’re using channels not just for communication but explicitly for synchronization. For everything else—like queuing work, decoupling producers and consumers, or limiting throughput—there are buffered channels. But that’s a story for the next section.