The red text that stops your program
You write a producer that pushes work into a channel. You write a consumer that reads from it. You run the program and the terminal spits out bright red text before freezing completely. The runtime kills your process with fatal error: all goroutines are asleep - deadlock!. The program does not recover. The scheduler gives up.
This message is not a compiler error. It is a runtime panic that triggers when the Go scheduler detects a circular wait. Every active goroutine is blocked on a channel operation, and no other goroutine is awake to unblock them. The runtime assumes you are stuck forever and terminates the process to prevent a silent hang.
The handshake that never finishes
Unbuffered channels enforce a strict synchronization rule. A send and a receive must happen at the exact same moment. Think of it as two people meeting at a closed door to pass a physical object. One person holds the object against the door. The other person must arrive at the same time to take it. If only one person shows up, both wait. Neither can leave. Neither can proceed.
Go models this as a blocking operation. When a goroutine executes ch <- value, it parks itself until another goroutine executes <-ch. When a goroutine executes <-ch, it parks itself until another goroutine executes ch <- value. The scheduler tracks these parked goroutines. If it checks the system and finds every single goroutine parked on a channel with no counterpart awake, it triggers the deadlock panic.
Buffered channels change the geometry. A buffered channel is a hallway with a small bench. The sender can place the object on the bench and walk away, as long as the bench has space. The receiver can grab the object from the bench and walk away, as long as the bench is not empty. The handshake only happens when the bench is completely full or completely empty.
A minimal reproduction and the fix
The simplest deadlock happens when you launch a sender but never launch a receiver.
package main
import "fmt"
// RunDeadlock demonstrates a missing receiver
func RunDeadlock() {
ch := make(chan int) // Unbuffered channel requires simultaneous send/receive
go func() {
ch <- 42 // Sender blocks here waiting for a receiver
}()
// No receiver exists. The main goroutine finishes immediately.
// The runtime detects the parked goroutine and panics.
}
The fix is straightforward. You must provide a receiver that runs concurrently with the sender.
package main
import "fmt"
// RunFixed demonstrates a paired send and receive
func RunFixed() {
ch := make(chan int) // Unbuffered channel enforces synchronization
go func() {
ch <- 42 // Sender blocks until main goroutine reads
}()
fmt.Println(<-ch) // Receiver unblocks the sender, then main exits
}
The sender and receiver now meet at the channel. The scheduler wakes both goroutines, transfers the value, and lets them continue. The program finishes cleanly.
Goroutines are cheap. Channels are not magic.
What the runtime actually sees
The Go scheduler maintains a queue of runnable goroutines and a list of parked goroutines. When a goroutine hits a channel operation, the scheduler checks the channel state. If the channel is unbuffered and empty, the sender parks. If the channel is unbuffered and full, the receiver parks. The scheduler then looks for a matching parked goroutine on the other side. If it finds one, it wakes both and transfers the value. If it does not find one, it puts the current goroutine to sleep and schedules another goroutine to run.
The deadlock check runs periodically. The runtime walks through every goroutine in the system. If it finds that every single goroutine is in a chanrecv or chansend sleep state, and no goroutine is ready to run, it triggers the panic. The runtime does not wait for a timeout. It does not guess. It assumes the program is logically broken and stops execution.
This behavior saves you from infinite loops that consume CPU cycles doing nothing. It forces you to fix the synchronization logic before the program ships.
Real world: mismatched loops and silent hangs
Deadlocks rarely happen in single-line examples. They appear when loop bounds, channel closing, and goroutine lifecycles drift out of sync. Consider a worker pool that processes jobs until a done signal arrives.
package main
import (
"fmt"
"sync"
)
// ProcessWorkers demonstrates a mismatched loop causing deadlock
func ProcessWorkers() {
jobs := make(chan int, 5)
results := make(chan string)
var wg sync.WaitGroup
// Start three workers
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs { // Workers block here waiting for jobs
results <- fmt.Sprintf("worker %d processed %d", id, j)
}
}(i)
}
// Send five jobs
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs) // Signal workers that no more jobs are coming
// Collect results
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
wg.Wait() // Wait for workers to finish
}
This code works because the range loop on jobs exits cleanly when close(jobs) is called. The workers finish, wg.Done() fires, and wg.Wait() returns. The main goroutine collects exactly five results. The counts match. The channels drain.
Now imagine the main goroutine only collects four results. The fifth result sits in the results channel. The workers are already done. The main goroutine blocks on <-results forever. The runtime eventually panics with the deadlock message. The fix is to ensure the receiver loop matches the sender count, or to use a done channel with select to break out of the receive loop when the work is complete.
Convention matters here. The Go community treats channel closing as a signal to the sender, not the receiver. The sender closes the channel when it is finished producing. The receiver uses range or checks the second return value of <-ch to detect closure. Never close a channel from the receiving side. Never close a channel that might still have active senders.
Pitfalls and runtime behavior
The deadlock panic is specific to circular waits on channels. Other channel mistakes trigger different runtime behavior.
Sending to a closed channel triggers an immediate panic with panic: send on closed channel. This is not a deadlock. The runtime catches it instantly because it is a programming error, not a synchronization state.
Receiving from a closed channel does not panic. It returns the zero value for the channel type and a boolean false indicating the channel is closed. This is why range works on closed channels. The loop condition checks the boolean and exits cleanly.
Buffered channels can also deadlock. If you create a channel with make(chan int, 3) and send four values without receiving, the fourth send blocks. If no receiver exists, the runtime panics with the same deadlock message. The buffer size only delays the block. It does not remove it.
Goroutine leaks are the cousin of deadlocks. A goroutine waits on a channel that never gets closed. The program finishes, but the goroutine stays parked in memory. The runtime does not panic. The process exits, and the leaked goroutine is cleaned up by the OS. The leak is invisible until you run a profiler or stress test. Always provide a cancellation path. Use context.Context as the first parameter in long-running functions. Pass it through every call site. Cancel it when the work is done.
The worst goroutine bug is the one that never logs.
When to reach for which pattern
Concurrency patterns exist to solve specific synchronization problems. Pick the tool that matches your data flow.
Use an unbuffered channel when you need strict synchronization between two goroutines and want to guarantee the sender blocks until the receiver is ready. Use a buffered channel when you want to decouple producers and consumers temporarily and allow a small batch of work to queue without blocking. Use a select statement with a default case when you need non-blocking channel operations and want to proceed immediately if the channel is empty or full. Use a done channel or context.Context when you need to signal multiple goroutines to stop and prevent goroutine leaks. Use a sync.WaitGroup when you need to wait for a fixed number of goroutines to finish before proceeding. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Trust the scheduler. Design the flow.