skip to content

Termination Handling

What happens between SIGTERM arriving and the process being gone: signal.NotifyContext, a drain budget, and the exit status. Asked because os.Exit skips every deferred call you wrote.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

13

How does a Go program catch Ctrl-C (SIGINT) instead of exiting immediately?

level: juniorimportance: must knowfreq 68%

answer

  1. the terminal talks to the process group
  2. one standard-library package owns this
  3. you hand the runtime a place to put it
  4. os/signal, a buffered os.Signal channel
  5. or the context-flavoured wrapper

basics

~20 s

Call signal.Notify with a buffered os.Signal channel listing os.Interrupt. Go then delivers Ctrl-C on that channel instead of terminating the process, so your code decides what happens next. signal.NotifyContext does the same registration and cancels a context.

solid answer

~40 s

By default a Go program that receives SIGINT just dies, because the runtime's own handler applies the default action. You opt out by registering interest: `ch := make(chan os.Signal, 1)` then `signal.Notify(ch, os.Interrupt, syscall.SIGTERM)`. From that point the runtime stops terminating the process for those signals and instead sends the `os.Signal` value on your channel; a receive on `ch` unblocks when the user presses Ctrl-C, and you run whatever shutdown work you want. The channel must be buffered, because package signal never blocks when delivering. The modern one-liner is `ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)` with `defer stop()`: it does the same registration but hands you a context that is cancelled on the first matching signal, which composes with everything that already takes a `context.Context`.

code

go · 5 lines
go
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)

sig := <-ch // unblocks on Ctrl-C or a supervisor's SIGTERM
fmt.Println("received:", sig) // prints: received: interrupt

go deeper

for a junior

Be able to write the three lines from memory: make a buffered chan os.Signal, call signal.Notify with os.Interrupt, then receive from it. Know that without this the process just dies on Ctrl-C.

for a middle

Explain what changes in the runtime when Notify is called, why the channel must be buffered, and how signal.NotifyContext expresses the same thing as a cancellable context.Context.

for a senior

Show where registration belongs in a real program: early in main, before work starts, with the cleanup path explicit. Be ready to say what is still not guaranteed, since SIGKILL cannot be caught.

for a principal

Frame it as a contract with whatever supervises the process: what your service promises to finish after a signal, and how that promise degrades when the supervisor escalates. Keep the policy in one place, not scattered per subsystem.

## What happens with no handler at all When you press Ctrl-C in a terminal, the terminal driver sends SIGINT to every process in the foreground process group. A Go binary that has registered nothing for SIGINT dies at that point. This is not the kernel's default disposition acting directly: Go's runtime installs its own handler for most signals at startup, and for SIGINT, SIGTERM and SIGHUP that handler's default action is to terminate the program as if the signal had been unhandled. The practical consequence is the same either way -- the process stops, and nothing you wrote gets a chance to run. ## Registering interest with signal.Notify The `os/signal` package is how you take over. Its central function is: ``` func Notify(c chan<- os.Signal, sig ...os.Signal) ``` After `Notify`, the package relays each of the named signals to `c`, and the runtime stops applying the default termination for those signals. If you pass no signals at all, every signal the package can catch is relayed to `c` -- almost never what you want, because it also hoovers up signals like SIGURG that the runtime uses internally. The canonical shape is three lines: ``` ch := make(chan os.Signal, 1) signal.Notify(ch, os.Interrupt, syscall.SIGTERM) sig := <-ch ``` Three details matter. First, the channel is buffered. Package signal performs a non-blocking send; if nobody is receiving and the buffer is full, the signal is discarded rather than queued, so an unbuffered channel can silently swallow a Ctrl-C. Second, you list the signals you actually care about: `os.Interrupt` for a human at a terminal, `syscall.SIGTERM` for a supervisor or init system asking the process to stop. Third, the value you receive is an `os.Signal`, whose `String()` gives you `"interrupt"` or `"terminated"` -- useful in a log line so the next person can see which one arrived. The signal arrives on an ordinary goroutine through an ordinary channel, not inside an OS signal handler. That is a genuinely nice property of Go's design: there is no async-signal-safety restriction on what you may do. You can allocate, take a mutex, log, write to a file, or call an HTTP shutdown method. In C that same code would be undefined behaviour inside a `sigaction` handler. ## signal.NotifyContext Since Go 1.16 the standard library offers a context-shaped wrapper: ``` func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) ``` It performs the same registration internally, but instead of handing you a channel it hands you a derived context that is cancelled the first time one of the listed signals arrives. `<-ctx.Done()` replaces `<-ch`, and `ctx.Err()` will be `context.Canceled`. Because almost every long-running standard-library and application API already accepts a `context.Context`, this version usually needs no plumbing of its own. The returned `stop` function unregisters the signal handling again -- like `signal.Reset` -- and should be deferred, both to release the internal goroutine and channel and to hand the signals back to their default behaviour once your program no longer wants them. ## Which signals you can and cannot catch SIGKILL and SIGSTOP can never be delivered to a handler; registering them with `signal.Notify` compiles fine and simply never fires. `os.Kill` exists as a value you can *send* to another process, not one you can receive. Anything that depends on cleanup running is therefore always best-effort: a supervisor that escalates from SIGTERM to SIGKILL will win. ## Multiple listeners `Notify` may be called many times, with different channels, for the same signal. Every registered channel receives its own copy of each delivery -- there is no single "the handler" that a later call replaces. That is what lets a library register for SIGHUP for config reload while `main` separately registers for SIGINT and SIGTERM. ## The mistakes that show up in review The first is registering the signal and then letting `main` return, or blocking on the wrong thing, so nothing ever receives -- the program becomes uninterruptible instead of graceful. The second is assuming that catching the signal makes deferred functions run: it does not do that by itself, it only gives your code the chance to run the cleanup path deliberately. The third is calling `Notify` inside a goroutine that starts *after* the moment a signal might arrive; register early, in `main`, before you start doing work a user might interrupt.

  • What does signal.NotifyContext give you that signal.Notify does not?
    It returns a derived context cancelled on the first matching signal, plus a stop function that unregisters the handling again. Since most long-running APIs already take a context.Context, that cancellation propagates without you plumbing a channel through every layer. Under the hood it is still signal.Notify on a channel it owns.
  • Can two different channels be registered for the same signal in one program?
    Yes. signal.Notify can be called repeatedly with different channels, and every registered channel gets its own copy of each delivery. A later call does not replace an earlier one, which is what lets a library watch SIGHUP while main separately watches os.Interrupt and syscall.SIGTERM.
  • Is there any restriction on what you may do when the signal value arrives?
    No. The value is delivered on a normal goroutine over a normal channel, not inside an OS signal handler, so async-signal-safety rules do not apply. You may allocate, log, take locks and make blocking calls -- all of which would be undefined behaviour inside a C sigaction handler.

Without signal.Notify, Ctrl-C is a fire alarm wired straight to the building's power cut. Registering a channel puts a receptionist in front of it: the alarm now rings on your desk, and you decide what to do about it.

saying these in an interview costs you the question

  • Thinks catching a signal makes deferred functions run automatically
  • Passes an unbuffered channel to signal.Notify
  • Believes signal.Notify can catch SIGKILL if you register it
  • Registers the signal only after the long-running work has started
  • Assumes a later signal.Notify call replaces the earlier registration
open as a page

Why does os.Exit skip every deferred call in a Go program, and what is lost?

level: middleimportance: must knowfreq 60%

basics

~10 s

os.Exit ends the process immediately at the runtime level without unwinding any goroutine's stack, so no deferred call anywhere runs. Whatever a deferred flush, close, cleanup or summary would have done simply never happens.

open as a page

Why does a drain budget built with context.WithTimeout(sigCtx, ...) fire instantly when sigCtx is an already-cancelled signal.NotifyContext context?

level: middleimportance: must knowfreq 55%

basics

~20 s

Cancellation flows from parent to child. A context derived from an already-cancelled parent is born cancelled: its Done channel is closed at once and its Err is context.Canceled, not context.DeadlineExceeded. Parent the budget on a live context.

open as a page

In Go, what exit status does a process report when main returns, and how do you exit non-zero?

level: juniorimportance: should knowfreq 50%

basics

~20 s

When main returns, the Go runtime ends the process with status 0, which every caller reads as success. To report failure you must call os.Exit with a non-zero code; os.Exit(1) is the conventional failure status.

open as a page

Why does a Go worker's shutdown path wait for in-flight work under a context.WithTimeout budget instead of waiting indefinitely?

level: juniorimportance: should knowfreq 45%

basics

~20 s

The platform that sent the stop signal kills the process after a fixed grace period anyway. A bounded wait finishes what it can and then reports what it abandoned; an unbounded wait hands that decision to a kill instead.

open as a page

Why is main in a Go program often a thin wrapper around a run() error function?

level: middleimportance: should knowfreq 45%

basics

~10 s

os.Exit runs no deferred calls, so the only safe place to call it is main, after everything else has returned. run() does the work and returns an error; main prints it and exits non-zero.

open as a page

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

level: middleimportance: should knowfreq 52%

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.

open as a page

Why does Go's os package define os.Interrupt but leave SIGTERM to syscall?

level: middleimportance: should knowfreq 46%

basics

~20 s

The os package is portable, so it defines only the two signal values guaranteed everywhere: os.Interrupt and os.Kill. Everything else, including SIGTERM, lives in the platform-specific syscall package. Registering os.Kill or syscall.SIGKILL with signal.Notify never fires.

open as a page

Your Go migration tool exits 0 even when the migration failed. Which termination paths produce that status?

level: seniorimportance: should knowfreq 35%

basics

~20 s

A Go process reports 0 whenever main returns, so an error that was only logged, a discarded return value, or a failure in a goroutine main never waited for all end green. Only a non-zero os.Exit makes failure visible.

open as a page

A worker's drain deadline fires with items still in flight. What should the process report, and how do you find what was still running?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Report it loudly: log the abandoned item count, emit a metric, and exit with a status reserved for an incomplete drain. Before exiting, write the goroutine profile to standard error so the straggler's stack is captured.

open as a page

How do you set a service's drain budget against the platform's stop timeout, and is a timed-out drain worth paging on?

level: principalimportance: should knowfreq 38%

basics

~20 s

Fit the budget strictly inside the platform's grace period, then set the posture from what an abandoned item costs: redeliverable work means alert on the rate, a duplicate or dropped side effect means page. Write the rule down.

open as a page

During a drain, how do you make a second SIGTERM stop a Go worker immediately instead of being swallowed?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

While Go's signal handler stays registered, a second SIGTERM no longer kills the process, so it does nothing during the drain. Either unregister the handler when the drain starts, restoring the default kill, or watch for it and abort.

open as a page

A Go REPL calls signal.Notify for SIGINT and now Ctrl-C does nothing at all. Why, and how do you get an escape hatch back?

level: seniorimportance: nice to knowfreq 34%

basics

~20 s

signal.Notify disabled the default termination for SIGINT, so an undrained channel means Ctrl-C is dropped and ignored. Press Ctrl-backslash: SIGQUIT still has its default handler and dumps every goroutine's stack, showing where the reader is parked.

open as a page