The non-blocking peek
You are running a background goroutine that processes tasks from a channel. The main program finishes its work and closes the channel to signal that no more data will arrive. Your background goroutine needs to notice the closure and exit gracefully. If you write a plain receive operation, the goroutine blocks forever when the channel is empty. You need a way to peek at the channel without getting stuck.
Go gives you a single tool for this: a select statement paired with a default case. It turns a blocking receive into an instant check. If the channel has data or is closed, the receive runs. If the channel is open but empty, the default case fires immediately.
How channel closure actually works
A channel in Go is a typed pipe. One goroutine sends values into it. Another goroutine receives them. When the sender finishes, it calls close(ch). This does not delete the channel. It flips an internal flag that tells receivers that no more values will ever arrive.
After a channel closes, any pending receives still get their values. Once the pipe drains, subsequent receives return the zero value for the channel's type. A chan int returns 0. A chan string returns an empty string. A chan struct{} returns an empty struct. The receive operation also returns a second boolean value that explicitly states whether the channel is open or closed.
You can capture that boolean directly with the two-value idiom. This is the standard way to drain a channel in a loop. It blocks until data arrives or the channel closes. It does not help when you need to check the state without waiting.
The struct{} type is a Go convention for signaling channels. It takes zero bytes of memory. The compiler knows this and optimizes away any allocation. You will see it everywhere in production code for done channels and shutdown signals. Trust the convention. It exists because it is the most efficient way to pass a signal without carrying payload data.
The minimal non-blocking check
Here is the simplest way to check closure without blocking:
// IsChannelClosed returns true if the channel is closed, false otherwise.
func IsChannelClosed(ch <-chan struct{}) bool {
select {
case <-ch: // receives immediately if closed or if data is buffered
return true
default: // runs instantly if the channel is open and empty
return false
}
}
The function takes a receive-only channel of struct{}. The select statement evaluates all cases simultaneously. If the channel is closed, the receive case is ready. The runtime executes it and returns true. If the channel is open but empty, no case is ready. The default case runs immediately and returns false.
This pattern is fast. It does not allocate memory. It does not spawn goroutines. It just asks the runtime for the current state of the channel. The receive-only type annotation <-chan is a safety feature. It prevents the function from accidentally sending on the channel. The compiler rejects any send attempt with invalid operation: cannot send to receive-only type <-chan struct{}. This keeps your API surface clean and predictable.
What the runtime does under the hood
When the compiler sees a select statement, it generates code that checks the readiness of each case. For a channel receive, readiness means two things. The channel is open and has buffered data, or the channel is closed. The runtime maintains a queue of blocked goroutines for each channel. A non-blocking select never adds your goroutine to that queue. It inspects the channel's state, makes a decision, and moves on.
The default case is the escape hatch. Without it, select blocks until at least one case is ready. With it, select becomes a poll. If nothing is ready, default executes. This turns a synchronous operation into a synchronous check.
You will see this pattern in test helpers and graceful shutdown routines. It is also the foundation for building non-blocking message routers. The tradeoff is that polling introduces a window of time between the check and the actual event. If a channel closes between your check and your next action, you might miss it. That is why you usually pair this check with a small sleep or a time.Ticker in a loop.
Buffered channels add another layer of complexity. A buffered channel of size three can hold three values before blocking. If you close a buffered channel, the select with default will return true immediately, even if values are still sitting in the buffer. The closure flag takes precedence over the buffer contents. If you need to drain the buffer first, you must use a blocking receive or a for range loop. The non-blocking check only tells you about the pipe state, not the remaining payload.
A realistic shutdown watcher
Background workers often need to monitor a shutdown signal while doing other work. Here is how a cleanup goroutine uses the non-blocking check to avoid blocking its own logic:
// CleanupWorker runs periodic maintenance until the done channel closes.
func CleanupWorker(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop() // stops the ticker to prevent goroutine leaks
for {
select {
case <-ctx.Done(): // exits immediately when the context cancels
return
case <-ticker.C: // runs maintenance on schedule
performCleanup()
}
}
}
This example follows the convention of passing context.Context as the first parameter, named ctx. The context carries the cancellation signal. The select statement handles both the shutdown signal and the timer without blocking. If you only have one channel to watch and you need to do other work in the loop, you swap the timer for a default case:
// PollingWorker checks for shutdown while processing local tasks.
func PollingWorker(done <-chan struct{}) {
for {
select {
case <-done: // breaks the loop if the channel closes
return
default: // continues immediately if the channel is still open
processLocalTask()
time.Sleep(10 * time.Millisecond) // prevents CPU spinning
}
}
}
The time.Sleep call is necessary here. Without it, the loop runs as fast as the CPU allows, burning a full core and generating heat. The sleep yields the thread to the scheduler. It turns a busy-wait into a cooperative poll. You will often see developers discard unused return values with the underscore _ when they call helper functions inside these loops. result, _ := doWork() tells the reader you considered the second return value and chose to drop it. Use it sparingly with errors. Swallowing errors silently breaks observability.
Pitfalls and race conditions
The select with default pattern is not a magic bullet. It has a narrow window of vulnerability. The check is instantaneous. It tells you the state of the channel at that exact nanosecond. If another goroutine sends a value or closes the channel right after your check returns false, your program proceeds with stale information.
This race condition matters when you use the check to decide whether to send. If you check a channel, see it is open, and then try to send, you might panic if the channel closed in between. Sending on a closed channel always panics. The runtime stops the program with panic: send on closed channel. The compiler cannot catch this because channel state is dynamic.
If you accidentally try to receive from a send-only channel, the compiler rejects the code immediately. You will see invalid operation: cannot receive from send-only type <-chan int. The type system enforces directionality at compile time. You must match the channel direction to your operation.
Another common mistake is using this pattern to replace sync.WaitGroup. A WaitGroup tracks how many goroutines are still running. A closed channel only signals that one specific pipe is done. They solve different problems. Use WaitGroup when you need to wait for multiple independent tasks to finish. Use a closed channel when you need to broadcast a signal to many listeners.
Goroutine leaks happen when a worker waits on a channel that never closes. Always provide a cancellation path. If you spawn a goroutine that reads from a channel, make sure something will close that channel or cancel the context attached to it. The worst goroutine bug is the one that silently consumes memory until the process crashes.
Receiver naming follows a strict community convention. The receiver name is usually one or two letters matching the type. (w *Worker) Run() is correct. (this *Worker) or (self *Worker) breaks the style guide. gofmt enforces indentation and spacing automatically. Most editors run it on save. Do not argue about formatting. Let the tool decide. It keeps the codebase consistent across teams.
When to use which pattern
Use a select with default when you need to poll a single channel without blocking the current goroutine. Use a for range loop when you want to drain a channel completely and block until it closes. Use a sync.WaitGroup when you need to wait for multiple goroutines to finish their work. Use a context.Context when you need to propagate cancellation deadlines across function boundaries. Use a plain blocking receive when you are willing to wait for the next value or closure.
Channels are communication primitives, not boolean flags. Treat them as pipes, not switches. The select statement is the only safe way to peek at a pipe without getting stuck. Pair it with a sleep or a ticker to avoid burning CPU cycles. Trust the runtime to handle the scheduling.