CFoundations

C Signals & Interrupt Handling: Graceful Shutdown, Crash Reporting & Async Events

TT
TopicTrick Team
C Signals & Interrupt Handling: Graceful Shutdown, Crash Reporting & Async Events

C Signals & Interrupt Handling: Graceful Shutdown, Crash Reporting & Async Events


Table of Contents


What Are Signals?

Signals are delivered asynchronously — they can arrive while your code is in the middle of a strlen(), a malloc(), or even inside another signal handler. This asynchronous nature is what makes signal handlers so restricted: almost any function you call from a handler could be corrupted or re-entered.


Important POSIX Signal Reference

SignalDefaultWhen Sent
SIGINT (2)TerminateUser presses Ctrl+C
SIGTERM (15)Terminatekill pid — request for graceful shutdown
SIGQUIT (3)Core dumpUser presses Ctrl+\
SIGKILL (9)Terminatekill -9 — cannot be caught or ignored
SIGHUP (1)TerminateTerminal closed; often used for config reload
SIGSEGV (11)Core dumpSegmentation fault (invalid memory access)
SIGBUS (7)Core dumpBus error (misaligned access)
SIGFPE (8)Core dumpFloating-point exception (division by zero)
SIGPIPE (13)TerminateWrite to broken pipe (read end closed)
SIGCHLD (17)IgnoreChild process terminated
SIGALRM (14)TerminateTimer from alarm() expired
SIGUSR1/2TerminateUser-defined; typically used for app-specific events

signal() vs sigaction(): The Right Tool

The old signal() function is portable but has several implementation-defined behaviors. The modern replacement is sigaction():

c
#include <signal.h>
#include <stdio.h>

void old_handler(int sig) {
    // signal() may or may not auto-reset the handler on delivery
    // behavior on concurrent signals is implementation-defined
    printf("SIGINT via signal()\n");
}

// sigaction: precise, portable, thread-safe
void new_handler(int sig) {
    // Handler body
}

int setup_signals(void) {
    struct sigaction sa;
    
    sa.sa_handler = new_handler;
    sigemptyset(&sa.sa_mask);        // No additional signals blocked during handler
    sa.sa_flags   = SA_RESTART;      // Restart syscalls interrupted by signal
    
    if (sigaction(SIGINT,  &sa, NULL) < 0) return -1;
    if (sigaction(SIGTERM, &sa, NULL) < 0) return -1;
    
    // Ignore SIGPIPE globally (common in server code)
    sa.sa_handler = SIG_IGN;
    if (sigaction(SIGPIPE, &sa, NULL) < 0) return -1;
    
    return 0;
}

SA_RESTART: Without this flag, if a signal interrupts a blocking syscall (like read(), accept(), sleep()), the syscall returns with errno = EINTR. With SA_RESTART, the OS automatically restarts those syscalls after the handler returns.

sa.sa_mask: Signals to block during handler execution. sigemptyset blocks none. sigfillset blocks all signals while the handler runs (prevents re-entrant handling).


Writing Correct Signal Handlers

Signal handlers run asynchronously on your thread's current stack, potentially interrupting any function mid-execution. Most standard library functions are NOT safe to call from a handler:

c
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

// BAD handler — calls async-signal-UNSAFE functions
void bad_handler(int sig) {
    printf("Received signal %d\n", sig);  // UNSAFE: printf uses malloc internally
    free(some_ptr);                        // UNSAFE: malloc/free are not re-entrant
    fclose(some_file);                     // UNSAFE: stdio is not re-entrant
}

// GOOD handler — minimalist, signal-safe
volatile sig_atomic_t g_shutdown_requested = 0;

void good_handler(int sig) {
    // ONLY: set a volatile flag
    g_shutdown_requested = 1;
    
    // Async-signal-safe functions we can call:
    // write() — direct syscall, no buffering
    const char msg[] = "Signal received\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);
}

Signal-Safe Functions: The Strict List

POSIX defines the complete list of async-signal-safe functions — the only ones you may call from a signal handler. Key safe functions include:

text
write()     read()      open()      close()    _exit()
kill()      getpid()    getppid()   alarm()    pause()
raise()     signal()    sigaction() sigemptyset() sigfillset()
waitpid()   fork()      execve()

Notable UNSAFE functions (never call from a handler):

  • printf, fprintf, sprintf — use internal locks and malloc
  • malloc, free, realloc — use internal heap locks
  • exit() — calls atexit handlers (non-reentrant)
  • syslog() — may use locks
  • Any pthread_* function (except a small subset)

The safe pattern: use the handler to set a volatile sig_atomic_t flag, then check the flag in your main loop.


volatile sig_atomic_t: The Communication Bridge

c
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdbool.h>

volatile sig_atomic_t g_running = 1;     // Main loop control
volatile sig_atomic_t g_reload_config = 0; // Config reload request

void sigterm_handler(int sig) {
    g_running = 0; // Signal the main loop to stop
}

void sighup_handler(int sig) {
    g_reload_config = 1; // Signal config reload
}

int main(void) {
    struct sigaction sa_term = { .sa_handler = sigterm_handler,
                                  .sa_flags   = SA_RESTART };
    sigemptyset(&sa_term.sa_mask);
    sigaction(SIGTERM, &sa_term, NULL);
    sigaction(SIGINT,  &sa_term, NULL);
    
    struct sigaction sa_hup = { .sa_handler = sighup_handler,
                                 .sa_flags   = SA_RESTART };
    sigemptyset(&sa_hup.sa_mask);
    sigaction(SIGHUP, &sa_hup, NULL);
    
    printf("Server running (PID %d). Send SIGTERM or Ctrl+C to stop.\n", getpid());
    
    while (g_running) {
        if (g_reload_config) {
            g_reload_config = 0;
            printf("Reloading configuration...\n");
            // do_reload_config();
        }
        // Normal server work
        sleep(1);
    }
    
    // Main loop exited cleanly — perform graceful shutdown
    printf("Shutting down gracefully...\n");
    // cleanup_connections();
    // flush_write_buffers();
    return 0;
}

Why volatile sig_atomic_t?

  • volatile: Prevents the compiler from caching the variable in a register — the main loop reads it from memory on every iteration.
  • sig_atomic_t: Guaranteed to be read and written atomically on all platforms — no partial reads where a signal could interrupt between loading the high byte and low byte of the value.

Graceful Server Shutdown Pattern

Production servers must clean up on SIGTERM — close client connections, flush write buffers, save state:

c
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static volatile sig_atomic_t g_stop = 0;
static void            *g_cleanup_ctx = NULL;

void signal_handler(int sig) {
    g_stop = 1;
}

void setup_graceful_shutdown(void) {
    struct sigaction sa = {
        .sa_handler = signal_handler,
        .sa_flags   = 0, // Don't restart syscalls — let accept() return EINTR
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
}

int run_server(int server_fd) {
    setup_graceful_shutdown();
    
    while (!g_stop) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) {
            if (g_stop) break; // accept() interrupted by our signal — expected
            perror("accept");
            continue;
        }
        handle_client(client_fd);
        close(client_fd);
    }
    
    // Here: g_stop == 1, clean up resources
    printf("Graceful shutdown: closing %d active connections\n", active_connections);
    // close_all_connections();
    // write_shutdown_log();
    return 0;
}

Note: SA_RESTART is intentionally NOT set here — we want accept() to return EINTR when the signal arrives, allowing the while loop condition (!g_stop) to be checked.


Crash Handler for SIGSEGV

c
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void crash_handler(int sig) {
    // Write crash info using only signal-safe write()
    const char *msg;
    if (sig == SIGSEGV) msg = "CRASH: Segmentation fault (SIGSEGV)\n";
    else if (sig == SIGBUS) msg = "CRASH: Bus error (SIGBUS)\n";
    else if (sig == SIGFPE) msg = "CRASH: Arithmetic error (SIGFPE)\n";
    else msg = "CRASH: Unknown signal\n";
    
    write(STDERR_FILENO, msg, strlen(msg)); // strlen is signal-safe
    
    // Flash flush any critical logs (platform specific)
    // fsync(log_fd); // If log_fd was opened before the crash
    
    // Re-raise with default handler to generate core dump
    signal(sig, SIG_DFL);
    raise(sig);
}

void install_crash_handlers(void) {
    struct sigaction sa = {
        .sa_handler = crash_handler,
        .sa_flags   = SA_RESETHAND, // Reset to default after first invocation
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGBUS,  &sa, NULL);
    sigaction(SIGFPE,  &sa, NULL);
}

[!CAUTION] When SIGSEGV fires, your program's memory may already be corrupted. The only truly safe actions are write(), _exit(), and re-raising with signal(sig, SIG_DFL) + raise(sig). Don't attempt complex cleanup.


Blocking Signals with sigprocmask

Sometimes you need to protect a critical section from being interrupted by signals:

c
#include <signal.h>

void critical_section(void) {
    sigset_t block_set, old_set;
    
    // Define which signals to block
    sigemptyset(&block_set);
    sigaddset(&block_set, SIGTERM);
    sigaddset(&block_set, SIGINT);
    
    // Block them, save the old mask
    sigprocmask(SIG_BLOCK, &block_set, &old_set);
    
    // === Critical section — signals queued but not delivered ===
    commit_transaction_to_disk();
    // === End critical section ===
    
    // Restore old mask — queued signals delivered now
    sigprocmask(SIG_SETMASK, &old_set, NULL);
}

This pattern ensures that a database transaction write is never interrupted mid-flight by a shutdown signal.


Frequently Asked Questions

Can I call exit() from a signal handler? exit() is NOT async-signal-safe because it calls atexit() handlers and flushes stdio buffers. Use _exit() (no cleanup) or set a flag and let the main function call exit(). For crash handlers, _exit(1) after logging is acceptable.

Is SIGKILL really unblockable? Yes — SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. They are implemented entirely in the kernel. When the OOM (Out-of-Memory) killer sends SIGKILL, your process has no opportunity to run any more code.

How do daemons handle SIGHUP for configuration reload? The convention: send SIGHUP to request a config reload without restarting. The handler sets a g_reload_config flag. The main loop detects the flag, re-reads the config file, and updates internal state — all without losing active connections. nginx -s reload sends SIGHUP to the master process.

Are signal handlers thread-safe in multi-threaded programs? Signal handlers are tricky in multi-threaded programs. A signal is delivered to one arbitrary thread (unless directed with pthread_kill). The recommended approach: use sigprocmask in all threads to block signals, then dedicate one thread to handle signals via sigwaitinfo or signalfd — this converts asynchronous signal delivery into synchronous I/O.


Key Takeaway

Signal handling is what makes C programs behave as Real Citizens of the Operating System rather than isolated scripts. By responding correctly to SIGTERM (clean shutdown), SIGHUP (reload config), and SIGSEGV (last-gasp crash report), you build production-quality software that interacts properly with system administrators, deployment tools (systemd, Docker), and monitoring systems.

Read next: C23 New Features: Modern C Evolution →


Part of the C Mastery Course — 30 modules from C basics to expert systems engineering.