[C] Safe Signal Handling: The Self-Pipe Trick

When developing servers or daemons in C/C++ on Linux/Unix environments, you’ll inevitably face a tricky challenge: signals. Whether it’s terminating a program with Ctrl + C (SIGINT) or directing specific behavior with the kill command, signals are powerful asynchronous events from outside the process.

The problem is that for all their power, handling them safely is notoriously difficult. Have you ever used printf or malloc inside a signal handler, only to have your program inexplicably hang or crash? This happens when you overlook the concept of async-signal-safety.

In this post, we’ll explore why signal handlers are so risky and implement a robust, reusable signal handling module in C using a classic technique to solve this problem: the ‘Self-Pipe Trick’.

What’s the Problem? Async-Signal-Safety

A signal handler can interrupt your program’s normal execution at any time. What happens if a signal arrives at the exact moment your main function has called malloc and acquired a lock on the heap memory?

  1. The main function acquires a heap memory lock inside malloc.
  2. A signal occurs, and the OS suspends main’s execution to call the registered signal handler.
  3. If the signal handler then calls printf or malloc (perhaps to log a message), these functions also attempt to acquire the same internal heap memory lock.
  4. But since the main function already holds the lock, the signal handler gets stuck waiting forever. This is called a deadlock.

Because a handler can interrupt anywhere, you must only use functions inside it that are guaranteed to be safe even if interrupted and re-entered—in other words, functions that are async-signal-safe. Only a handful of system calls, like write, read, close, and _exit, fall into this category. Most standard library functions we use daily, such as printf, malloc, free, and fopen, are not safe.

The Solution: The Self-Pipe Trick

So, how should we handle signals that require complex processing? The answer is to do the absolute minimum in the signal handler and defer the actual work to the safe context of the main program. The Self-Pipe Trick is the most common way to implement this idea.

Here’s how it works:

  1. Create a Pipe: At program startup, create a pipe that the process can use to write to itself. A pipe consists of a pair of file descriptors: one for reading (fd[0]) and one for writing (fd[1]).
  2. Register a Minimal Handler: The actual signal handler does only one thing: it writes the signal number to the write-end of the pipe (fd[1]). Since write is an async-signal-safe function, this is a safe operation.
  3. Monitor in an Event Loop: The main program uses an I/O multiplexing function like poll, select, or epoll to monitor the read-end of the pipe (fd[0]). When a signal occurs and the handler writes to the pipe, poll notifies the program that the file descriptor is ready to be read.
  4. Process Safely: The main loop reads the signal number from the pipe and calls the appropriate, pre-defined callback function to handle the signal. This entire process happens within the safe context of the main program, not the signal handler’s context.

Effectively, this transforms an asynchronous signal event into a synchronous file descriptor I/O event, allowing you to handle it consistently alongside other events like network sockets.

A Reusable C Implementation

Now, let’s look at a reusable signal handling module designed around this concept.

1. Header File: signal_handler.h

First, we define the public API. A user should be able to understand the library’s functionality just by looking at this header file.

#ifndef SIGNAL_HANDLER_H
#define SIGNAL_HANDLER_H

#include <signal.h>

// A function pointer type for custom signal handlers.
typedef void (*SignalHandler)(int signo);

// Initializes the signal handling mechanism.
int signal_init(void);

// Frees resources used by the signal handler.
void signal_fini(void);

// Sets the action for a specific signal (signo).
int signal_set_action(int signo, SignalHandler handler);

// Returns the read file descriptor of the pipe to monitor for signal events.
int signal_get_fd(void);

// Reads signals from the pipe and calls the registered handlers.
int signal_dispatch(void);

#endif // SIGNAL_HANDLER_H

2. Implementation File: signal_handler.c

This is the implementation containing the core logic of the Self-Pipe Trick.

#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include "signal_handler.h"

static SignalHandler signal_handlers[NSIG];
static int pipe_fds[2] = {-1, -1};

// [Core] The master handler that only writes the signal number to the pipe.
static void master_signal_handler(int signo, siginfo_t *info, void *context) {
    (void)info;
    (void)context;
    // Only use the async-signal-safe function write().
    ssize_t written;
    do {
        written = write(pipe_fds[1], &signo, sizeof(signo));
    } while (written == -1 && errno == EINTR);
}

int signal_init(void) {
    if (pipe2(pipe_fds, O_CLOEXEC | O_NONBLOCK) == -1) {
        perror("pipe2");
        return -1;
    }
    for (int i = 0; i < NSIG; ++i) {
        signal_handlers[i] = NULL;
    }
    return 0;
}

void signal_fini(void) {
    if (pipe_fds[0] != -1) close(pipe_fds[0]);
    if (pipe_fds[1] != -1) close(pipe_fds[1]);
}

int signal_set_action(int signo, SignalHandler handler) {
    if (signo < 1 || signo >= NSIG) return -1;

    signal_handlers[signo] = handler;
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sigemptyset(&sa.sa_mask);

    if (handler == SIG_DFL || handler == SIG_IGN) {
        sa.sa_handler = handler;
        sa.sa_flags = 0;
    } else {
        sa.sa_sigaction = master_signal_handler;
        sa.sa_flags = SA_SIGINFO | SA_RESTART;
    }

    if (sigaction(signo, &sa, NULL) == -1) {
        perror("sigaction");
        return -1;
    }
    return 0;
}

int signal_get_fd(void) {
    return pipe_fds[0];
}

int signal_dispatch(void) {
    int signo;
    ssize_t bytes_read;
    int dispatched_count = 0;

    while ((bytes_read = read(pipe_fds[0], &signo, sizeof(signo))) > 0) {
        if (bytes_read == sizeof(signo)) {
            SignalHandler handler = signal_handlers[signo];
            if (handler && handler != SIG_DFL && handler != SIG_IGN) {
                handler(signo);
                dispatched_count++;
            }
        }
    }
    return dispatched_count;
}

3. Example Usage: main.c

Now, let’s write a main application using our module. You can see how we use poll to wait for signal events.

#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#include <stdlib.h>
#include "signal_handler.h"

volatile sig_atomic_t running = 1;

// Custom handler for SIGINT (Ctrl+C).
void handle_sigint(int signo) {
    printf("\nCaught signal %d (SIGINT). Shutting down...\n", signo);
    running = 0;
}

// Custom handler for SIGTERM.
void handle_sigterm(int signo) {
    printf("\nCaught signal %d (SIGTERM). Shutting down gracefully...\n", signo);
    running = 0;
}

int main(void) {
    printf("Starting... PID: %d\n", getpid());

    if (signal_init() != 0) {
        return EXIT_FAILURE;
    }

    signal_set_action(SIGINT, handle_sigint);
    signal_set_action(SIGTERM, handle_sigterm);
    signal_set_action(SIGPIPE, SIG_IGN); // Ignore SIGPIPE

    struct pollfd pfd;
    pfd.fd = signal_get_fd();
    pfd.events = POLLIN;

    while (running) {
        printf("Waiting for a signal or other events...\n");
        int ret = poll(&pfd, 1, -1); // Wait indefinitely

        if (ret > 0 && (pfd.revents & POLLIN)) {
            printf("Signal event detected. Dispatching...\n");
            signal_dispatch();
        }
    }

    printf("Cleaning up and exiting.\n");
    signal_fini();
    return EXIT_SUCCESS;
}

In Conclusion

The Self-Pipe Trick is a powerful and proven method for handling signals safely. By using this technique, we gain several advantages:

  • Safety: It prevents deadlocks and race conditions caused by calling non-async-signal-safe functions.
  • Integration: It allows you to treat signal events just like I/O events, integrating them cleanly into a complex application’s event loop.
  • Simplicity: It drastically simplifies the role of the signal handler, making the code more predictable.

Of course, modern Linux kernels also offer signalfd(), a more convenient alternative that implements this trick at the kernel level. However, understanding the principles of the Self-Pipe Trick is a fundamental building block for designing robust software that works across all POSIX systems.

May your next project be one step more robust with safe signal handling.