Go Deadlocks: When Simple Concurrency Waits Forever
Go makes concurrency look simple.
Goroutines are cheap.
Channels are easy to use.
sync.WaitGroup looks straightforward.
sync.Mutex has only a couple of methods.
And that is exactly why deadlocks are easy to create.
Deadlocks in Go rarely come from complexity. They come from broken waiting relationships.
The program is not frozen.
It is waiting forever.
What Is a Deadlock in Go?
A deadlock happens when a goroutine is waiting for something that will never happen.
Not slow. Not temporarily blocked. Never.
Here is the simplest example:
package main
func main() {
ch := make(chan int)
ch <- 1
}
This program has a sender, but no receiver.
In an unbuffered channel, a send blocks until another goroutine receives the value. Here, there is no other goroutine.
So the program waits forever.
Go detects this and crashes:
fatal error: all goroutines are asleep - deadlock!
That message is useful.
But it can also be misleading.
Go Does Not Catch Every Deadlock
The runtime only detects deadlocks when all goroutines are blocked.
In real systems such as HTTP servers, workers, and background jobs, you can easily have this situation:
- one goroutine is stuck forever
- the rest of the system keeps running
No crash. No error.
But still a bug.
A request never returns. A worker never finishes.
That is also a deadlock.
Sending Without a Receiver
ch := make(chan string)
ch <- "hello"
Unbuffered channels require both sides.
A send blocks until a receiver is ready.
No receiver means infinite wait.
Receiving Without a Sender
ch := make(chan string)
msg := <-ch
A receive blocks until someone sends.
No sender means infinite wait.
Buffered Channels Only Delay the Problem
ch := make(chan int, 1)
ch <- 1
ch <- 2
The first send succeeds. The second send blocks.
Buffering does not fix deadlocks.
It only postpones them.
This is a common trap:
It works locally, because the buffer did not fill yet.
Ranging Over a Channel That Never Closes
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
}()
for v := range ch {
println(v)
}
This prints 1 and 2, then hangs forever.
Because:
range ch does not mean until values stop coming.
It means until the channel is closed.
Correct version:
go func() {
defer close(ch)
ch <- 1
ch <- 2
}()
WaitGroup Deadlocks
Classic example:
var wg sync.WaitGroup
wg.Add(1)
go func() {
println("work")
// wg.Done() is missing
}()
wg.Wait()
Wait() blocks forever.
Correct usage:
wg.Add(1)
go func() {
defer wg.Done()
println("work")
}()
Subtle but critical mistake:
go func() {
wg.Add(1)
defer wg.Done()
}()
wg.Wait()
This is wrong.
Add must be called before starting the goroutine.
Mutex Deadlocks
Forgetting to unlock
mu.Lock()
return
mu.Unlock()
The mutex stays locked forever.
Correct:
mu.Lock()
defer mu.Unlock()
Locking the same mutex twice
mu.Lock()
mu.Lock()
Go’s sync.Mutex is not reentrant.
Even the same goroutine will block on the second lock.
Channel Ownership Matters
A useful rule is this:
the sender owns the channel lifecycle.
Meaning:
- the sender closes the channel
- receivers only consume
If a receiver closes the channel while senders are still active:
close(ch)
ch <- 1 // panic
This is not just a panic risk.
Broken ownership often leads to coordination issues and eventually deadlocks.
select Does Not Save You
select {
case msg := <-ch:
println(msg)
case <-time.After(time.Second):
println("timeout")
}
This helps.
But it does not fix broken logic.
It only makes waiting visible.
A more production-shaped version is usually this:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
select {
case msg := <-ch:
println(msg)
case <-ctx.Done():
println("cancelled:", ctx.Err())
}
Still, the underlying rule stays the same:
if no one sends, nothing magical happens.
How to Think About Deadlocks
Stop thinking in syntax.
Think in relationships.
Ask:
- Who is waiting?
- What are they waiting for?
- Who is responsible for providing it?
- What happens if that never happens?
Most deadlocks are not complex.
They are just missing actors.
Final Thoughts
Go’s concurrency model is powerful because it is small.
But small does not mean safe.
It just means you can break things faster.
Deadlocks are not really about channels, mutexes, or WaitGroups.
They are about waiting without guarantees.
The syntax is simple.
The responsibility is not.