Updated Jul 18, 2026

06 - Signals & Lifecycle

📋 Jump to Takeaways

🎁 You press Ctrl+C and a well-written program doesn't just vanish. It finishes the request it was handling, closes its files, and exits on its own terms. What just traveled from your keyboard into the process, and how did the program catch it?

A signal is a tiny asynchronous message the kernel delivers to a process: stop, reload, your child exited, you touched a bad address. In the last lesson you killed a subprocess bluntly with a context timeout. This lesson is the graceful version: catching signals, shutting down cleanly, reloading config, and sending signals to other processes.

What Signals Are

Signals are numbered notifications. A handful are worth knowing by name, because you'll handle them constantly:

// SIGINT  (2)  Ctrl+C in the terminal
// SIGTERM (15) the polite "please stop" that kill sends by default
// SIGHUP  (1)  terminal hangup, reused by convention to mean "reload config"
// SIGKILL (9)  force kill: cannot be caught, blocked, or ignored
// SIGSTOP      pause the process: also cannot be caught or ignored
// SIGUSR1/2    no fixed meaning, free for your app to define

Most signals can be caught and handled. Two cannot: SIGKILL and SIGSTOP are handled entirely by the kernel, so no program can trap or delay them. That's deliberate; it guarantees an operator always has a way to stop a misbehaving process.

Catching Signals

Go delivers signals to your program over a channel. You register interest with signal.Notify, passing a buffered channel and the signals you care about:

sigs := make(chan os.Signal, 1)   // buffered so a signal is never dropped
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

sig := <-sigs                     // blocks until one arrives
fmt.Println("got signal:", sig)   // got signal: interrupt

The channel must be buffered. Signal delivery is non-blocking: if the runtime can't send on your channel immediately, the signal is dropped. A buffer of 1 gives it somewhere to land while your code gets around to reading it.

Graceful Shutdown with NotifyContext

Reading from a channel by hand is fine, but for shutdown there's a cleaner tool. signal.NotifyContext (Go 1.16+) returns a context that's canceled when a signal arrives, so shutdown flows through the same context you already cancel goroutines with:

ctx, stop := signal.NotifyContext(context.Background(),
    syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := startServer()
<-ctx.Done()                 // unblocks on Ctrl+C or SIGTERM
fmt.Println("draining...")   // draining...
srv.Shutdown()               // finish in-flight work, then return

This is the deeper machinery under the graceful shutdown pattern from Go in Practice, and it composes with the context cancellation from the concurrency course. One canceled context can stop every goroutine in the program at once. Call stop() when you're done to stop catching the signals.

Reloading on SIGHUP

SIGTERM means stop, but SIGHUP means "reload your config and keep running." You handle it in its own loop, separate from the shutdown signals, so a reload never triggers an exit:

hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
    for range hup {
        cfg, err := loadConfig("/etc/app.yaml")
        if err != nil {
            fmt.Println("reload failed, keeping old config")
            continue
        }
        apply(cfg)
        fmt.Println("config reloaded")   // config reloaded
    }
}()

Notice the loop keeps the old config when a reload fails. A long-running service should never crash because someone saved a typo. This is the pattern a daemon uses to pick up new settings without dropping connections. A daemon is a long-running background service with no terminal attached, the kind of process that stays up for days handling requests.

Ignoring and Restoring Signals

Sometimes you want to ignore a signal, or stop caring about one you registered. signal.Ignore drops signals entirely, signal.Stop unregisters a channel, and signal.Reset restores Go's default behavior:

signal.Ignore(syscall.SIGHUP)   // discard SIGHUP: it does nothing now

signal.Stop(sigs)               // stop delivering to this channel
signal.Reset(syscall.SIGINT)    // SIGINT goes back to its default (terminate)

Ignoring SIGHUP is a classic trick so a daemon survives its controlling terminal closing. The controlling terminal is the shell session a process was launched from, and closing that window makes the kernel send SIGHUP to everything it started. Just remember you can't ignore SIGKILL or SIGSTOP, no matter what you pass.

Sending Signals

Signals aren't only for receiving. Your program can send one to any process it has permission to, through the os.Process handle you get back from starting or finding a process:

p, err := os.FindProcess(pid)
if err == nil {
    p.Signal(syscall.SIGTERM)   // ask it to stop politely
}

p.Kill() is the shortcut for sending SIGKILL, the one that can't be refused. Reach for SIGTERM first and give the process a moment to clean up; escalate to Kill only if it ignores you. That polite-then-forceful sequence is exactly what systemctl stop and Kubernetes do when they shut a service down.

Key Takeaways

  • Signals are asynchronous kernel messages. Know SIGINT (Ctrl+C), SIGTERM (default stop), SIGHUP (reload by convention), and SIGUSR1/SIGUSR2 (app-defined). SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.
  • signal.Notify(ch, sigs...) delivers signals to a channel; the channel must be buffered or signals are dropped.
  • signal.NotifyContext(ctx, sigs...) returns a context canceled on a signal, the idiomatic base for graceful shutdown; call the returned stop() when done.
  • Handle SIGHUP in its own loop to reload config without exiting, and keep the old config if the reload fails.
  • signal.Ignore(sigs...) discards signals, signal.Stop(ch) unregisters a channel, and signal.Reset(sigs...) restores default behavior.
  • Send signals with proc.Signal(syscall.SIGTERM); proc.Kill() sends the uncatchable SIGKILL. Prefer SIGTERM first, escalate to Kill only if ignored.

🎁 You've handled signals arriving over a channel and never blocked a thread doing it. That same trick, turning a blocking wait into something cheap, is what lets a single Go server juggle thousands of network connections. Next you'll open real sockets and start moving bytes over the network.

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy