Common Channel Mistakes and Deadlocks in Go

Prevent Go deadlocks by ensuring channel operations have matching sends and receives or using buffered channels.

When the handoff never happens

You write a Go program that spawns a few goroutines to process data. You run it, and instead of printing results, the terminal just sits there. You hit Ctrl+C, and the stack trace screams fatal error: all goroutines are asleep - deadlock!. The program froze because two parts of your code are waiting for each other, and neither will move first.

This happens more often than you expect, especially when coming from languages where threads just run until they finish. Go channels are powerful, but they enforce synchronization strictly. If you block a send or receive with no hope of progress, the runtime stops everything and tells you. Deadlocks are logic errors, not syntax errors. The compiler trusts you to coordinate.

Channels enforce a strict handshake

A deadlock is a traffic jam where every car is waiting for the car in front to move, but the car in front is waiting for you. In Go, channels act like a synchronized handoff. An unbuffered channel requires a sender and a receiver to meet at the exact same moment. If one side shows up and the other isn't there, the side that showed up stands there waiting forever.

Think of an unbuffered channel like passing a baton in a relay race. The runner holding the baton cannot move forward until the next runner grabs it. If the next runner hasn't arrived, the first runner stands still. The baton doesn't disappear into thin air. It stays in the hand of the sender until a receiver takes it.

A buffered channel adds a drop box. The sender can drop the baton in the box and keep running, as long as the box isn't full. The receiver can grab the baton from the box whenever they arrive. Buffering decouples the timing of the sender and receiver, but it doesn't remove the need for coordination. If the box fills up, the sender blocks again. If the box is empty, the receiver blocks.

Channels are reference types. When you pass a channel to a function or assign it to a variable, you copy the reference, not the underlying buffer. Multiple goroutines can hold references to the same channel and communicate through it safely. The channel itself is synchronized. You don't need a mutex to protect the channel operations.

The minimal deadlock

The simplest deadlock occurs when a goroutine tries to send on an unbuffered channel with no receiver ready. The compiler does not catch this. The code is syntactically valid. The error appears at runtime.

package main

import "fmt"

func main() {
	// Unbuffered channel requires a receiver ready before send completes.
	ch := make(chan int)

	// This send blocks until another goroutine reads from ch.
	// Since no other goroutine exists, this line never finishes.
	ch <- 42

	// This line is unreachable.
	fmt.Println("Done")
}

When you run this, the main goroutine executes ch <- 42. The channel has no buffer and no waiting receiver. The goroutine transitions to a blocked state. The scheduler looks for other runnable goroutines. There are none. The runtime detects that progress is impossible. It triggers the deadlock panic. The error message is blunt: fatal error: all goroutines are asleep - deadlock!.

This is a safety feature. Without it, your program would hang silently, consuming resources until you killed the process. The runtime saves you from silence. A panic is better than a hang.

Realistic deadlock: The waiting game

Real-world deadlocks often involve multiple goroutines and channels. A common pattern is a worker that sends results and signals completion, while the main goroutine waits for completion before reading results. If the worker blocks on the send, and the main goroutine blocks on the completion signal, neither can proceed.

package main

import "fmt"

// FetchResults simulates a worker that sends data and signals completion.
// It accepts a channel for data and a channel for the done signal.
func FetchResults(data chan<- string, done chan<- struct{}) {
	// Worker tries to send data.
	// This blocks if the channel is unbuffered and no one is reading.
	data <- "result"

	// Worker signals it is done.
	// This line is unreachable if the previous send blocks forever.
	done <- struct{}{}
}

func main() {
	// Unbuffered channel for data.
	data := make(chan string)
	// Unbuffered channel for completion signal.
	done := make(chan struct{})

	// Start worker in background.
	go FetchResults(data, done)

	// Main waits for the worker to finish before reading data.
	// The worker is blocked trying to send to data.
	// Main is blocked waiting for done.
	// Neither can proceed. Deadlock.
	<-done

	// This line is never reached.
	fmt.Println(<-data)
}

The deadlock here is subtle. The main goroutine waits on done. The worker goroutine tries to send to data. Since data is unbuffered, the worker blocks until main reads from data. But main won't read from data until it receives from done. The worker won't send to done until it finishes sending to data. Circular dependency.

The fix is to break the cycle. You can buffer the data channel so the worker can send without waiting. Or you can reorder the operations in main to read data before waiting for completion.

func main() {
	// Buffer size 1 allows the worker to send one value without blocking.
	data := make(chan string, 1)
	done := make(chan struct{})

	go FetchResults(data, done)

	// Main waits for completion.
	<-done

	// Now safe to read. The value is already in the buffer.
	fmt.Println(<-data)
}

Buffering hides latency but doesn't eliminate the need for coordination. If you buffer the channel, you must ensure the buffer size matches the expected workload. A buffer of one breaks this specific deadlock, but a buffer of zero would fail again. Order matters. If A waits for B, and B waits for A, nothing happens.

Closing channels safely

Closing a channel signals that no more values will be sent. Receivers can detect this by checking the second return value of a receive operation, or by using a range loop. Closing a channel is a one-way signal. Only the sender should close the channel. Receivers should never close a channel.

package main

import "fmt"

// Producer sends values and closes the channel when done.
func Producer(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	// Close signals no more values.
	// Only the sender closes.
	close(ch)
}

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

	go Producer(ch)

	// Range loop automatically stops when the channel is closed.
	for val := range ch {
		fmt.Println(val)
	}

	fmt.Println("Finished")
}

Closing a channel that is already closed causes a runtime panic. The compiler rejects panic: close of closed channel only if it can prove the channel is closed at compile time, which is rare. Usually, you get the panic at runtime. Similarly, sending on a closed channel causes a panic. The compiler rejects panic: send on closed channel at runtime if the channel was closed before the send.

A common mistake is closing a channel from multiple goroutines. If two goroutines try to close the same channel, one will succeed and the other will panic. Use a sync.Once to ensure a channel is closed exactly once, or use a select statement with a done channel to coordinate closure.

Another mistake is looping over a channel without closing it. The range loop waits forever for more values. The goroutine running the loop leaks. The worst goroutine bug is the one that never logs. Always ensure the sender closes the channel, or use a context to cancel the receiver.

Convention aside: Receiver names in methods should be short, usually one or two letters matching the type. Use (c *Channel) Send() not (this *Channel) Send(). This keeps code concise and readable.

Select and non-blocking operations

The select statement lets you wait on multiple channel operations. It picks one ready operation and executes it. If multiple operations are ready, it picks one at random. If no operations are ready, it blocks until one becomes ready.

You can use select with a default case to perform a non-blocking send or receive. The default case runs immediately if no other case is ready. This is useful for checking if a channel has a value without blocking.

package main

import "fmt"

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

	// Non-blocking send.
	// If the channel is full, the default case runs.
	select {
	case ch <- 42:
		fmt.Println("Sent")
	default:
		fmt.Println("Channel full, skipped")
	}

	// Non-blocking receive.
	// If the channel is empty, the default case runs.
	select {
	case val := <-ch:
		fmt.Println("Received:", val)
	default:
		fmt.Println("Channel empty")
	}
}

Using select with default is a common pattern for timeouts and graceful shutdowns. You can combine a channel operation with a timer channel to wait for a result with a deadline. If the timer fires first, the select picks the timer case and moves on.

Select gives you choice. Default gives you escape.

Goroutine leaks and silent failures

A goroutine leak occurs when a goroutine stays alive longer than necessary, usually because it is blocked on a channel that never receives a value. Goroutine leaks are silent. The program continues to run, but memory usage grows over time. Eventually, the process runs out of memory and crashes.

Leaks often happen when a receiver waits on a channel, but the sender exits without closing the channel or sending a value. The receiver waits forever. The goroutine is stuck in the blocked state, holding onto stack memory and resources.

To prevent leaks, ensure every goroutine has a path to exit. Use context.Context to propagate cancellation signals. Functions that start goroutines should accept a context as the first parameter, conventionally named ctx. The goroutine should listen for cancellation and exit promptly.

Convention aside: context.Context always goes as the first parameter. Functions that take a context should respect cancellation and deadlines. This is the standard way to manage goroutine lifecycles in Go.

You can detect goroutine leaks using the pprof tool. The goroutine profile shows the stack traces of all active goroutines. If you see many goroutines stuck on channel operations, you likely have a leak. Trust gofmt. Argue logic, not formatting. Most editors run gofmt on save, so focus your energy on correctness.

Decision matrix

Use an unbuffered channel when you need strict synchronization between a sender and a receiver, ensuring the send completes only when the receiver is ready.

Use a buffered channel when the sender should proceed without waiting for a receiver, or when you want to decouple the timing of production and consumption.

Use a select statement with a default case when you need to attempt a send or receive without blocking, allowing the goroutine to continue if the channel is not ready.

Use context.Context when you need to propagate cancellation signals or deadlines across goroutine boundaries, replacing manual done channels.

Use sync.WaitGroup when you need to wait for a set of goroutines to finish without exchanging data, avoiding the overhead of channels for simple coordination.

Use a single goroutine with sequential code when concurrency adds complexity without performance benefit, keeping the logic simple and debuggable.

Where to go next