The program freezes, then screams
You write a Go program that spawns a worker to fetch data. You expect the main function to print the result. Instead, the terminal hangs. After a moment, the runtime prints fatal error: all goroutines are asleep - deadlock! and the program crashes. You stare at the code. The logic flows. The syntax is valid. The compiler accepted it. The runtime disagrees.
This error is the Go runtime's way of telling you that your program has reached a stalemate. Every active goroutine is blocked, waiting for something that will never happen. The runtime detects this condition and kills the program to save you from an infinite hang. Understanding why this happens requires looking at how goroutines and channels interact under the hood.
What a deadlock actually is
A deadlock occurs when two or more goroutines are waiting for each other, and none can proceed. In Go, this almost always involves channels. Channels are the pipes that goroutines use to communicate and synchronize.
When you send a value on an unbuffered channel, the sender pauses until a receiver is ready. When you receive from an unbuffered channel, the receiver pauses until a sender is ready. This synchronization is powerful. It ensures data is passed safely between goroutines without locks. It also creates a tight coupling. If the sender is waiting for a receiver that is also waiting for the sender, nobody moves.
Think of it like two people standing at a narrow door. Both want to pass through. Both wait for the other to step aside. Neither steps aside. They stand there forever. The runtime looks at the scene, sees that nobody is moving, and pulls the fire alarm.
Minimal example: The missing keyword
The most common cause of a deadlock is forgetting to start a goroutine. You write a function that sends data to a channel. You call that function from main. You expect it to run in the background. It does not.
package main
import "fmt"
// Main demonstrates a deadlock caused by a missing go keyword.
func main() {
ch := make(chan int)
// producer blocks here because main is not receiving yet.
// main blocks on the next line waiting for producer.
// Nobody moves.
producer(ch)
fmt.Println(<-ch)
}
// Producer sends a value to the channel.
func producer(ch chan int) {
ch <- 42
}
The compiler accepts this code. It sees a valid function call. It sees a valid channel operation. It does not know that producer will block forever. The runtime finds out at execution time.
Walk through what happens
Execution starts in main. The channel ch is created. It is unbuffered. This means sends and receives must happen simultaneously.
main calls producer(ch). This is a synchronous call. main pauses and waits for producer to return.
Inside producer, the code tries to send 42 to ch. The channel is unbuffered. There is no receiver ready. producer blocks. It waits for someone to receive from ch.
Back in main, the next line is fmt.Println(<-ch). This line tries to receive from ch. It blocks. It waits for someone to send to ch.
The runtime now has two goroutines. main is blocked waiting for a send. producer is blocked waiting for a receive. Both are waiting for each other. No other goroutines exist. The runtime checks the state of all goroutines. It finds that all are asleep. It triggers the deadlock panic.
The fix is to add the go keyword.
package main
import "fmt"
// Main demonstrates the correct way to start a goroutine.
func main() {
ch := make(chan int)
// go keyword starts producer in a new goroutine.
// main continues to the next line immediately.
go producer(ch)
fmt.Println(<-ch)
}
// Producer sends a value to the channel.
func producer(ch chan int) {
ch <- 42
}
Now producer runs in a separate goroutine. main reaches the receive line. The two goroutines synchronize at the channel. producer sends. main receives. Both unblock. The program prints 42 and exits.
Goroutines are cheap. Channels are not magic.
Realistic example: Pipeline stall
In real code, deadlocks often hide in pipelines. A pipeline is a series of stages connected by channels. Each stage reads from an input channel, processes data, and sends to an output channel. If one stage is not running, the whole pipeline freezes.
package main
import "fmt"
// Generate sends numbers to a channel.
func Generate(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
// Square reads from in, squares values, sends to out.
func Square(in <-chan int, out chan<- int) {
for n := range in {
out <- n * n
}
close(out)
// Convention: range loop handles channel closure.
// No need for explicit close check inside the loop.
}
// Main orchestrates the pipeline.
func main() {
gen := make(chan int)
sq := make(chan int)
go Generate(gen)
// Missing go keyword on Square causes deadlock.
// Generate sends to gen. Square is not running to receive.
// Generate blocks. Main blocks waiting for sq.
Square(gen, sq)
for n := range sq {
fmt.Println(n)
}
}
Generate runs in a goroutine. It sends 0 to gen. Square is called synchronously in main. Square tries to receive from gen. Generate is blocked sending to gen. Square is blocked receiving from gen. main is blocked calling Square. Deadlock.
The fix is to start Square in a goroutine.
package main
import "fmt"
// Generate sends numbers to a channel.
func Generate(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
// Square reads from in, squares values, sends to out.
func Square(in <-chan int, out chan<- int) {
for n := range in {
out <- n * n
}
close(out)
}
// Main orchestrates the pipeline.
func main() {
gen := make(chan int)
sq := make(chan int)
go Generate(gen)
go Square(gen, sq)
// Both stages run concurrently.
// Main consumes results from the final stage.
for n := range sq {
fmt.Println(n)
}
}
Now Generate and Square run in parallel. Generate sends to gen. Square receives from gen. Square sends to sq. main receives from sq. Data flows through the pipeline.
Pitfalls and runtime errors
Deadlocks come in many shapes. The missing go keyword is the simplest. Circular dependencies are more subtle. Goroutine A waits for Goroutine B. Goroutine B waits for Goroutine A. The runtime detects this just as it detects the missing goroutine. All goroutines are asleep.
Another common trap is using sync.WaitGroup incorrectly. If you call Wait before Add, or forget to call Done, the program hangs. The runtime does not panic on a WaitGroup hang. It just waits forever. You have to catch this yourself.
Buffered channels can hide deadlocks. A buffered channel allows sends without an immediate receiver, up to the buffer size. If you fill the buffer and then try to send again, the sender blocks. If no receiver is draining the buffer, the program deadlocks. The runtime still detects this. The error message is the same.
package main
import "fmt"
// Main demonstrates a buffered channel deadlock.
func main() {
ch := make(chan int, 1)
ch <- 1
ch <- 2
// Buffer is full. Send blocks.
// No receiver is running.
// Deadlock.
fmt.Println(<-ch)
}
The runtime panics with fatal error: all goroutines are asleep - deadlock!. The buffer size was one. The first send succeeded. The second send blocked. main never reached the receive line.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Silent errors often lead to deadlocks. If a function fails to start a goroutine and swallows the error, the program might deadlock later with no clear cause. Return the error. Handle it.
Prevention strategies
You can prevent deadlocks by designing your concurrency carefully. Use buffered channels when you want to decouple senders and receivers temporarily. This allows a sender to make progress even if the receiver is slow. It does not eliminate deadlocks. It just moves the blocking point.
Use select to wait on multiple channels. select allows a goroutine to wait on several channel operations. It proceeds with the first one that is ready. If none are ready, it blocks. If you include a default case, it never blocks. This is useful for non-blocking sends and receives.
package main
import "fmt"
// Main demonstrates a non-blocking send using select.
func main() {
ch := make(chan int)
select {
case ch <- 1:
fmt.Println("Sent")
default:
fmt.Println("Channel full or no receiver")
}
}
This code prints Channel full or no receiver. The send does not block. The program continues. This pattern prevents deadlocks in cases where a receiver might not be ready.
Use context.Context to cancel waiting goroutines. If a goroutine is waiting on a channel, give it a way to wake up if the operation takes too long. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
package main
import (
"context"
"fmt"
"time"
)
// FetchData simulates a slow operation.
func FetchData(ctx context.Context, ch chan<- string) {
select {
case <-ctx.Done():
fmt.Println("Cancelled")
return
case ch <- "data":
fmt.Println("Sent")
}
}
// Main demonstrates cancellation.
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ch := make(chan string)
go FetchData(ctx, ch)
select {
case <-ctx.Done():
fmt.Println("Timeout")
case data := <-ch:
fmt.Println(data)
}
}
This code prints Timeout. The goroutine wakes up when the context expires. It does not wait forever. Context is plumbing. Run it through every long-lived call site.
Decision: when to use this vs alternatives
Use an unbuffered channel when you need strict synchronization between two goroutines. Use a buffered channel when you want to allow a sender to make progress without an immediate receiver. Use sync.WaitGroup when you need to wait for multiple goroutines to finish without passing data. Use context.Context when you need to cancel a waiting goroutine. Use select when you have multiple channels and want to handle whichever is ready first. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
The worst goroutine bug is the one that never logs.