skip to content

How do you write a non-blocking send and a non-blocking receive on a Go channel?

level: middleimportance: should knowfreq 56%

answer

  1. no TrySend method exists in Go
  2. one real case plus one escape hatch
  3. select is the check and the act at once
  4. reaching default means it did not happen
  5. an empty default on a send loses the value

basics

~20 s

Wrap the operation in a select with a default case: select { case ch <- v: default: } is a try-send, and select { case v := <-ch: default: } is a try-receive. Reaching default means the operation did not happen.

solid answer

~50 s

There is no `trySend` function in Go; the idiom is a one-case `select` plus a `default`. `select { case ch <- v: default: }` attempts the send: it succeeds only if a receiver is already waiting or the channel has room, and otherwise falls to `default` with the value **not** sent. `select { case v, ok := <-ch: ... default: }` is the mirror image: you get a value only if one is available right now. The critical discipline is what you do in the `default` branch. Reaching it means the work was silently dropped or the read produced nothing, so the branch must do something honest — count the drop, log it, buffer it elsewhere, or retry — rather than being left empty. An empty `default` on a try-send is a data-loss bug that no test will notice.

code

go · 17 lines
go
// try-send: never blocks, but may drop v
select {
case metrics <- sample:
default:
	dropped++ // v was NOT sent; make the loss visible
}

// try-receive: takes an item only if one is already there
select {
case v, ok := <-work:
	if !ok {
		return // channel closed
	}
	handle(v)
default:
	// open but empty right now
}

go deeper

for a junior

Memorise the two shapes and what reaching default means: the send did not happen, or no value was taken. Nothing is retried for you.

for a middle

Be ready to explain why the select is atomic while a len-then-send check is a race, and when a try-send on an unbuffered channel can succeed at all.

for a senior

Demonstrate the discipline around the default branch. Every drop path needs a counter or a spill target, because silent loss under load is the failure mode this idiom creates.

for a principal

Own the guideline for the codebase: where non-blocking sends are permitted, what every drop must be attributed to in metrics, and which paths must block instead so loss is never invisible.

## The idiom Go has no `TrySend`/`TryReceive` method on channels. Both are expressed with a `select` whose only real case is the operation you want and whose `default` covers "it could not happen now". ```go // try-send select { case ch <- v: // v was handed to a waiting receiver, or copied into free buffer space default: // nobody was ready and there was no room: v was NOT sent } // try-receive select { case v, ok := <-ch: // got v; ok is false only if ch is closed default: // nothing was available: no value was taken } ``` Because `default` is reached only when the communication cannot proceed at that instant, the operation is genuinely all-or-nothing. There is no half-send and nothing is queued for later. ## When does the send case succeed? For an **unbuffered** channel, only if a receiver is already parked waiting on it. If no goroutine is sitting in a receive, a try-send always takes `default` — which surprises people who expect "send it, someone will pick it up". For a **buffered** channel, the send also succeeds when the buffer is not full. For the receive case, the mirror rules apply: a value is available if a sender is parked, if the buffer holds something, or if the channel is closed. ## The branch that matters is `default` A try-send whose `default` is empty throws the value away. This is the single most common defect built on this idiom, and it is invisible: nothing panics, nothing logs, throughput just quietly does not match input. Whatever the reason for using a non-blocking send, the `default` branch owes you one of: - **an increment of a dropped-items counter**, so the loss is measurable; - **a log line**, at a rate that does not itself become the problem; - **an alternative destination** — spill to disk, a slower path, a retry queue; - **an explicit, commented decision that dropping is correct here**. The same applies less dangerously to try-receive: an empty `default` there just means "no work this pass", which is usually fine, but only if the surrounding code actually does something else on that pass. ## Why not check first and then send? A tempting alternative is `if len(ch) < cap(ch) { ch <- v }`. This is wrong. Between the `len` call and the send another goroutine can fill the channel, and your send blocks after all — exactly the outcome you were trying to avoid. `len` and `cap` on a channel are a snapshot with no guarantee attached. The select form is atomic: the runtime decides readiness and performs the operation under the channel's lock, so there is no window between the check and the act. Any "check then act" pattern on a channel is a race; the `select` is the check *and* the act. ## The comma-ok form is a different thing `v, ok := <-ch` is often confused with a try-receive. It is not one: it **blocks**. `ok` does not report "was anything available"; it reports "was this value produced by a real send", so `ok` is false only when the channel is closed and drained. Inside a try-receive you can combine them — `case v, ok := <-ch:` — and then `ok == false` means the channel is closed, while landing in `default` means the channel is open but empty. Two distinct outcomes that are easy to conflate. ## When the idiom is right The non-blocking form earns its place where the goroutine has something better to do than wait: a loop that is already doing real work each pass and wants to pick up any pending item cheaply; a metrics or debug channel where losing a sample is preferable to slowing the hot path; a probe of a signal channel in the middle of a long computation. What it must not become is the body of a loop whose only purpose is to wait for the channel — that turns a parked, free goroutine into one that saturates a CPU core.

  • Why is `if len(ch) < cap(ch) { ch <- v }` not a safe non-blocking send?
    It is check-then-act. Another goroutine can fill the channel between the `len` call and the send, so the send blocks anyway. `len` and `cap` on a channel are only a snapshot. The select form performs the readiness test and the send atomically under the channel's lock, so no such window exists.
  • On an unbuffered channel, when does a try-send actually succeed?
    Only when a receiver is already parked in a receive on that channel, so the runtime can hand the value straight across. If nobody is currently receiving, the try-send takes `default` every time, even though a receiver might arrive a microsecond later.
  • Inside a try-receive, how do you tell "channel closed" from "nothing available"?
    Use `case v, ok := <-ch:`. Landing in that case with `ok == false` means the channel is closed and drained. Landing in `default` means the channel is still open but had nothing ready. They are different outcomes and usually deserve different handling — one is terminal, the other is transient.
  • What should the default branch of a try-send contain?
    Something that makes the drop accountable: a counter, a rate-limited log, a spill to an alternative path, or an explicit comment stating that discarding is correct here. An empty default is silent data loss that no test and no error return will surface.

saying these in an interview costs you the question

  • Looks for a built-in TrySend or TryReceive method
  • Leaves the default branch of a try-send empty
  • Checks len(ch) against cap(ch) before sending
  • Thinks a try-send queues the value for later delivery
  • Confuses the comma-ok receive with a non-blocking receive