The silent hang
You are building a log processor. One goroutine reads lines from a file and pushes them to a channel. Three worker goroutines pull from that channel, parse the lines, and write to a database. The file has 10,000 lines. The workers process them all. Then they sit there, waiting for more lines that will never come. Your program hangs. The CPU usage drops to zero, but the process never exits. You killed it with Ctrl+C. The problem isn't the workers. It is the signal. The sender finished, but the receivers have no idea.
This is the most common concurrency bug in Go. Goroutines block on channel operations until a value arrives or the channel closes. If the sender stops sending but never closes the channel, the receivers block forever. The program appears dead. The goroutines are alive, but they are stuck waiting on a door that never opens.
Closing is a broadcast
Closing a channel in Go is a broadcast signal. It tells every goroutine listening on that channel that the stream of data has ended. It is not a cleanup step. It is a communication primitive. When you close a channel, you are not destroying the data inside. You are flipping a flag that says "no more values will arrive."
Receivers can detect this flag and stop waiting. Without closing, a receiver blocks forever on a channel that has no more senders. The channel itself stays open, and the receiver assumes more data might appear later. The runtime cannot guess that you are done. You must tell it explicitly.
Think of a channel like a conveyor belt in a factory. Workers stand at the end, grabbing items as they arrive. If the machine at the start stops producing, the workers keep reaching for items that never come. Closing the channel is like pulling a lever that stops the belt and flashes a red light. The workers see the light, finish processing the items already in their hands, and go home.
Closing is a signal. Don't treat it as garbage collection.
Minimal example
Here is the simplest pattern: one sender, one receiver, a close at the end. The sender pushes values, closes the channel, and the receiver drains the channel using a range loop.
package main
import "fmt"
func main() {
// Buffered channel holds 3 values so the sender can finish without blocking
ch := make(chan int, 3)
// Sender pushes values and closes the channel to signal completion
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
// Receiver reads until the channel is closed and empty
for val := range ch {
fmt.Println(val)
}
// Range loop exits automatically when ch is closed and drained
}
The range loop is the idiomatic way to consume a channel. It handles the drain-and-exit logic for you. It pulls values one by one. When the channel is closed and the buffer is empty, the loop breaks. You don't need to check a boolean flag. You don't need a separate stop signal. The close carries the information.
What happens at runtime
When you call close(ch), the runtime marks the channel as closed. Any subsequent send on that channel triggers a panic. The runtime catches this immediately and aborts the goroutine. The error message is panic: close of closed channel. This is a hard error. The program crashes.
Receivers behave differently. If the channel has buffered values, the receiver pulls them out one by one. Once the buffer is empty and the channel is closed, the receive operation returns the zero value of the element type and a boolean false. The range loop checks this boolean internally. When it sees false, it breaks the loop.
You can also use the two-value receive to check the state manually. This is useful when you need to distinguish between a zero value and a closed channel, or when you are polling inside a select statement.
val, ok := <-ch
if !ok {
// Channel is closed and empty
fmt.Println("Stream ended")
}
The ok value is true when a value is received. It is false when the channel is closed and empty. If the channel is closed but still has buffered values, ok is true and you get the next buffered value. The false only appears when the stream is truly exhausted.
The runtime enforces the invariant. One close. Many receives.
Realistic pipeline
Real code often involves multiple stages. A common pattern is a pipeline where one goroutine generates data, and multiple workers consume it. The generator must close the channel when it is done. If the generator crashes or returns early without closing, the workers leak.
Here is a realistic example with a generator and multiple workers. The generator uses defer to ensure the channel closes even if the function returns early. The main function uses a sync.WaitGroup to wait for the workers to finish.
package main
import (
"fmt"
"sync"
)
// generateNumbers sends integers to a channel and closes it when finished
func generateNumbers(ch chan<- int) {
defer close(ch)
// Defer ensures the channel closes even if the function panics or returns early
for i := 1; i <= 5; i++ {
ch <- i
}
}
// worker prints values from the channel until it is closed
func worker(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
// Signal the WaitGroup when this worker exits
for val := range ch {
fmt.Printf("Worker %d got %d\n", id, val)
}
fmt.Printf("Worker %d finished\n", id)
}
func main() {
// Unbuffered channel pairs sender and receiver directly
ch := make(chan int)
var wg sync.WaitGroup
// Add three workers to the WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, ch, &wg)
}
// Run generator in a separate goroutine
go generateNumbers(ch)
// Wait for all workers to finish
wg.Wait()
}
The defer close(ch) pattern is common, but use it with care. If the function has multiple return paths that might close the channel elsewhere, defer causes a double-close panic. Use defer only when the close is the sole responsibility of that scope. If multiple goroutines might close the channel, use a sync.Once to guard the close operation.
Trust gofmt. Argue logic, not formatting.
Pitfalls and runtime panics
Closing a channel twice panics. The compiler cannot catch this. The runtime detects it and aborts the program with panic: close of closed channel. This happens when multiple goroutines try to close the same channel, or when a function returns early and a deferred close runs after an explicit close.
Closing a receive-only channel is a compile-time error. The compiler rejects this with invalid operation: close(ch) (cannot close receive-only channel). The type system protects you here. You can only close a channel if you have send rights.
Closing a nil channel panics. The runtime aborts with panic: close of nil channel. Always check for nil before closing, or ensure the channel is initialized.
A closed channel in a select statement causes a busy loop. In a select, a closed channel is always ready to receive. The receive returns the zero value and ok is false. If you have a select with a case that reads from a closed channel, that case runs immediately. If you don't break out of the loop, the goroutine spins forever, consuming CPU.
for {
select {
case val, ok := <-ch:
if !ok {
// Channel closed, break to avoid spin
return
}
fmt.Println(val)
}
}
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If the sender dies, the receiver hangs. Use context.Context to propagate cancellation. If the context is done, stop sending and close the channel.
Context is plumbing. Run it through every long-lived call site.
Convention and design
The Go community has strong conventions around channels. Public names start with a capital letter. Private names start lowercase. Channel variables are usually private. Expose methods that return channels, not the channel itself. This keeps the implementation details hidden.
Don't pass a *string. Strings are cheap to pass by value. Send the string directly over the channel. Pointers add indirection without benefit.
When receiving from a channel just to drain it, use the blank identifier. for range ch discards values and waits for the close. This is cleaner than for { <-ch } which blocks forever if the channel is never closed.
The receiver name is usually one or two letters matching the type. (s *Stream) Close() is better than (self *Stream) Close(). Keep receiver names short and consistent.
Accept interfaces, return structs. If a function returns a channel, it returns a struct-like value. The caller accepts the channel interface. This mantra applies to the types flowing through the channel as well. If you are sending objects, send structs. If you are accepting behavior, accept interfaces.
The worst goroutine bug is the one that never logs. A leaked receiver is silent. Add logging when workers finish. If a worker doesn't log, you know it leaked.
Decision matrix
Use close(ch) when a single sender finishes producing data and needs to signal receivers to stop.
Use a done channel with close(done) when you need to broadcast a cancellation signal to multiple goroutines without sending data.
Use context.Context when you need to propagate cancellation with deadlines or values across function boundaries, especially in HTTP handlers.
Use a sync.WaitGroup when you need to wait for goroutines to finish but don't need to communicate data between them.
Use a buffered channel without closing when the channel acts as a queue and the lifetime of the channel matches the lifetime of the program.
Channels communicate. Context cancels. WaitGroups wait. Pick the tool for the job.