Now, let’s talk about one of the most genuinely useful patterns you’ll employ with select: using a done channel for clean and responsive cancellation. This is the pattern that saves you from the dreaded “my goroutine is stuck forever” scenario, and it’s so idiomatic it might as well be Go’s official way of saying “stop what you’re doing.”

The core idea is simple yet brilliant. You pass a <-chan struct{} (typically named doneCtx or ctx.Done()) into a goroutine. This channel will never receive any meaningful data; its sole purpose is to be closed when it’s time to shut things down. The goroutine then uses a select statement to simultaneously listen for its normal work and this cancellation signal. When the done channel is closed, the case for it is immediately selected (because receiving from a closed channel always returns the zero value), and the goroutine can bail out. It’s a fire alarm, not a mail slot.

Here’s the classic, bare-bones setup. You’ll see this pattern everywhere.

func doWork(done <-chan struct{}, input <-chan int) {
    for {
        select {
        case i, ok := <-input:
            if !ok {
                fmt.Println("Input channel closed, shutting down.")
                return
            }
            fmt.Printf("Working on: %d\n", i)
            // ... do some actual work with i ...
        case <-done:
            fmt.Println("Cancellation signal received, shutting down.")
            return
        }
        // You could put a short sleep here to simulate work if you want.
    }
}

func main() {
    done := make(chan struct{})
    inputCh := make(chan int, 3)

    go doWork(done, inputCh)

    inputCh <- 1
    inputCh <- 2
    inputCh <- 3

    // Time to cancel
    close(done)

    // Wait a bit to let the goroutine finish shutting down
    time.Sleep(100 * time.Millisecond)
}

Why a struct{} and not a bool?

You might wonder why we use chan struct{} instead of something like chan bool. It’s about efficiency. A struct{} occupies zero bytes of memory. Sending a true on a chan bool would work, but it’s semantically sloppy (you’re signaling an event, not passing data) and ever-so-slightly less efficient. The closed channel is the signal. This is a perfect example of Go’s pragmatism.

The Preemption Problem and Default Cases

Here’s a critical pitfall: a select statement will block forever if none of its channels become ready. If your input channel in the example above never receives another value and no one ever closes done, your goroutine leaks. This is where a default case becomes your best friend. It makes the select non-blocking, allowing you to perform other work or, more commonly, to check for cancellation in a tight loop.

func doWorkWithDefault(done <-chan struct{}) {
    for {
        // Do some non-channel based work here
        result := someCalculation()

        select {
        case <-done:
            fmt.Println("Cancelled! Aborting calculation.")
            return
        default:
            // Just continue on with the loop if no signal
        }

        fmt.Printf("Result is: %d\n", result)
        time.Sleep(500 * time.Millisecond) // Simulate more work
    }
}

Without that default case, the select would block until done was closed, halting your entire loop. The default lets you “check in” on the cancellation signal at the top of every loop iteration.

Context Is Just a Fancy Done Channel

When you use context.Context, you’re using this exact pattern with more features bolted on. ctx.Done() returns a <-chan struct{} that gets closed when the context is cancelled. The advice here is identical: always select from ctx.Done() in long-running or blocking operations.

func doWorkWithContext(ctx context.Context, input <-chan int) {
    for {
        select {
        case i := <-input:
            fmt.Printf("Working on: %d\n", i)
        case <-ctx.Done():
            fmt.Printf("Stopping due to: %v\n", ctx.Err())
            return
        }
    }
}

The beauty is that the caller can now use context.WithTimeout or context.WithCancel to control the lifecycle of your function without having to create and manage their own done channel. It’s the same pattern, just packaged more formally.

The One Major Gotcha: Resource Cleanup

The biggest mistake you can make with this pattern is ignoring the need for cleanup. When you bail out of a goroutine via the done channel, you’re often breaking out of a loop abruptly. You must ask yourself: “What resources did I acquire that need to be released?” Open files? Database connections? Network sockets? A mutex you’re holding?

Always defer your cleanup inside the goroutine. The cancellation signal shouldn’t mean “crash messily”; it should mean “stop your work gracefully and put your toys away.”

func doWorkSafely(done <-chan struct{}) {
    // Imagine this is an expensive resource we need to clean up
    resource, err := acquireResource()
    if err != nil {
        return
    }
    defer resource.Release() // This WILL run when we return, even via 'done'

    for {
        select {
        case <-done:
            fmt.Println("Cancelled, but will clean up thanks to defer!")
            return
        default:
            resource.DoWork()
        }
    }
}

This pattern is the bedrock of writing robust, responsive concurrent code in Go. It’s the difference between a program that gracefully shuts down and one that you have to kill -9 with extreme prejudice. Master it.