skip to content

Why must the channel passed to signal.Notify be buffered in Go?

level: middleimportance: should knowfreq 52%

answer

  1. the package must never wedge on you
  2. think about how a non-blocking send behaves
  3. what happens when nobody is at the receive
  4. select with a default drops the value
  5. one slot, and go vet checks it

basics

~20 s

Package signal never blocks: it does a non-blocking send. If no goroutine is receiving and the buffer is full, the signal is silently discarded. A buffer of one holds a shutdown signal until someone reads it.

solid answer

~40 s

The `os/signal` package delivers signals from its own internal goroutine, and it deliberately refuses to block on your channel -- a signal handler that could wedge on a slow consumer would be a bug in the runtime. So the send is non-blocking: if the channel has no free buffer slot and no receiver is parked at that instant, the delivery is dropped and nothing tells you. With `make(chan os.Signal, 1)`, a signal that arrives while your goroutine is busy is parked in the buffer and read on the next receive. One slot is enough when you only need to know that a shutdown signal happened; give it more slots only if you genuinely count or distinguish repeated deliveries. `go vet` reports unbuffered channels passed to `signal.Notify`, so this usually gets caught before review.

code

go · 7 lines
go
// wrong: go vet reports an unbuffered channel here
bad := make(chan os.Signal)
signal.Notify(bad, os.Interrupt)

// right: one slot holds a signal until someone receives
good := make(chan os.Signal, 1)
signal.Notify(good, os.Interrupt)

go deeper

for a junior

Remember the literal rule: make(chan os.Signal, 1), never a zero-capacity channel. Know that the standard library will not wait for you to be ready.

for a middle

Explain the mechanism -- a non-blocking send with a default branch -- and describe the symptom it produces: a Ctrl-C that works only sometimes, with no error anywhere.

for a senior

Be ready to size the buffer from the program's behaviour rather than by rote, and to point at go vet as the cheap way to keep the mistake out of the codebase.

for a principal

Treat silent-drop APIs as a class: where the standard library refuses to block on you, the sizing decision has been delegated to your team, and that decision belongs in review guidance and CI, not in individual memory.

## The rule `signal.Notify`'s documentation states it plainly: package signal will not block sending to the channel, so the caller must ensure the channel has sufficient buffer space for the signals it expects. In practice this means `make(chan os.Signal, 1)` at minimum, and an unbuffered `make(chan os.Signal)` is a latent bug. ## Why the package refuses to block Inside `os/signal` there is a goroutine that loops receiving signals from the runtime and forwarding them to every channel registered for that signal. If that forwarding could block, one careless consumer -- a goroutine that stopped reading, or one that is busy for a second -- would stall the delivery of signals to every other registered channel, and would stall the package's own bookkeeping. So the send is written as a `select` with a `default`, which is Go's standard non-blocking send: ``` select { case c <- sig: default: } ``` The consequence is unavoidable and is the whole point of the question: when the `default` branch is taken, the signal is gone. It is not queued, not retried, and no error surfaces anywhere. The program simply behaves as though the user never pressed anything. ## What the failure actually looks like Picture an interactive program -- a REPL that reads a line, runs a command that takes a couple of hundred milliseconds, then loops. The signal channel is unbuffered and is read at the top of the loop. Press Ctrl-C while a command is running and there is no goroutine parked on the receive at that instant, so the delivery is discarded. The user presses Ctrl-C again, catches the loop between commands, and this time it works. The reported bug is "Ctrl-C only works sometimes", which is a miserable thing to debug precisely because the failure leaves no trace: no log line, no error, no dropped-signal counter. Worse, `signal.Notify` has already disabled the default termination behaviour, so the dropped Ctrl-C does not fall through to killing the process either. The program is silently more stubborn than it was before you added signal handling. A buffer of one fixes exactly this: the signal that arrives mid-command sits in the buffer, and the very next receive picks it up. ## How big should the buffer be Size it by what you do with the values, not by superstition. - **One slot** is right for the overwhelmingly common case: you want to know that a stop was requested. A second SIGINT arriving before you read the first adds no information -- you are already stopping. - **More slots** are justified when the *number* or the *identity* of deliveries drives behaviour. A program watching SIGHUP for config reload and SIGINT/SIGTERM for stop may want room for a couple, so a reload request is not lost while a stop is buffered. - **Never zero.** There is no scenario in which an unbuffered channel is more correct here; it can only lose deliveries. Note that repeated deliveries of the same signal are *not* coalesced into your buffer by the package doing anything clever -- if the slot is full, the extra copies are simply dropped, which for identical stop signals is the behaviour you wanted anyway. ## The tooling catches it `go vet` includes a check for exactly this misuse: passing an unbuffered channel to `signal.Notify`. Running `go vet ./...` in CI catches the mistake at the point it is introduced, which is far cheaper than discovering it from an intermittent user report. It is one of the more clearly worthwhile vet checks precisely because the runtime failure is silent. ## The related mistake that a buffer does not fix Buffering guarantees the signal is *stored*; it guarantees nothing about anyone *reading* it. If the goroutine that was supposed to receive has returned, or is blocked on something else forever, then a one-slot buffer fills with the first signal and every subsequent one is dropped -- and the default termination is still disabled. So the buffer is necessary but not sufficient: you also need a receiver that is alive for as long as the registration is. ## Saying it in an interview The compact answer is: delivery is a non-blocking send, so an unregistered receiver at that instant means the signal is discarded with no diagnostic; a buffer of one holds it until the program gets around to reading; and `go vet` flags the unbuffered case. If you can also name the symptom -- "Ctrl-C works intermittently" -- you have shown you have actually debugged it rather than memorised the doc comment.

  • How large should the buffer be if the program also watches SIGHUP for a config reload?
    Give it a couple of slots rather than one. With a single slot, a buffered stop signal can crowd out a reload request that arrives before anyone reads. Size the buffer by how many distinct deliveries actually change behaviour; identical repeated stop signals need no extra room.
  • Does buffering the channel guarantee the signal is handled?
    No. It guarantees the signal is stored, not that anyone reads it. If the receiving goroutine has exited or is blocked forever, the single slot fills and everything after is dropped -- while signal.Notify has already disabled default termination, so the process becomes uninterruptible.
  • Why doesn't package signal just queue every signal internally instead?
    Unbounded internal queueing would turn a stuck consumer into unbounded memory growth, and blocking delivery would let one slow consumer stall delivery to every other registered channel. A non-blocking send with a caller-sized buffer puts the sizing decision where the knowledge is.

saying these in an interview costs you the question

  • Says the send blocks until the program is ready to receive
  • Thinks a dropped signal falls through to the default exit
  • Claims the runtime retries or queues undelivered signals
  • Sizes the buffer at zero because 'one goroutine is always waiting'
  • Assumes a buffer alone makes signal handling reliable