How Go's `os/signal` Package Works: A Deep Dive from First Principles
ProgrammingIntermediateMarch 26, 202615 min read

How Go's `os/signal` Package Works: A Deep Dive from First Principles

#Computer Science

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 -9uncatchable 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 continue

When 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.

Tip

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:

go
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:

go
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:

go
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:

go
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
            }
        }
    }
}
Important

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:

go
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:

go
// 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():

go
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

go
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

go
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

go
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)
}
Tip

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 every Notify call with a deferred Stop to release the registration and restore OS defaults
  • Prefer NotifyContext in servers – it integrates cleanly with context propagation and requires less boilerplate
  • Never catch SIGKILL or SIGSTOP – the OS prevents this by design; Notify silently ignores them
  • Use SIGHUP for config reload – it is the Unix convention; send it with kill -HUP <pid> without restarting the process
  • Keep signal-receiving goroutines fastprocess() 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.

Written by

C

Chanarith Buth

Backend Engineer @Techversed Co., Ltd

Techversedtechversed

Concepts, patterns, and insights for engineers who think before they ship.