What Is a Signal?
A signal is a notification sent by the operating system kernel (or another process) to a running program. Think of it as a tap on the shoulder — something happened outside your program’s normal flow of execution, and the OS wants to tell you about it.
When you press Ctrl+C in a terminal, the kernel sends SIGINT to your program. By default, that kills it. Go’s os/signal package lets you intercept signals like this and decide what to do — flush a database write, close connections, or log a final message before exiting.
| Signal | Number | Triggered By | Default Action |
|---|---|---|---|
SIGINT |
2 | Ctrl+C |
Terminate |
SIGTERM |
15 | kill <pid> |
Terminate |
SIGHUP |
1 | Terminal disconnect | Terminate |
SIGQUIT |
3 | Ctrl+\ |
Terminate + stack dump |
SIGKILL |
9 | kill -9 — uncatchable |
Terminate (forced) |
SIGUSR1/2 |
10/12 | Application-defined | Terminate |
Synchronous signals (SIGBUS, SIGFPE, SIGSEGV) are caused by program errors and converted by Go into runtime panics — you do not handle these with os/signal. Asynchronous signals arrive from outside the program and are what this package is designed for.
How Signal Delivery Works
Understanding what happens under the hood makes the API much easier to use correctly.
sequenceDiagram
participant User as User / OS Kernel
participant RT as Go Runtime<br/>(signal handler)
participant SRL as signal_recv()<br/>loop goroutine
participant Process as process() func
participant Chan as Buffered Channel<br/>chan os.Signal
participant App as Your Goroutine<br/>(<-sigChan)
User->>RT: Signal delivered (e.g. SIGINT)<br/>Kernel invokes the registered C handler
RT->>RT: Record signal in internal<br/>runtime queue / bitmask
RT->>SRL: Unblock signal_recv()<br/>(returns signal number)
SRL->>Process: Call process(syscall.Signal(n))
Process->>Process: Lock handlers mutex<br/>Look up registered channels
Process->>Chan: Non-blocking send<br/>select { case c <- sig: default: }
Chan->>App: Goroutine unblocks<br/>sig := <-sigChan
App->>App: Run your cleanup logic<br/>and exit or continueWhen a Go program starts, the runtime pre-installs OS-level signal handlers before main() runs. When a signal arrives, those handlers record it and wake up a special goroutine that calls signal_recv() — a blocking runtime function. That goroutine then fans the signal out to whichever channels your code registered with Notify.
You never need to manage threads or callbacks. Signals flow through ordinary Go channels, so you handle them exactly like any other concurrent event in your program.
Source Code Dive
The Handler Registry
The package tracks all registrations in a single global struct protected by a mutex:
var handlers struct {
sync.Mutex
// Maps each channel to the set of signals it wants
m map[chan<- os.Signal]*handler
// Reference count: how many channels want each signal number
ref [numSig]int64
// Channels in the process of being stopped
stopping []stopping
}
Each channel’s registered signals are stored as a compact bitmask — one bit per signal number. Lookups and updates are a single bitwise operation:
type handler struct {
mask [(numSig + 31) / 32]uint32
}
func (h *handler) want(sig int) bool {
return (h.mask[sig/32]>>uint(sig&31))&1 != 0
}
func (h *handler) set(sig int) {
h.mask[sig/32] |= 1 << uint(sig&31)
}
func (h *handler) clear(sig int) {
h.mask[sig/32] &^= 1 << uint(sig&31)
}
The Notify Function
Notify registers a channel to receive the signals you specify. The first time it is called anywhere in the program, it starts the background watcher goroutine:
func Notify(c chan<- os.Signal, sig ...os.Signal) {
if c == nil {
panic("os/signal: Notify using nil channel")
}
handlers.Lock()
defer handlers.Unlock()
add := func(n int) {
if n < 0 {
return
}
h := getHandler() // get or create handler for this channel
if !h.want(n) {
h.set(n) // mark channel as interested in signal n
if handlers.ref[n] == 0 {
enableSignal(n) // tell the runtime to capture this signal
// Start the background watcher goroutine — only once, ever
watchSignalLoopOnce.Do(func() {
if watchSignalLoop != nil {
go watchSignalLoop()
}
})
}
handlers.ref[n]++ // increment reference count
}
}
// ...
}
sync.Once ensures the watcher goroutine starts exactly once regardless of how many times Notify is called. handlers.ref[n] is a reference count — when it drops to zero (via Stop), the runtime stops capturing that signal and the OS default behavior is restored.
The Signal Dispatcher
When signal_recv() unblocks, it calls process(), which fans the signal out to all interested channels using a non-blocking send:
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) {
select {
case c <- sig:
default: // drop if buffer is full — never block the dispatcher
}
}
}
}
The default branch means signals are silently dropped if your channel buffer is full. Always use a buffered channel and read from it promptly. If you need to handle bursts of the same signal, increase the buffer size accordingly.
Safe Deregistration with Stop
Stop has a subtle race condition to handle: a signal might have been captured by the runtime but not yet delivered to the channel at the moment you call Stop. The implementation handles this with a temporary stopping list and a runtime-level idle wait:
func Stop(c chan<- os.Signal) {
handlers.Lock()
h := handlers.m[c]
if h == nil {
handlers.Unlock()
return
}
delete(handlers.m, c)
// ... decrement ref counts, disable signals if ref hits 0 ...
// Keep c in the stopping list so any in-flight signal can still be delivered
handlers.stopping = append(handlers.stopping, stopping{c, h})
handlers.Unlock()
// Block until the runtime confirms no signals are mid-delivery
signalWaitUntilIdle()
handlers.Lock()
// Now safe to fully remove c
for i, s := range handlers.stopping {
if s.c == c {
handlers.stopping = slices.Delete(handlers.stopping, i, i+1)
break
}
}
handlers.Unlock()
}
The guarantee in the documentation — “when Stop returns, it is guaranteed that c will receive no more signals” — is upheld by signalWaitUntilIdle(), which is implemented in the Go runtime itself.
The OS Abstraction Layer
On Unix systems, signal_unix.go provides the bridge to the runtime. The loop function is assigned to watchSignalLoop and runs in the background goroutine:
// Defined by the runtime package — these cross the Go/runtime boundary
func signal_disable(uint32)
func signal_enable(uint32)
func signal_ignore(uint32)
func signal_ignored(uint32) bool
func signal_recv() uint32 // blocks until a signal arrives
func loop() {
for {
process(syscall.Signal(signal_recv()))
}
}
func init() {
watchSignalLoop = loop
}
On Plan 9, signals are strings called “notes” rather than integers. signal_plan9.go provides an alternative implementation of loop, signum, and the enable/disable functions behind the same interface — the rest of the package is unaffected.
NotifyContext — Signals as Context Cancellation
Added in Go 1.16, NotifyContext integrates signal handling with the context package. When a signal arrives, the context is cancelled — which naturally propagates through any HTTP handlers, database calls, or goroutines that respect ctx.Done():
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:
// Cancel context with a descriptive error message
c.cancel(signalError(s.String() + " signal received"))
case <-c.Done():
}
}()
}
return c, c.stop
}
Practical Example
Classic Channel-Based Handling
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
fmt.Printf("Server started (PID: %d). Press Ctrl+C to stop.\n", os.Getpid())
// Always use a buffered channel — a buffer of 1 is enough for a single
// signal type. It queues one signal even if your goroutine isn't ready yet.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go doWork()
sig := <-sigChan
fmt.Printf("\nReceived: %s — running cleanup...\n", sig)
cleanup()
}
func doWork() {
for i := 1; ; i++ {
fmt.Printf("Working... tick %d\n", i)
time.Sleep(1 * time.Second)
}
}
func cleanup() {
fmt.Println("Flushing buffers and closing connections.")
time.Sleep(200 * time.Millisecond)
fmt.Println("Done.")
}
Handling Multiple Signals Differently
signal.Notify(sigChan,
syscall.SIGINT, // Ctrl+C → shutdown
syscall.SIGTERM, // kill → shutdown
syscall.SIGHUP, // hangup → reload config
syscall.SIGUSR1, // user-defined → dump state
)
for {
switch sig := <-sigChan; sig {
case syscall.SIGHUP:
fmt.Println("Reloading configuration...")
reloadConfig()
case syscall.SIGUSR1:
fmt.Println("Dumping internal state...")
dumpState()
case syscall.SIGINT, syscall.SIGTERM:
fmt.Printf("Shutdown signal (%s) received.\n", sig)
return
}
}
Using NotifyContext for Servers
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Pass ctx to your HTTP server, workers, database clients, etc.
// When Ctrl+C is pressed, ctx.Done() closes and everything shuts down cleanly.
if err := server.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
Prefer NotifyContext for server applications. It eliminates the manual channel wiring and works naturally with any library that accepts a context.Context for cancellation.
Signal Handling Best Practices
Here are the key practices that separate correct signal handling from brittle signal handling:
- Always use buffered channels – a buffer of 1 is the minimum; signals are dropped without error if the buffer is full
- Always call
signal.Stop– pair everyNotifycall with a deferredStopto release the registration and restore OS defaults - Prefer
NotifyContextin servers – it integrates cleanly with context propagation and requires less boilerplate - Never catch
SIGKILLorSIGSTOP– the OS prevents this by design;Notifysilently ignores them - Use
SIGHUPfor config reload – it is the Unix convention; send it withkill -HUP <pid>without restarting the process - Keep signal-receiving goroutines fast –
process()holds the global mutex during dispatch; heavy work should be offloaded to a separate goroutine
Conclusion
Go’s os/signal package is a thin but carefully designed bridge between the OS kernel’s signal delivery mechanism and Go’s channel-based concurrency model. The Go runtime installs signal handlers at startup, a background goroutine waits for them using signal_recv(), and process() fans each signal out to whichever buffered channels your code registered. Your goroutine receives it like any other channel value — no callbacks, no locks, no platform-specific code.
Start with signal.Notify for simple shutdown handling and graduate to NotifyContext as your application adopts context-based cancellation throughout. Both give you clean, predictable shutdown behavior without fighting the runtime.
