{} The Go Reference

Processes · Systems · Intermediate

Signals

Reacting to OS signals in Go — os/signal.Notify, catching SIGINT/SIGTERM for graceful shutdown, signal.NotifyContext, and ignoring or resetting signals.

Processes Intermediate ⏱ 4 min read Complete

🔔 Analogy

A signal is the OS tapping your program on the shoulder. Some taps are polite requests — “please wrap up and exit” (SIGTERM), “the user pressed Ctrl-C” (SIGINT) — and you can choose how to respond: finish the in-flight request, flush logs, close connections, then leave. Two taps, though, aren’t requests but commands the kernel enforces no matter what: SIGKILL (“you’re gone, now”) and SIGSTOP (“freeze”). Good systems software listens for the polite taps so it almost never has to be killed.

Signals as channel receives

A signal is an asynchronous notification from the kernel. Handling async events safely is hard in most languages (you’re in a restricted handler context). Go’s trick: turn signals into channel sends with os/signal, so you handle them in ordinary code.

graph LR
OS["OS: SIGTERM"] -->|"signal.Notify"| CH["chan os.Signal (buffered)"]
CH --> CODE["your code: <-ch → clean up → exit"]

This runs here and delivers a real signal — the goroutine sends our own process SIGTERM via syscall.Kill (the kill(2) syscall), exactly as kill <pid> or an orchestrator would. (A select timeout guards against any sandbox that blocks self-signalling.)

signals.go — editable & runnable
package main

import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)

func main() {
// Relay SIGINT/SIGTERM onto a buffered channel.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

// Deliver a REAL signal to ourselves — the same kill(2) syscall that
// Ctrl-C, 'kill <pid>', or Kubernetes uses.
go func() {
	time.Sleep(50 * time.Millisecond)
	syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
}()

fmt.Println("server running; waiting for shutdown signal…")
select {
case sig := <-sigs: // block until the signal arrives
	fmt.Printf("received %v — shutting down gracefully\n", sig)
	// Real cleanup goes here: stop accepting work, drain, flush, close.
	fmt.Println("drained connections, flushed logs — bye")
case <-time.After(2 * time.Second):
	fmt.Println("(no signal delivered — sandbox restriction)")
}
}

The handler logic — receive on the channel, then clean up — is identical whether the signal comes from syscall.Kill, a terminal Ctrl-C, or an orchestrator’s SIGTERM.

Graceful shutdown, the modern way

In real services you rarely manage the channel yourself — signal.NotifyContext gives you a context that cancels on the first signal, which you hand straight to your server’s Shutdown:

func main() {
	// ctx is cancelled when SIGINT or SIGTERM arrives.
	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: mux}
	go srv.ListenAndServe()

	<-ctx.Done() // wait for the signal
	log.Println("shutting down…")

	// Give in-flight requests up to 10s to finish, then force.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(shutdownCtx) // stops accepting, drains existing conns
}

That’s the canonical pattern: catch the signal → stop accepting new work → drain in-flight work with a deadline → exit. It pairs directly with context cancellation.

Common signals

SignalMeaningDefaultCatchable?
SIGINTCtrl-C from terminalterminateyes
SIGTERMpolite “please stop” (kill, orchestrators)terminateyes
SIGHUPterminal closed / “reload config” by conventionterminateyes
SIGUSR1/SIGUSR2user-definedterminateyes
SIGKILLforce killterminateno
SIGSTOPforce suspendstopno

You can also ignore a signal (signal.Ignore) or stop catching one (signal.Reset/signal.Stop), e.g. to restore default Ctrl-C behavior after a critical section.

🐹 SIGTERM is your shutdown contract with the platform

Kubernetes, systemd, Docker, and most process managers send SIGTERM first and only escalate to SIGKILL after a grace period (30s by default in k8s). So the single most valuable signal to handle is SIGTERM: catch it, stop accepting new requests, finish what’s in flight, and exit before the grace period runs out. A service that ignores SIGTERM gets SIGKILLed mid-request — dropped connections, half-written files (which is why atomic writes matter). Handle SIGTERM and you control your own shutdown.

⚠️ Buffer the channel; you can't catch SIGKILL

Two musts. Buffer the signal channel (make(chan os.Signal, 1)) — signal.Notify sends non-blocking, so an unbuffered channel with no waiting receiver silently drops the signal. And you cannot catch SIGKILL or SIGSTOP — no amount of handling saves you from kill -9, so never rely on cleanup running on those; make your on-disk state crash-safe instead. Finally, signal handlers run concurrently with everything else, so the work you do on receiving one must be goroutine-safe.

See also

Next: programs that wake on a timer or a file change — scheduling & watching.

Check your understanding

Score: 0 / 5

1. What is an OS signal?

Signals are the OS's way of poking a running process out-of-band: Ctrl-C sends SIGINT, `kill` sends SIGTERM, a segfault raises SIGSEGV. The process can catch most of them and decide how to react — except SIGKILL and SIGSTOP, which can't be caught.

2. How do you receive signals in Go?

Go turns async signals into channel sends: make a buffered chan os.Signal, call signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM), then `sig := <-ch`. This fits signals into the normal select/channel model instead of unsafe async handlers.

3. Which signals can a process NOT catch or ignore?

SIGKILL (force terminate) and SIGSTOP (force suspend) are uncatchable and unignorable by design — they're the OS's last resort. That's why graceful shutdown listens for SIGTERM/SIGINT: SIGKILL gives you no chance to clean up.

4. What's the idiomatic modern way to tie shutdown to a context?

signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) (Go 1.16+) gives a context that cancels on the first matching signal. You pass it to your server's Shutdown(ctx) and everything wired to ctx.Done() unwinds — the cleanest graceful-shutdown wiring.

5. Why must the signal channel be buffered (capacity ≥ 1)?

The runtime delivers signals with a non-blocking send. If your channel has no buffer and you're not parked on a receive at that instant, the notification is lost. A buffer of 1 ensures a signal arriving before you reach `<-ch` is still captured.

Comments

Sign in with GitHub to join the discussion.