DEV Community

Cover image for select and default in Go: Non-Blocking Sends, Receives, and Polling
Gabriel Anhaia
Gabriel Anhaia

Posted on

select and default in Go: Non-Blocking Sends, Receives, and Polling


You add a default case to a select. The test that hung now
passes. You ship it. A week later someone opens the metrics
dashboard and one core is pinned at 100% on a service that should
be idle. The goroutine is spinning through a select millions of
times a second, hitting default on every pass, doing nothing but
burning the CPU.

That is the trap the default case invites. It is also one of the
most useful tools in Go's concurrency toolkit once you know where
it belongs. The keyword is small. The behavior it unlocks, and the
way it goes wrong, is worth understanding line by line.

What default actually does

A select without a default blocks. It parks the goroutine until
one of its cases can proceed. If no case is ever ready, the
goroutine sits there forever. That is usually what you want.

Add a default, and the semantics flip. Now select never blocks.
It checks every case once. If exactly one is ready, it runs. If
several are ready, it picks one at random. If none are ready, it
runs default and moves on immediately.

select {
case v := <-ch:
    use(v)
default:
    // ch had nothing right now
}
Enter fullscreen mode Exit fullscreen mode

That is the whole rule. default is the "nothing was ready" arm.
It converts a blocking channel operation into a poll: a question you
ask once instead of a wait you commit to.

The non-blocking receive

The most common use is checking a channel without committing to a
wait. You want to drain whatever is there and keep going if it is
empty.

func tryReceive(ch <-chan int) (int, bool) {
    select {
    case v := <-ch:
        return v, true
    default:
        return 0, false
    }
}
Enter fullscreen mode Exit fullscreen mode

If a value is waiting, you get it and true. If not, you get the
zero value and false, right now, with no parking. This is how you
poll a channel from a loop that has other work to do.

One place this shows up is draining a buffered channel before
shutdown:

func drain(ch <-chan Job) []Job {
    var out []Job
    for {
        select {
        case j := <-ch:
            out = append(out, j)
        default:
            return out
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The loop pulls jobs until the channel has nothing left, then the
default arm returns. No hang, no leftover work. Note this drains
whatever is buffered at the moment you look; a producer still
sending can race you, so only drain after you know the sender has
stopped.

The non-blocking send

The send side is the same idea and the one people forget exists. A
send on a full buffered channel blocks. A send on an unbuffered
channel blocks until a receiver is ready. Wrap it in a select
with default and the send becomes a try.

func trySend(ch chan<- Event, e Event) bool {
    select {
    case ch <- e:
        return true
    default:
        return false
    }
}
Enter fullscreen mode Exit fullscreen mode

If the channel has room, the event goes in and you get true. If
it is full, you get false and the event is dropped, without
blocking the caller.

This is the backbone of a load-shedding metrics or logging pipeline.
When the consumer falls behind, you would rather drop a data point
than block the hot path that produced it.

type Reporter struct {
    events  chan Event
    dropped uint64 // import "sync/atomic"
}

func (r *Reporter) Report(e Event) {
    select {
    case r.events <- e:
    default:
        // buffer full, drop and count it
        atomic.AddUint64(&r.dropped, 1)
    }
}
Enter fullscreen mode Exit fullscreen mode

The Report call never blocks the caller. When the buffer fills,
events are dropped and counted so you can alarm on the drop rate.
That is a deliberate trade: bounded latency in exchange for lossy
delivery under pressure. Make that choice on purpose, and record
the drops so the loss is visible.

The busy-loop trap

Here is the code that pins a core. It looks reasonable. It reads
like "wait for work, and if there is none, check again."

for {
    select {
    case job := <-jobs:
        handle(job)
    default:
        // nothing to do, loop again
    }
}
Enter fullscreen mode Exit fullscreen mode

There is no blocking anywhere in that loop. When jobs is empty,
default runs instantly and the for spins back to the top,
instantly, forever. The goroutine executes the select as fast as
the CPU allows. On an idle service that is a core stuck at 100% for
no work done.

The fix depends on what you actually wanted. Usually you wanted to
block until a job arrives, which means you did not want default
at all:

for {
    select {
    case job := <-jobs:
        handle(job)
    case <-ctx.Done():
        return
    }
}
Enter fullscreen mode Exit fullscreen mode

No default. The select parks the goroutine until a job comes in
or the context is cancelled. Zero CPU while idle. This is the right
shape for a worker that has nothing to do between jobs.

When you really do want to poll

Sometimes you genuinely need to poll: check a channel, do a little
background work, check again. The answer is not a bare default. It
is a default with a throttle, or a ticker case that paces the
loop.

If the goal is "handle jobs, but also do maintenance every second,"
add a timer case instead of default:

func run(ctx context.Context, jobs <-chan Job) {
    tick := time.NewTicker(time.Second)
    defer tick.Stop()
    for {
        select {
        case j := <-jobs:
            handle(j)
        case <-tick.C:
            maintenance()
        case <-ctx.Done():
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This blocks between events. When a job arrives, it runs. Every
second the ticker fires and maintenance runs. No spinning, because
every arm of the select is a blocking channel receive. The timer
provides the pacing that a bare default would have skipped.

If you truly need a default in a polling loop, put a sleep in it
so the loop yields the core between checks:

for {
    select {
    case v := <-ch:
        process(v)
    default:
        time.Sleep(10 * time.Millisecond)
    }
}
Enter fullscreen mode Exit fullscreen mode

That sleep is what stands between you and a pinned core. It is a
blunt instrument. The ticker version above is almost always better
because it reacts to work instantly instead of waking on a fixed
interval. Reach for the sleep only when some non-channel condition
forces you to poll.

Timeout is a case, not a default

People sometimes reach for default when they want a timeout. Those
are different. default fires when nothing is ready right now. A
timeout fires when nothing is ready within a window. Mixing them
up gives you a poll where you wanted a bounded wait.

A timeout is its own case, backed by time.After or a context
deadline:

select {
case v := <-ch:
    use(v)
case <-time.After(2 * time.Second):
    return errTimeout
}
Enter fullscreen mode Exit fullscreen mode

No default here. The select blocks for up to two seconds. If a
value arrives first, you take it. If the timer wins, you time out.
The moment you add a default to this, the two-second wait
collapses to zero and you have a poll again. If you keep this in a
tight loop, prefer a single time.NewTimer you reset, since
time.After allocates a fresh timer on every call.

The rule that keeps you out of trouble

A default in a select means one thing: "if nothing is ready this
instant, do not wait." That is exactly right for a try-send, a
try-receive, or draining a channel you know the producer has left.

It is exactly wrong for a loop whose only job is to wait for work. A
select with no default blocks and costs nothing while idle. The
moment you find yourself writing default: followed by a comment
like "nothing to do, loop again," stop. You almost certainly wanted
to block. Delete the default, add a ctx.Done() arm, and let the
scheduler park the goroutine the way it was built to.

Non-blocking channel operations are a language-level feature, not a
library trick, and Go's runtime scheduler is what makes the blocking
version free. The Complete Guide to Go Programming goes deep on how
select compiles, how the scheduler parks and wakes goroutines, and
why a blocking select costs nothing while a spinning one costs a
core. Hexagonal Architecture in Go is where you learn to keep these
try-send and drain patterns behind a port, so the load-shedding
decision lives at the boundary instead of leaking through your domain.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)