The signal that ends the wait
You launch a goroutine to process a large file. The main function needs to wait for the file to finish before it can zip the result and send it to the user. You can't just return from main because the goroutine runs independently. You need a way for the worker to say "I'm finished" that the main function can hear.
A done channel is the standard Go idiom for this. It is a channel used purely for signaling, not for transferring data. The worker closes the channel when the work completes. The waiter reads from the channel and blocks until the close happens. When the channel closes, the read returns immediately, and the waiter knows it's time to move on.
This pattern is simple, but it relies on specific runtime behaviors of channels. Closing a channel is different from sending a value. Closing broadcasts to all receivers. Sending delivers a single value to a single receiver. Understanding the difference prevents deadlocks and panics.
How a done channel works
A done channel is usually created with make(chan struct{}). The type struct{} is an empty struct. It has no fields. It occupies zero bytes of memory. Using struct{} as the channel element type is a convention that signals "this channel carries no payload, only a signal."
When the goroutine finishes its work, it calls close(done). This marks the channel as closed. No more values can be sent. Any goroutine blocked on a receive operation <-done wakes up instantly. The receive returns the zero value of the element type. For struct{}, the zero value is struct{}{}. The receiver doesn't care about the value. It only cares that the receive completed.
package main
import "fmt"
func main() {
// struct{} takes zero memory. The channel carries a signal, not data.
done := make(chan struct{})
// Launch the worker in a separate goroutine.
go func() {
fmt.Println("Working...")
// close marks the channel as finished.
// All receivers blocked on done will wake up immediately.
close(done)
}()
// This read blocks until the channel is closed.
// It returns the zero value struct{}{} instantly upon close.
<-done
fmt.Println("Done")
}
The runtime handles the synchronization. The close call doesn't block. It happens as soon as the worker reaches it. The receive blocks until the close happens. If the close happens first, the receive returns immediately. If the receive happens first, the goroutine sleeps until the close occurs.
Goroutines are cheap. Channels are not magic. The done channel is just a wire that changes state.
Why close instead of send
You could send a value to signal completion. done <- true works if the channel is buffered or if a receiver is ready. Closing is better for three reasons.
First, closing is idempotent for the receiver. Multiple goroutines can wait on the same done channel. When the channel closes, all of them wake up. This is a broadcast. If you send a value, only one receiver gets it. The others keep waiting. You would need a buffered channel with enough capacity for all receivers, or a loop to send multiple times. Closing handles the fan-out automatically.
Second, closing doesn't require a receiver to be ready. If you send to an unbuffered channel and no one is receiving, the sender blocks. If the receiver hasn't started yet, you get a deadlock. Closing never blocks. The worker can close the channel even if the waiter is still initializing. The waiter will wake up as soon as it reaches the receive.
Third, closing communicates intent. close(done) reads as "the stream is finished." done <- true reads as "here is a boolean value." The compiler treats them differently, and other developers understand the convention instantly.
Convention aside: The community uses struct{} for done channels. If you see chan bool or chan int used for completion, it's usually a misunderstanding of the idiom. Stick to struct{}. It costs nothing and signals the purpose clearly.
Realistic pattern: waiting with a timeout
In production code, you rarely wait forever. Background tasks can hang. Networks fail. You need a way to give up if the work takes too long. The select statement lets you wait on multiple channels at once. You can wait for the done channel or a timer.
package main
import (
"fmt"
"time"
)
// doWork simulates a task that returns a done channel.
// The caller waits on this channel to know when the task finishes.
func doWork() chan struct{} {
done := make(chan struct{})
go func() {
// Simulate work that might take a while.
time.Sleep(2 * time.Second)
// Signal completion.
close(done)
}()
return done
}
func main() {
done := doWork()
// time.After creates a channel that sends a value after the duration.
// It's a one-shot timer.
timeout := time.After(1 * time.Second)
// select waits for any case to be ready.
// If multiple are ready, it picks one at random.
select {
case <-done:
// The work finished in time.
fmt.Println("Work completed successfully")
case <-timeout:
// The timer fired before the work finished.
fmt.Println("Work timed out")
}
}
The select statement blocks until one of the cases can proceed. If done closes, the first case runs. If timeout fires, the second case runs. If both happen at the same instant, the runtime picks one randomly. This randomness is a feature, not a bug. It prevents bias in concurrent code.
The time.After function returns a <-chan time.Time. It sends a single value when the duration elapses. You don't need to manage the timer manually. The garbage collector cleans it up after the send.
Context is plumbing. Run it through every long-lived call site. In a real application, you would pass a context.Context to doWork instead of using time.After directly. The context carries the deadline and cancellation signal. The worker checks the context and stops if cancelled. The done channel still signals completion, but the context controls the lifecycle.
Pitfalls and runtime panics
Done channels are simple, but they have sharp edges. The runtime will panic if you misuse them.
Closing twice
You can only close a channel once. Calling close on an already closed channel panics with close of closed channel. This happens when multiple goroutines try to signal completion, or when a cleanup function runs twice.
done := make(chan struct{})
close(done)
close(done) // panic: close of closed channel
If you need to protect against double-closing, wrap the close in a sync.Once. The Once ensures the function runs exactly once, even if called from multiple goroutines.
var once sync.Once
once.Do(func() {
close(done)
})
Sending on a closed channel
You cannot send to a closed channel. The compiler doesn't catch this because the channel might be closed at runtime by another goroutine. The runtime panics with send on closed channel.
done := make(chan struct{})
close(done)
done <- struct{}{} // panic: send on closed channel
This error usually means your logic is confused. A done channel should only be closed, never sent to. If you find yourself sending, you might be using the wrong pattern.
Deadlocks
If you read from a done channel but never close it, the receiver blocks forever. If the receiver is the only goroutine left, the program deadlocks. The runtime detects this and panics with all goroutines are asleep - deadlock!.
done := make(chan struct{})
// Forgot to close(done) in the goroutine.
<-done // panic: deadlock
Always ensure the closing path exists. If the worker panics, the channel never closes, and the waiter hangs. Use recover in the worker if you need to guarantee the close, or use a defer close(done) at the start of the goroutine.
go func() {
defer close(done) // Ensures close happens even if the function panics.
// Work here.
}()
The worst goroutine bug is the one that never logs. A hanging waiter produces no output. Add logging or timeouts to catch these issues early.
Decision matrix
Pick the right tool for the synchronization job. Go provides several primitives, and each has a specific role.
Use a done channel when a single event signals completion to one or more waiters. The channel closes once, and all receivers wake up. This is ideal for background tasks, cleanup routines, or shutdown signals.
Use a sync.WaitGroup when you need to wait for a dynamic number of goroutines to finish. You add to the counter before launching each goroutine, and each goroutine decrements the counter when done. The main function waits until the counter hits zero. This is better than done channels when the number of tasks isn't fixed.
Use a context.Context when you need to propagate cancellation or deadlines alongside the completion signal. The context carries the signal, and the worker checks it to stop early. The done channel still signals completion, but the context controls the lifecycle. Always pass the context as the first parameter.
Use a sync.Once when you must guarantee a cleanup or signal happens exactly once, even if called from multiple goroutines. This protects against double-closing channels or double-initializing resources.
Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing. Goroutines add complexity. Only use them when you have independent work that can run in parallel.