When the program stops moving
You launch your new background worker. It fetches data, processes it, and prints the result. You run the program. The terminal sits silent. You check your network. You check your logic. Nothing is wrong. You wait thirty seconds. Then the runtime panics with fatal error: all goroutines are asleep - deadlock!.
Your program didn't crash because of a null pointer or a bad index. It crashed because every goroutine stopped moving at the exact same time. The runtime detected that no progress was possible and terminated the process to save you from an infinite hang.
The circle of waiting
A deadlock happens when a group of goroutines form a circular dependency. Goroutine A waits for a signal from B. Goroutine B waits for a signal from C. Goroutine C waits for a signal from A. No one can proceed because everyone is blocked by someone else in the chain.
Think of a restaurant kitchen. The chef waits for the waiter to bring ingredients. The waiter waits for the chef to finish the dish so they can take the next order. The dishwasher waits for the chef to use the sink. If everyone waits, nothing happens. The kitchen is deadlocked.
In Go, blocking operations create these waits. Reading from an unbuffered channel blocks until someone writes. Writing to an unbuffered channel blocks until someone reads. Locking a mutex blocks until someone unlocks it. Waiting on a sync.WaitGroup blocks until all counter values reach zero. If the blocking operations form a cycle, the program stops.
A deadlock is a perfect circle of waiting. Break the circle to move forward.
Minimal example: The lonely reader
The simplest deadlock involves a single goroutine and an unbuffered channel. Unbuffered channels require a handshake. A send blocks until a receive is ready. A receive blocks until a send is ready. If only one side exists, the handshake never completes.
package main
func main() {
// Create an unbuffered channel. Operations block until both sides are ready.
ch := make(chan int)
// This line blocks. The main goroutine waits for a value.
// No other goroutine exists to send a value.
// The runtime sees only one goroutine, and it is blocked.
<-ch
}
The program starts in main. It creates a channel. It attempts to read from the channel. The read operation blocks because no value is available. The runtime checks the state of all goroutines. There is only one goroutine, and it is blocked. The runtime concludes that deadlock has occurred and panics.
Unbuffered channels are strict. Both sides must be ready, or nothing happens.
How the runtime catches you
The Go runtime includes a deadlock detector. It runs whenever a goroutine blocks on a synchronization primitive. When a goroutine blocks, the runtime scans all other goroutines. If every goroutine is blocked, the runtime assumes a deadlock and panics.
This detection is a heuristic. It looks for a state where no goroutine can make progress. It works for channels, mutexes, WaitGroup, and other blocking calls. The panic message is fatal error: all goroutines are asleep - deadlock!.
The runtime saves you from silence. A panic is better than a hanging server.
Realistic example: The handshake trap
Deadlocks often appear in coordination patterns where two goroutines need to signal each other. A common mistake is reversing the order of operations, creating a cycle where each goroutine waits for the other to start.
package main
import "fmt"
func main() {
start := make(chan struct{})
done := make(chan struct{})
// Launch a worker that waits for a signal before doing work.
go func() {
// Block until main sends on start.
<-start
fmt.Println("Work complete")
// Signal that work is done.
done <- struct{}{}
}()
// BUG: Main waits for done before sending start.
// The worker is blocked on start.
// Main is blocked on done.
// Neither can proceed.
<-done
start <- struct{}{}
}
The worker goroutine blocks on <-start. It cannot proceed until main sends a value. The main goroutine blocks on <-done. It cannot proceed until the worker sends a value. The worker cannot send to done because it is stuck waiting for start. main cannot send to start because it is stuck waiting for done. The cycle is complete.
To fix this, main must send the start signal before waiting for the result. The sender must unblock the receiver before waiting for the receiver's response.
package main
import "fmt"
func main() {
start := make(chan struct{})
done := make(chan struct{})
go func() {
<-start
fmt.Println("Work complete")
done <- struct{}{}
}()
// Send start first to unblock the worker.
start <- struct{}{}
// Now wait for the worker to finish.
<-done
}
Draw the dependency graph. If the arrows form a loop, you have a deadlock.
Pitfalls and hidden traps
Deadlocks manifest in several ways. Channels are the most common source, but mutexes and buffered channels can also cause issues.
Mutex double-locking
Go's sync.Mutex is not reentrant. If a goroutine holds a lock and tries to acquire it again, it deadlocks. The mutex does not track which goroutine holds the lock. It only tracks whether the lock is free. When the same goroutine calls Lock a second time, it blocks waiting for the lock to be released. It can never release the lock because it is blocked.
package main
import "sync"
var mu sync.Mutex
func doWork() {
mu.Lock()
// ... some code ...
mu.Lock() // Deadlock: same goroutine tries to lock again.
mu.Unlock()
mu.Unlock()
}
The runtime panics with the same deadlock error. The convention in Go is to avoid nested locking of the same mutex. Refactor the code to release the lock before the recursive call, or split the logic so the lock is held only once.
Go's sync.Mutex is not reentrant. If you hold a lock and try to acquire it again, you deadlock. This design prevents accidental nested locking and keeps the implementation simple and fast.
Buffered channels hiding bugs
A buffered channel can mask a deadlock. If you change an unbuffered channel to a buffered channel with capacity one, the send might succeed without a receiver. The program runs, but the logic is still broken. The buffer fills up later, or a goroutine leaks because the channel is never read.
package main
func main() {
// Buffered channel with capacity 1.
ch := make(chan int, 1)
// This send succeeds immediately because the buffer has space.
ch <- 42
// No receiver. The value sits in the buffer forever.
// The program exits, but the goroutine that would read is missing.
// In a long-running server, this leaks memory and goroutines.
}
Using a buffer to fix a deadlock is a band-aid. It delays the failure but does not fix the coordination logic. The program might work in tests with low load and fail in production when the buffer fills. Fix the order of operations instead of adding capacity.
Buffered channels are not a fix. They are a decoupling tool. Use them to separate producers and consumers, not to hide missing synchronization.
Goroutine leaks vs deadlocks
A goroutine leak is different from a deadlock. In a leak, a goroutine blocks forever, but other goroutines continue running. The program does not panic. The leak consumes memory and file descriptors. Over time, the server runs out of resources.
Leaks often happen when a goroutine waits on a channel that never gets closed, or when a goroutine waits for a context cancellation that never arrives. The deadlock detector does not catch leaks because not all goroutines are asleep. Only the leaked goroutine is stuck.
Use context.Context with a timeout to prevent goroutine leaks. If a goroutine might hang, pass a context with a deadline. The goroutine should select on the context's done channel to exit early.
context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This prevents goroutines from waiting indefinitely.
Decision: Choosing the right coordination
Concurrency requires coordination. Pick the tool that matches your synchronization needs.
Use an unbuffered channel when you need strict synchronization between two goroutines and want to ensure the sender and receiver meet at the same moment. Use a buffered channel when the sender should not block immediately, or when you want to decouple the producer from the consumer to allow bursts of work. Use a sync.WaitGroup when you need to wait for a group of goroutines to finish without passing values between them. Use context.Context with a timeout when you suspect a goroutine might hang and need a safety net to cancel the operation. Use a mutex when multiple goroutines need exclusive access to shared memory, but be careful to never lock the same mutex twice in the same goroutine.
Concurrency is coordination. Design the handoffs, not just the speed.