How Go Handles Signals and Context Cancellation Without Losing Signals
Go installs its own handlers for every signal it cares about, funnels them through a lock-free bitmask queue into a single goroutine that forwards them to your channels, and builds context cancellation as a parent-child tree that cancels children before the parent finishes.
TL;DR: Go installs its own handler for every signal it cares about, and because a signal handler cannot block, allocate or take a lock, it does almost nothing: it sets a bit in a global bitmask and wakes one dedicated goroutine. That goroutine turns bits back into signals and pushes them onto your channels. Context cancellation is a different mechanism entirely — a tree of
cancelCtxnodes where cancelling a parent cancels its children.
Two mechanisms in the Go runtime look related and are not. Signal handling is
about getting a signal from the kernel into a Go channel without losing it.
Context cancellation is about propagating a “stop” through a tree of
operations. They meet in exactly one place: signal.NotifyContext, which
wires a signal to a context.
Let’s look at both, in the Go 1.28 development tree.
The table of signals
The runtime has a table, one entry per signal number, describing what to do with each one:
// src/runtime/signal_unix.go:17
// sigTabT is the type of an entry in the global sigtable array.
// sigtable is inherently system dependent, and appears in OS-specific files,
// but sigTabT is the same for all Unixy systems.
// The sigtable array is indexed by a system signal number to get the flags
// and printable name of each signal.
type sigTabT struct {
flags int32
name string
}and on Linux:
// src/runtime/sigtab_linux_generic.go:9
var sigtable = [...]sigTabT{
/* 0 */ {0, "SIGNONE: no trap"},
/* 1 */ {_SigNotify + _SigKill, "SIGHUP: terminal line hangup"},
/* 2 */ {_SigNotify + _SigKill, "SIGINT: interrupt"},
/* 3 */ {_SigNotify + _SigThrow, "SIGQUIT: quit"},
/* 4 */ {_SigThrow + _SigUnblock, "SIGILL: illegal instruction"},
/* 5 */ {_SigThrow + _SigUnblock, "SIGTRAP: trace trap"},
/* 6 */ {_SigNotify + _SigThrow, "SIGABRT: abort"},
/* 7 */ {_SigPanic + _SigUnblock, "SIGBUS: bus error"},
/* 8 */ {_SigPanic + _SigUnblock, "SIGFPE: floating-point exception"},
/* 9 */ {0, "SIGKILL: kill"},
/* 10 */ {_SigNotify, "SIGUSR1: user-defined signal 1"},
/* 11 */ {_SigPanic + _SigUnblock, "SIGSEGV: segmentation violation"},
/* 12 */ {_SigNotify, "SIGUSR2: user-defined signal 2"},
/* 13 */ {_SigNotify, "SIGPIPE: write to broken pipe"},
/* 14 */ {_SigNotify, "SIGALRM: alarm clock"},
/* 15 */ {_SigNotify + _SigKill, "SIGTERM: termination"},
...
/* 27 */ {_SigNotify + _SigUnblock, "SIGPROF: profiling alarm clock"},
...
}The flags are the policy. _SigNotify means “this signal can be caught by
os/signal”. _SigThrow means “turn it into a Go panic”. _SigPanic means
“panic if it happens in Go code, but die if it happens in C code”. _SigKill
means “the default action is to kill the process”. _SigUnblock means “never
block this signal in the mask” — those are the synchronous signals (SIGSEGV,
SIGBUS, SIGFPE, SIGILL) that must be delivered immediately so they can become
panics.
The flags also determine which signals are allowed to be blocked by the signal mask:
// src/runtime/signal_unix.go:1394
// blockableSig reports whether sig may be blocked by the signal mask.
// We never want to block the signals marked _SigUnblock;
// these are the synchronous signals that turn into a Go panic.
// We never want to block the preemption signal if it is being used.
// In a Go program--not a c-archive/c-shared--we never want to block
// the signals marked _SigKill or _SigThrow, as otherwise it's possible
// for all running threads to block them and delay their delivery until
// we start a new thread. When linked into a C program we let the C code
// decide on the disposition of those signals.
func blockableSig(sig uint32) bool {
flags := sigtable[sig].flags
if flags&_SigUnblock != 0 {
return false
}
if sig == sigPreempt && preemptMSupported && debug.asyncpreemptoff == 0 {
return false
}
if isarchive || islibrary {
return true
}
return flags&(_SigKill|_SigThrow) == 0
}Installing the handler
At startup, the runtime walks the table and installs its own handler for every signal it wants:
// src/runtime/signal_unix.go:110
// Initialize signals.
// Called by libpreinit so runtime may not be initialized.
//
//go:nosplit
//go:nowritebarrierrec
func initsig(preinit bool) {
if !preinit {
// It's now OK for signal handlers to run.
signalsOK = true
}
// For c-archive/c-shared this is called by libpreinit with
// preinit == true.
if (isarchive || islibrary) && !preinit {
return
}
for i := uint32(0); i < _NSIG; i++ {
t := &sigtable[i]
if t.flags == 0 || t.flags&_SigDefault != 0 {
continue
}
// We don't need to use atomic operations here because
// there shouldn't be any other goroutines running yet.
fwdSig[i] = getsig(i)
if !sigInstallGoHandler(i) {
// Even if we are not installing a signal handler,
// set SA_ONSTACK if necessary.
if fwdSig[i] != _SIG_DFL && fwdSig[i] != _SIG_IGN {
setsigstack(i)
} else if fwdSig[i] == _SIG_IGN {
sigInitIgnored(i)
}
continue
}
handlingSig[i] = 1
setsig(i, abi.FuncPCABIInternal(sighandler))
}
}The handler that gets installed is sighandler, and the sigaction it uses is
configured with SA_ONSTACK (run on the alternate signal stack) and with
every signal blocked during the handler:
// src/runtime/os_linux.go:478
func setsig(i uint32, fn uintptr) {
var sa sigactiont
sa.sa_flags = _SA_SIGINFO | _SA_ONSTACK | _SA_RESTORER | _SA_RESTART
sigfillset(&sa.sa_mask)
...
sa.sa_handler = fn
sigaction(i, &sa, nil)
}fwdSig[i] remembers whatever handler was installed before Go started, so that
Go can forward the signal to the original handler when Go does not want it —
for instance when the signal arrives on a thread that is not running Go code.
Where a signal lands
Here is the rule that surprises people: the kernel delivers a process-directed signal to an arbitrary thread that does not have that signal blocked. Not “the main thread”. Not “the thread that sent it”. Any thread whose signal mask allows it.
Because signal masks are per-thread, “does this thread accept the signal” is a per-thread question, and the answer changes over time as threads come and go. Go’s answer to “what if no thread will take it” is to keep one dedicated thread whose mask it manages precisely:
// src/runtime/signal_unix.go:1076
// ensureSigM starts one global, sleeping thread to make sure at least one thread
// is available to catch signals enabled for os/signal.
func ensureSigM() {
if maskUpdatedChan != nil {
return
}
maskUpdatedChan = make(chan struct{})
disableSigChan = make(chan uint32)
enableSigChan = make(chan uint32)
go func() {
// Signal masks are per-thread, so make sure this goroutine stays on one
// thread.
LockOSThread()
defer UnlockOSThread()
// The sigBlocked mask contains the signals not active for os/signal,
// initially all signals except the essential. When signal.Notify()/Stop is called,
// sigenable/sigdisable in turn notify this thread to update its signal
// mask accordingly.
sigBlocked := sigset_all
for i := range sigtable {
if !blockableSig(uint32(i)) {
sigdelset(&sigBlocked, i)
}
}
sigprocmask(_SIG_SETMASK, &sigBlocked, nil)
for {
select {
case sig := <-enableSigChan:
if sig > 0 {
sigdelset(&sigBlocked, int(sig))
}
case sig := <-disableSigChan:
if sig > 0 && blockableSig(sig) {
sigaddset(&sigBlocked, int(sig))
}
}
sigprocmask(_SIG_SETMASK, &sigBlocked, nil)
maskUpdatedChan <- struct{}{}
}
}()
}The dedicated thread starts with everything blockable blocked. When you call
signal.Notify(c, os.Interrupt), the runtime removes SIGINT from that mask,
making it deliverable on that thread, and then waits for the update to be
applied:
// src/runtime/signal_unix.go:187
// sigenable enables the Go signal handler to catch the signal sig.
// It is only called while holding the os/signal.handlers lock,
// via os/signal.enableSignal and signal_enable.
func sigenable(sig uint32) {
if sig >= uint32(len(sigtable)) {
return
}
// SIGPROF is handled specially for profiling.
if sig == _SIGPROF {
return
}
t := &sigtable[sig]
if t.flags&_SigNotify != 0 {
ensureSigM()
enableSigChan <- sig
<-maskUpdatedChan
if atomic.Cas(&handlingSig[sig], 0, 1) {
atomic.Storeuintptr(&fwdSig[sig], getsig(sig))
setsig(sig, abi.FuncPCABIInternal(sighandler))
}
}
}enableSigChan <- sig followed by <-maskUpdatedChan is a handshake: by the
time Notify returns, the mask has actually been changed. That is what makes
“raise a signal immediately after Notify” safe — the signal cannot be raised
into a void, because the thread that can receive it has already been told.
sigsend: the handler’s entire job
The handler cannot block, cannot allocate and cannot take a mutex, so it does the minimum: it sets a bit.
// src/runtime/sigqueue.go:68
// sigsend delivers a signal from sighandler to the internal signal delivery queue.
// It reports whether the signal was sent. If not, the caller typically crashes the program.
// It runs from the signal handler, so it's limited in what it can do.
func sigsend(s uint32) bool {
bit := uint32(1) << uint(s&31)
if s >= uint32(32*len(sig.wanted)) {
return false
}
sig.delivering.Add(1)
// We are running in the signal handler; defer is not available.
if w := atomic.Load(&sig.wanted[s/32]); w&bit == 0 {
sig.delivering.Add(-1)
return false
}
// Add signal to outgoing queue.
for {
mask := sig.mask[s/32]
if mask&bit != 0 {
sig.delivering.Add(-1)
return true // signal already in queue
}
if atomic.Cas(&sig.mask[s/32], mask, mask|bit) {
break
}
}
// Notify receiver that queue has new bit.
Send:
for {
switch sig.state.Load() {
default:
throw("sigsend: inconsistent state")
case sigIdle:
if sig.state.CompareAndSwap(sigIdle, sigSending) {
break Send
}
case sigSending:
// notification already pending
break Send
case sigReceiving:
if sig.state.CompareAndSwap(sigReceiving, sigIdle) {
if GOOS == "darwin" || GOOS == "ios" {
sigNoteWakeup(&sig.note)
break Send
}
notewakeup(&sig.note)
break Send
}
}
}
sig.delivering.Add(-1)
return true
}Read that carefully; several things are going on.
The bitmask. sig.mask is a [(_NSIG + 31) / 32]uint32 — one bit per
signal number. Setting a bit is atomic and cannot fail, cannot block and cannot
allocate. That is the entire “queue”.
sig.wanted is the gate. If nobody called Notify for this signal, the bit
is not set in wanted and sigsend returns false, which means “Go does not
want this signal” and the runtime takes the default action — usually dying.
Signals are coalesced. if mask&bit != 0 { return true } — if the signal is
already pending, the second one is dropped. This is standard POSIX behaviour
(non-realtime signals are not queued) and it is why os/signal cannot be used
as a counter.
The wakeup. Here is the “self-pipe”-style part. The handler has just
written a bit into shared memory; the goroutine that will read it is asleep on a
note. Waking it up requires a system call, and the only system call that is
async-signal-safe enough to make here is the one in the Darwin branch:
// src/runtime/os_darwin.go:96
// because the functions pthread_mutex_lock, pthread_cond_signal, and
// pthread_mutex_unlock, called by semawakeup, are not async-signal-safe.
// There is only one case where we need to wake up a note from a signal
// handler: the sigsend function. The signal handler code does not require
// all the features of notes: it does not need to do a timed wait.
// This is a separate implementation of notes, based on a pipe, that does
// not support timed waits but is async-signal-safe.
func sigNoteSetup(*note) {
if sigNoteRead != 0 || sigNoteWrite != 0 {
// Generalizing this would require avoiding the pipe-fork-closeonexec race, which entangles syscall.
throw("duplicate sigNoteSetup")
}
var errno int32
sigNoteRead, sigNoteWrite, errno = pipe()
if errno != 0 {
throw("pipe failed")
}
closeonexec(sigNoteRead)
closeonexec(sigNoteWrite)
// Make the write end of the pipe non-blocking, so that if the pipe
// buffer is somehow full we will not block in the signal handler.
// Leave the read end of the pipe blocking so that we will block
// in sigNoteSleep.
setNonblock(sigNoteWrite)
}
// sigNoteWakeup wakes up a thread sleeping on a note created by sigNoteSetup.
func sigNoteWakeup(*note) {
var b byte
write(uintptr(sigNoteWrite), unsafe.Pointer(&b), 1)
}That is a textbook self-pipe: a byte written to a pipe in the handler, read by
the sleeping consumer. On Linux the same job is done by notewakeup, which
uses a futex and is async-signal-safe, so the pipe is only needed on Darwin
and iOS, where pthread_cond_signal is not.
The three-state protocol. sig.state is sigIdle, sigSending or
sigReceiving, and the transitions are compare-and-swaps. The comment at the
top of the file states the invariant:
// src/runtime/sigqueue.go:15
// Synchronization between sigsend and signal_recv is based on the sig.state
// variable. It can be in three states:
// * sigReceiving means that signal_recv is blocked on sig.Note and there are
// no new pending signals.
// * sigSending means that sig.mask *may* contain new pending signals,
// signal_recv can't be blocked in this state.
// * sigIdle means that there are no new pending signals and signal_recv is not
// blocked.
//
// Transitions between states are done atomically with CAS.
//
// When signal_recv is unblocked, it resets sig.Note and rechecks sig.mask.
// If several sigsends and signal_recv execute concurrently, it can lead to
// unnecessary rechecks of sig.mask, but it cannot lead to missed signals
// nor deadlocks.That last sentence is the guarantee the whole design exists to provide: it cannot lead to missed signals.
The consumer side
One goroutine drains the bitmask and turns it back into signals:
// src/runtime/sigqueue.go:126
// Called to receive the next queued signal.
// Must only be called from a single goroutine at a time.
//
//go:linkname signal_recv os/signal.signal_recv
func signal_recv() uint32 {
for {
// Serve any signals from local copy.
for i := uint32(0); i < _NSIG; i++ {
if sig.recv[i/32]&(1<<(i&31)) != 0 {
sig.recv[i/32] &^= 1 << (i & 31)
return i
}
}
// Wait for updates to be available from signal sender.
Receive:
for {
switch sig.state.Load() {
default:
throw("signal_recv: inconsistent state")
case sigIdle:
if sig.state.CompareAndSwap(sigIdle, sigReceiving) {
if GOOS == "darwin" || GOOS == "ios" {
sigNoteSleep(&sig.note)
break Receive
}
notetsleepg(&sig.note, -1)
noteclear(&sig.note)
break Receive
}
case sigSending:
if sig.state.CompareAndSwap(sigSending, sigIdle) {
break Receive
}
}
}
// Incorporate updates from sender into local copy.
for i := range sig.mask {
sig.recv[i] = atomic.Xchg(&sig.mask[i], 0)
}
}
}The atomic.Xchg at the end is the “take the whole mask at once” step: the
consumer swaps the shared mask for its private copy in one atomic operation per
word, so no bit can be lost between the swap and the scan.
And os/signal drives it with the world’s simplest loop:
// src/os/signal/signal_unix.go:21
func loop() {
for {
process(syscall.Signal(signal_recv()))
}
}process fans the signal out to every registered channel, without blocking:
// src/os/signal/signal.go:245
func process(sig os.Signal) {
n := signum(sig)
if n < 0 {
return
}
handlers.Lock()
defer handlers.Unlock()
for c, h := range handlers.m {
if h.want(n) {
// send but do not block for it
select {
case c <- sig:
default:
}
}
}
// Avoid the race mentioned in Stop.
for _, d := range handlers.stopping {
if d.h.want(n) {
select {
case d.c <- sig:
default:
}
}
}
}Note the default: in the select: the send is non-blocking. If your channel is
full, the signal is dropped. That is why the Notify documentation says the
caller must provide enough buffer space:
// src/os/signal/signal.go:110
// Notify causes package signal to relay incoming signals to c.
// If no signals are provided, all incoming signals will be relayed to c.
// Otherwise, just the provided signals will.
//
// Package signal will not block sending to c: the caller must ensure
// that c has sufficient buffer space to keep up with the expected
// signal rate. For a channel used for notification of just one signal value,
// a buffer of size 1 is sufficient.The Stop race
signal.Stop(c) has a genuine race to handle: a signal might be in the
handler, in the queue, or on its way to your channel, at the moment you call
Stop. If the signal then arrives at your channel after Stop returned, the
contract “When Stop returns, it is guaranteed that c will receive no more
signals” would be violated — and worse, for SIGINT the program would neither
exit nor deliver the signal.
The package solves it with a “stopping” list and a quiescence wait:
// src/os/signal/signal.go:190
// Stop causes package signal to stop relaying incoming signals to c.
// It undoes the effect of all prior calls to [Notify] using c.
// When Stop returns, it is guaranteed that c will receive no more signals.
func Stop(c chan<- os.Signal) {
handlers.Lock()
h := handlers.m[c]
if h == nil {
handlers.Unlock()
return
}
delete(handlers.m, c)
for n := 0; n < numSig; n++ {
if h.want(n) {
handlers.ref[n]--
if handlers.ref[n] == 0 {
disableSignal(n)
}
}
}
// Signals will no longer be delivered to the channel.
// We want to avoid a race for a signal such as SIGINT:
// it should be either delivered to the channel,
// or the program should take the default action (that is, exit).
// To avoid the possibility that the signal is delivered,
// and the signal handler invoked, and then Stop deregisters
// the channel before the process function below has a chance
// to send it on the channel, put the channel on a list of
// channels being stopped and wait for signal delivery to
// quiesce before fully removing it.
handlers.stopping = append(handlers.stopping, stopping{c, h})
handlers.Unlock()
signalWaitUntilIdle()
handlers.Lock()
for i, s := range handlers.stopping {
if s.c == c {
handlers.stopping = slices.Delete(handlers.stopping, i, i+1)
break
}
}
handlers.Unlock()
}The wait itself is in the runtime:
// src/runtime/sigqueue.go:170
// signalWaitUntilIdle waits until the signal delivery mechanism is idle.
// This is used to ensure that we do not drop a signal notification due
// to a race between disabling a signal and receiving a signal.
// This assumes that signal delivery has already been disabled for
// the signal(s) in question, and here we are just waiting to make sure
// that all the signals have been delivered to the user channels
// by the os/signal package.
//
//go:linkname signalWaitUntilIdle os/signal.signalWaitUntilIdle
func signalWaitUntilIdle() {
// Although the signals we care about have been removed from
// sig.wanted, it is possible that another thread has received
// a signal, has read from sig.wanted, is now updating sig.mask,
// and has not yet woken up the processor thread. We need to wait
// until all current signal deliveries have completed.
for sig.delivering.Load() != 0 {
Gosched()
}
// Although WaitUntilIdle seems like the right name for this
// function, the state we are looking for is sigReceiving, not
// sigIdle. The sigIdle state is really more like sigProcessing.
for sig.state.Load() != sigReceiving {
Gosched()
}
}sig.delivering is the counter incremented at the top of sigsend. When it
drops to zero, no handler is in flight. And when the state is sigReceiving,
the consumer goroutine is asleep in notetsleepg with the mask already
swapped — so nothing is in the pipe.
NotifyContext
Go 1.16 added the glue between the two halves of this article:
// src/os/signal/signal.go:275
// NotifyContext returns a copy of the parent context that is marked done
// (its Done channel is closed) when one of the listed signals arrives,
// when the returned stop function is called, or when the parent context's
// Done channel is closed, whichever happens first.
//
// The stop function unregisters the signal behavior, which, like [signal.Reset],
// may restore the default behavior for a given signal. For example, the default
// behavior of a Go program receiving [os.Interrupt] is to exit. Calling
// NotifyContext(parent, os.Interrupt) will change the behavior to cancel
// the returned context. Future interrupts received will not trigger the default
// (exit) behavior until the returned stop function is called.
//
// If a signal causes the returned context to be canceled, calling
// [context.Cause] on it will return an error describing the signal.
//
// The stop function releases resources associated with it, so code should
// call stop as soon as the operations running in this Context complete and
// signals no longer need to be diverted to the context.
func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
ctx, cancel := context.WithCancelCause(parent)
c := &signalCtx{
Context: ctx,
cancel: cancel,
signals: signals,
}
c.ch = make(chan os.Signal, 1)
Notify(c.ch, c.signals...)
if ctx.Err() == nil {
go func() {
select {
case s := <-c.ch:
c.cancel(signalError(s.String() + " signal received"))
case <-c.Done():
}
}()
}
return c, c.stop
}A signal becomes a cancellation cause, and the signalError type is arranged
so that errors.Is(err, context.Canceled) still matches:
// src/os/signal/signal.go:352
type signalError string
func (s signalError) Error() string {
return string(s)
}
func (s signalError) Is(target error) bool {
if target == context.Canceled {
return true
}
return false
}The context tree
Now the other half. context.Context is an interface with four methods, and
the cancellation machinery is a tree of cancelCtx nodes:
// src/context/context.go:430
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err atomic.Value // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}Each cancelCtx holds a set of children and a done channel. Cancelling a
parent closes its done channel and cancels every child, recursively:
// src/context/context.go:551
// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
// cancel sets c.cause to cause if this is the first time c is canceled.
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
if cause == nil {
cause = err
}
c.mu.Lock()
if c.err.Load() != nil {
c.mu.Unlock()
return // already canceled
}
c.err.Store(err)
c.cause = cause
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}Three details worth noting:
- The first cancel wins.
if c.err.Load() != nil { return }— a second cancel is a no-op, so the error a context reports is the reason it was cancelled the first time. - The parent’s lock is held while cancelling children. That is why the comment says “NOTE: acquiring the child’s lock while holding parent’s lock” — it is a deliberate lock-ordering decision, and the tree structure guarantees it cannot deadlock (a child never holds its own lock while acquiring its parent’s).
closedchanis a shared, already-closed channel. A context that is cancelled before anyone asks for itsDonechannel does not allocate one; it reuses a global closed channel.
The children map is how the tree is built:
// src/context/context.go:478
// propagateCancel arranges for child to be canceled when parent is.
// It sets the parent context of cancelCtx.
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
if p, ok := parentCancelCtx(parent); ok {
// parent is a *cancelCtx, or derives from one.
p.mu.Lock()
if err := p.err.Load(); err != nil {
// parent has already been canceled
child.cancel(false, err.(error), p.cause)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}
if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}That function has three strategies, chosen in order:
- The parent is already cancelled — cancel the child immediately.
- The parent is a
cancelCtx— register the child in the parent’schildrenmap. No goroutine, no allocation beyond the map entry. This is the fast path and it covers the overwhelming majority of real code, becauseWithCancelon aWithCancelparent stays inside thecancelCtxworld. - The parent is some other
Context— the only way to find out that an arbitraryContextwas cancelled is to watch itsDonechannel, so the runtime spawns a goroutine per child.
That third case is the price of the interface being open-ended. A custom
Context implementation that is not a cancelCtx costs one goroutine per
derived context, for the life of the derived context.
The tree is torn down when a child is cancelled explicitly:
// src/context/context.go:400
func removeChild(parent Context, child canceler) {
if s, ok := parent.(stopCtx); ok {
s.stop()
return
}
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}Removing the child from the parent’s map is what stops a long-lived parent from
accumulating children forever. If you forget to call the cancel function, the
child stays in the parent’s map and the parent holds a reference to it — a
leak that go vet can find for you (lostcancel).
Canceled versus DeadlineExceeded
The two sentinel errors are not the same thing, and the difference is meaningful:
// src/context/context.go:166
// Canceled is the error returned by [Context.Err] when the context is canceled
// for some reason other than its deadline passing.
var Canceled = errors.New("context canceled")
// DeadlineExceeded is the error returned by [Context.Err] when the context is canceled
// due to its deadline passing.
var DeadlineExceeded error = deadlineExceededError{}
type deadlineExceededError struct{}
func (deadlineExceededError) Error() string { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool { return true }
func (deadlineExceededError) Temporary() bool { return true }Canceled is a plain errors.New value: a package-level singleton you can
compare with ==. DeadlineExceeded is a distinct type, because it has to
carry the Timeout() bool method so that net/http and friends can tell a
timeout from a cancellation. That means:
err == context.Canceledworks for cancellation.err == context.DeadlineExceededalso works, because the value is a zero-size struct and comparing two of them is true — but the idiomatic test iserrors.Is(err, context.DeadlineExceeded).errors.Is(err, context.DeadlineExceeded)is false for a plain cancellation.
A deadline context is a timerCtx, which is a cancelCtx plus a timer:
// src/context/context.go:664
// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}and the deadline is enforced with a time.AfterFunc — which is to say, with
the per-P timer heap from the previous article:
// src/context/context.go:654
c.mu.Lock()
defer c.mu.Unlock()
if c.err.Load() == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, cause)
})
}
return c, func() { c.cancel(true, Canceled, nil) }Note the asymmetry at the end: the AfterFunc path cancels with
DeadlineExceeded, the explicit cancel function cancels with Canceled. If
your deadline fires and you call cancel, whichever happens first wins.
And the cause is a separate thing from the error. context.Cause(ctx) returns
the cause you passed to WithCancelCause, or the error itself if there was
none. That is how NotifyContext can report “interrupt signal received”
while ctx.Err() still returns context.Canceled.
Trade-offs
What the signal design buys you: no lost signals, no blocking in the handler, and a clean separation between “the kernel told us something” and “a goroutine decides what to do about it”. The three-state CAS protocol means concurrent senders and one receiver can never deadlock or miss a bit.
What it costs:
- Signals are coalesced. Ten SIGINTs in a row produce one delivery.
- The channel send is lossy. If your channel is full, the signal is dropped. You must size the buffer yourself.
- One extra goroutine and one extra thread. The
os/signalloop goroutine and the dedicated mask thread exist for the whole life of the program. Notifyis not free. It takes a global lock, updates a map, and synchronously waits for the mask change to be applied.
What the context design buys you: cancellation is a value that flows
down a tree, not a channel you have to plumb. Any number of layers of
WithCancel compose, and cancelling the root cancels everything under it.
What it costs:
- A goroutine per non-
cancelCtxparent. Derive from a customContextimplementation and you pay a goroutine. - The tree must be pruned. Forget
defer cancel()and the parent holds the child forever. - Cancellation is not rollback.
ctx.Done()tells you to stop; it does not undo work already done, and it does not interrupt a goroutine that is not watching.
Caveats
signal.Notify changes the default behaviour. Once you Notify for
SIGINT, the program no longer dies on Ctrl-C; it is your job to exit. If your
handler decides to continue, the user cannot interrupt you any more.
The second SIGINT is often the escape hatch. A common pattern is to
Notify for SIGINT, cancel the context on the first one, and exit on the
second.
signal.Notify with a full channel drops the signal. Not “queues it” —
drops it.
os/signal is Unix-centric. On Windows, the signals are synthesized from
console control events, and the whole model is different.
context.Canceled and context.DeadlineExceeded are not interchangeable.
Code that returns a context error to a caller should preserve which one it was;
http.Server turns DeadlineExceeded into a 504 and everything else into a
503.
ctx.Err() blocks until the Done channel is closed. That looks like a
bug in Err:
// src/context/context.go:464
func (c *cancelCtx) Err() error {
// An atomic load is ~5x faster than a mutex, which can matter in tight loops.
if err := c.err.Load(); err != nil {
// Ensure the done channel has been closed before returning a non-nil error.
// closedchan is closed at init and shared by every canceled context, so
// receiving from it would take one process-global lock for nothing.
if done := c.Done(); done != closedchan {
<-done
}
return err.(error)
}
return nil
}It is not a bug: the comment explains that the error is stored before the
channel is closed, and Err waits for the close so that a caller that sees a
non-nil error is guaranteed that <-ctx.Done() would return immediately.
A cancelled context does not interrupt work. It is advisory. A goroutine sitting in a syscall or a tight loop will keep going.
NotifyContext’s stop function restores the default behaviour. Calling it
for SIGINT means the next Ctrl-C kills the program again.
Wrapping up
The one thing to remember: Go’s signal handler does almost nothing — it
sets a bit in a global bitmask with a compare-and-swap and wakes one goroutine,
which then forwards signals to your channels. Nothing is lost, because the
mask update is synchronised with Notify and the consumer swaps the whole
mask atomically.
The part people get wrong: believing the signal is delivered to the main
thread, or that Notify is a no-op until the next signal. The kernel picks a
thread with the signal unblocked, Go keeps one dedicated thread whose mask it
manages for exactly this purpose, and Notify does not return until that
thread’s mask has been changed.
What to go do about it: use signal.NotifyContext instead of
signal.Notify plus a manual goroutine — it wires the signal to a context
with the correct cause, and it gives you the stop function that restores the
default behaviour when you are done.
Further reading
src/runtime/signal_unix.go—sigtablehandling,sighandler,sigenable,ensureSigM,minitSignalssrc/runtime/sigtab_linux_generic.go— the Linux signal table and its flagssrc/runtime/sigqueue.go—sigsend,signal_recv,signalWaitUntilIdle, the three-state protocolsrc/runtime/os_darwin.go— the pipe-basedsigNote*functionssrc/runtime/sigqueue_note.go— the non-Darwin stubs for those functionssrc/runtime/os_linux.go—setsig,minit,validSIGPROFsrc/os/signal/signal.go—Notify,Stop,process,NotifyContextsrc/os/signal/signal_unix.go— theloopthat drivessignal_recvsrc/context/context.go—cancelCtx,propagateCancel,cancel,removeChild,timerCtx