Linux System Programming · intermediate · ~10 min
**What you will learn** - How pressing **Ctrl+C** in a terminal turns into the **SIGINT** signal delivered to your process. - How to install a SIGINT handler with `sigaction()` and write a handler that does *only* one safe thing: set a flag. - Why your main loop must poll a `volatile sig_atomic_t` flag and perform cleanup *outside* the handler. - How to implement a two-strike policy: first Ctrl+C requests a graceful shutdown, second Ctrl+C force-quits. - How to restore the default disposition (`SIG_DFL`) and re-raise so the kernel terminates with the correct exit status. - How `EINTR` (interrupted system calls) interacts with signal handling, and how to handle it.
When you run a command-line program and press Ctrl+C, it usually stops instantly. That is not magic and it is not the shell killing the program directly. The terminal driver translates the keystroke into a signal named SIGINT ("signal interrupt") and the kernel delivers it to every process in the foreground process group. The default action for SIGINT is to terminate the process. Your program can change that default and decide for itself how to respond.
This lesson is about replacing "die instantly" with "shut down gracefully". A long-running program often holds resources the operating system will not perfectly clean up for you in the way you want: a log buffer that has not been flushed to disk, a temporary file that should be deleted, a network connection that should be closed politely, or a lock file shared with other programs. If the process is killed mid-write, you can lose data or leave the system in a half-finished state. Graceful shutdown means: notice the interrupt, stop taking new work, finish or abandon the current step safely, release everything, then exit.
This builds directly on the prerequisite Writing safe signal handlers — async-signal-safe rules. That lesson established the central constraint: a signal can interrupt your program at any machine instruction, even in the middle of malloc() or printf(). So a handler may only call a small whitelist of async-signal-safe functions and may only touch variables of type volatile sig_atomic_t. Here we apply that rule to one specific, extremely common signal — SIGINT — and to one specific pattern: the flag-and-poll model of graceful shutdown.
A few terms you will see throughout:
SIG_DFL / SIG_IGN: the default action, and "ignore", respectively.Almost every serious long-running program — a database, a web server, a build tool, a file-sync daemon, a video encoder — needs to handle interruption cleanly. The difference between a professional tool and a fragile one is often what happens when it is stopped halfway.
Concrete consequences of getting this wrong:
fsync() before exiting, or roll back to a known-good state./var/run/app.pid, a database lock, an advisory flock) and is killed instantly, the lock may persist and block the next run. Cleanup on SIGINT removes it.apt, git, and docker print a message and unwind safely on Ctrl+C instead of leaving a mess. Users trust software that stops predictably.SIG_DFL and re-raising matters.Getting signal handling subtly wrong is also a classic source of bugs that only appear under load or at the worst possible moment, because the handler runs at unpredictable times. Doing it correctly is a hallmark of robust systems code.
Pressing Ctrl+C does not send bytes to your program's stdin. The terminal line discipline (the kernel layer between the keyboard and your program) is configured so that the INTR character — Ctrl+C by default — is intercepted and converted into a SIGINT sent to the foreground process group.
[keyboard] Ctrl+C
|
v
+-------------------+ recognizes INTR char
| terminal driver |-----------------------+
| (line discipline) | |
+-------------------+ v
kernel: deliver SIGINT to
every process in the
foreground process group
|
v
your process: run the
installed handler (or default)
When to care: any interactive terminal program. When it does not apply: a daemon with no controlling terminal will not receive SIGINT from a keystroke (it is usually stopped with SIGTERM instead).
Knowledge check: If three programs are connected by a pipe (a | b | c) and you press Ctrl+C, which ones receive SIGINT? (Answer: all of them — they are in the same foreground process group.)
Every signal has a current disposition for your process. For SIGINT the three choices are:
| Disposition | Constant | Effect |
|---|---|---|
| Default | SIG_DFL |
Terminate the process |
| Ignore | SIG_IGN |
SIGINT is discarded; Ctrl+C does nothing |
| Handle | a function pointer | Your handler runs |
You install a handler to switch from default to "handle". You restore SIG_DFL when you want the kernel's default termination back (for example, on a second Ctrl+C).
Pitfall: setting the disposition to SIG_IGN makes Ctrl+C completely dead — there is no way for the user to interrupt. Use SIG_IGN only deliberately and briefly (e.g. around a critical section), never as a lazy substitute for a handler.
The core idea: the handler records that a signal happened; the main code reacts to it.
main loop signal handler
--------- --------------
while (!stop) { on Ctrl+C:
do_one_unit_of_work(); <-- stop = 1; (that's all)
if (stop) break; (returns immediately)
}
cleanup(); // flush, close, unlock
return;
The flag must be declared volatile sig_atomic_t:
sig_atomic_t is an integer type guaranteed to be read and written in a single, uninterruptible step. A normal int could in theory be updated in two machine instructions, and a signal arriving between them would see a torn value.volatile tells the compiler the variable can change behind its back (in the handler), so it must re-read it from memory on every loop check instead of caching it in a register. Without volatile, the compiler may optimize while (!stop) into an infinite loop that never re-reads stop.When to use: essentially always, for cooperative shutdown. When NOT to: if you need to wait blocked for a signal rather than spin-polling, prefer sigsuspend() or a self-pipe / signalfd (covered later in the track) so you do not burn CPU.
Knowledge check: Why is static int stop = 0; (a plain int) not safe here, even on a machine where int writes are atomic? (Answer: the compiler may cache stop in a register across loop iterations because nothing in the visible loop modifies it; without volatile the change made in the handler is never observed.)
sigaction() vs signal()You install the handler with sigaction(), not the older signal(). signal() has portability quirks — on some historical systems it reset the disposition to SIG_DFL after the first delivery, and its restart behavior is unspecified. sigaction() gives you explicit, portable control over:
sa_handler — the function to call.sa_mask — additional signals to block while the handler runs, so the handler is not itself interrupted by other signals.sa_flags — options such as SA_RESTART (auto-restart certain interrupted system calls).Pitfall: forgetting sigemptyset(&sa.sa_mask) leaves sa_mask uninitialized (garbage), which can block random signals. Always initialize it.
EINTR: interrupted system callsWhen a signal is handled while your program is blocked in a slow system call (read(), write(), sleep(), accept(), ...), the call may return early with errno == EINTR. This is expected, not an error.
You have two choices:
SA_RESTART so the kernel transparently restarts the call. (Note: not all calls restart even with this flag.)EINTR yourself and either retry or treat it as a shutdown cue.For a graceful-shutdown loop, an EINTR from sleep() or read() is often exactly the wake-up you want — you fall out, see the flag is set, and exit.
Knowledge check: You call read(), the user hits Ctrl+C, your handler sets stop = 1, and read() returns -1 with errno == EINTR. Is this a bug? (Answer: no — it is the normal interaction. Check your flag and proceed to shutdown, or retry the read if the flag is not set.)
Installing a SIGINT handler with sigaction():
#include <signal.h>
static volatile sig_atomic_t stop = 0; /* the ONLY state the handler touches */
static void handle_sigint(int sig) { /* signature is fixed: void f(int) */
(void)sig; /* silence "unused parameter" warning */
stop = 1; /* one atomic write, nothing else */
}
int install(void) {
struct sigaction sa;
sa.sa_handler = handle_sigint; /* function to run on SIGINT */
sigemptyset(&sa.sa_mask); /* block no extra signals during handler */
sa.sa_flags = 0; /* 0 => system calls may return EINTR */
return sigaction(SIGINT, &sa, NULL); /* returns 0 on success, -1 on error */
}
Restoring the default and re-raising (used for the second Ctrl+C):
signal(SIGINT, SIG_DFL); /* disposition back to "terminate" */
raise(SIGINT); /* send SIGINT to self -> kernel kills us */
Key points: the handler type is always void handler(int); sigaction reports failure with -1 and sets errno; passing NULL as the third argument means "I don't need the previous action saved".
When the user presses Ctrl+C, the operating system sends your program a signal called SIGINT (interrupt). By default this stops the program immediately.
Often you want something gentler. You want the program to finish what it is doing safely before it exits:
Then exit cleanly.
sigaction(). A handler is a function the OS calls when the signal arrives.volatile sig_atomic_t stopping = 1;. Nothing else.if (stopping) break;.return from main.A signal can arrive at almost any moment. Doing real work inside the handler is unsafe. So the handler just records that a signal happened. The main code reacts to it at a safe point.
If cleanup hangs, the user needs a way out. When they press Ctrl+C a second time, they want to quit NOW.
On the second SIGINT, restore the default handler so the next press kills the program immediately:
signal(SIGINT, SIG_DFL); /* second Ctrl+C terminates immediately */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
/* Two flags, both async-signal-safe integer types.
stop_requested: set on the first Ctrl+C (graceful shutdown).
sigint_count : how many times Ctrl+C was pressed (force-quit policy). */
static volatile sig_atomic_t stop_requested = 0;
static volatile sig_atomic_t sigint_count = 0;
/* The handler does the minimum: bump the counter and, on the second
press, restore the default and re-raise so the kernel terminates us
with the conventional signal exit status. raise() is async-signal-safe. */
static void handle_sigint(int sig) {
sigint_count++;
if (sigint_count >= 2) {
signal(sig, SIG_DFL); /* next/instant SIGINT terminates normally */
raise(sig); /* force-quit now */
return;
}
stop_requested = 1; /* ask the main loop to wind down cleanly */
}
static int install_handler(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa); /* zero every field, including sa_flags */
sa.sa_handler = handle_sigint;
if (sigemptyset(&sa.sa_mask) != 0) return -1;
sa.sa_flags = 0; /* leave SA_RESTART off so sleep() returns on signal */
return sigaction(SIGINT, &sa, NULL);
}
int main(void) {
if (install_handler() != 0) {
perror("sigaction");
return 1;
}
/* Pretend we hold real resources that must be released cleanly. */
FILE *log = fopen("work.log", "w");
if (!log) {
perror("fopen");
return 1;
}
printf("Working... press Ctrl+C once for graceful shutdown,\n");
printf("twice to force-quit.\n");
int unit = 0;
while (!stop_requested) {
unit++;
fprintf(log, "completed unit %d\n", unit); /* simulate real work */
if (sleep(1) != 0 && errno == EINTR) {
/* sleep was interrupted by the signal; loop test handles the rest */
}
}
/* Cleanup happens HERE, in normal code, never inside the handler. */
fprintf(stderr, "\nGraceful shutdown: flushing and closing after %d units\n", unit);
if (fflush(log) != 0) perror("fflush");
if (fclose(log) != 0) perror("fclose"); /* close => release the file resource */
return 0;
}
What it does: installs a SIGINT handler, then loops doing one "unit of work" per second and logging it. The first Ctrl+C sets stop_requested, the loop falls out, and the program flushes and closes its log file before exiting 0. A second Ctrl+C (if you press it before shutdown finishes) restores the default disposition and re-raises SIGINT, so the kernel terminates the process immediately.
Expected output (you press Ctrl+C once after a few seconds):
Working... press Ctrl+C once for graceful shutdown,
twice to force-quit.
^C
Graceful shutdown: flushing and closing after 3 units
and work.log contains completed unit 1, completed unit 2, completed unit 3.
Edge cases: if Ctrl+C arrives during sleep(), the call returns early with EINTR — that is fine, the loop simply re-checks the flag. If the user presses Ctrl+C twice rapidly, the force-quit path runs and the log may be left without a final flush — that is the intended trade-off of "force quit now".
stop_requested / sigint_count are declared volatile sig_atomic_t. volatile forces the loop to re-read them from memory each time; sig_atomic_t guarantees each read/write is a single uninterruptible operation.handle_sigint is the handler. On the first call it falls through to stop_requested = 1. On the second (sigint_count >= 2) it calls signal(sig, SIG_DFL) to restore the kernel default, then raise(sig) to deliver SIGINT to itself — the kernel then terminates the process. Both signal and raise are async-signal-safe.install_handler zeroes a struct sigaction, points sa_handler at the handler, empties sa_mask (no extra signals blocked during the handler), and leaves sa_flags = 0 so a blocked sleep() returns with EINTR rather than auto-restarting. sigaction(SIGINT, ...) activates it.main, we install the handler first and bail out via perror if it fails. Then we open work.log as a stand-in for a real resource that must be released.!stop_requested. Each pass increments unit, writes a line to the log, and sleeps one second. The sleep is what makes the program interruptible at a clean point.handle_sigint, which sets stop_requested = 1. If the program was sleeping, sleep() returns early. Control comes back to the while condition, which is now false, so the loop ends.fflush pushes buffered bytes to the OS, fclose writes any remainder and releases the file descriptor. This is exactly the work that would be unsafe inside the handler.return 0 exits cleanly.Trace of the flag through one run (Ctrl+C pressed during unit 3's sleep):
| Moment | stop_requested |
sigint_count |
What happens |
|---|---|---|---|
| start | 0 | 0 | loop begins |
| during unit 3 sleep | 0 | 0 | sleeping |
| Ctrl+C delivered | 1 | 1 | handler runs, sleep returns EINTR |
| loop condition re-checked | 1 | 1 | !stop_requested is false -> exit loop |
| cleanup | 1 | 1 | flush + close, return 0 |
1. Doing real work inside the handler.
/* WRONG */
static void handle_sigint(int sig) {
fprintf(stderr, "shutting down\n"); /* NOT async-signal-safe */
fclose(log); /* NOT async-signal-safe */
}
fprintf/fclose use internal locks and buffers; if the signal interrupted the same FILE mid-operation, you can deadlock or corrupt state. Fix: set a flag in the handler and do the printing/closing in main after the loop. Recognize it: intermittent hangs or garbled output only when the signal lands at a bad moment.
2. Forgetting volatile.
/* WRONG */
static sig_atomic_t stop = 0; /* no volatile */
while (!stop) { ... }
The optimizer may cache stop in a register and loop forever, never seeing the handler's write. Fix: static volatile sig_atomic_t stop = 0;. Recognize it: Ctrl+C "does nothing" in an optimized build but works at -O0.
3. Using a plain int for the flag. A non-atomic type can be read or written in pieces; a signal between the pieces sees a torn value. Fix: use sig_atomic_t.
4. Using signal() to install instead of sigaction(). signal()'s restart and reset behavior is not portable. Fix: install with sigaction(). (signal() is fine for the narrow job of resetting to SIG_DFL inside the force-quit path, which is what the example does.)
5. Leaving sa_mask uninitialized.
/* WRONG */
struct sigaction sa;
sa.sa_handler = handle_sigint;
/* sa.sa_mask is garbage here */
sigaction(SIGINT, &sa, NULL);
Uninitialized sa_mask may block arbitrary signals while the handler runs. Fix: memset(&sa, 0, sizeof sa) and/or sigemptyset(&sa.sa_mask).
6. Treating EINTR as a fatal error. Returning failure from main because read()/sleep() came back with EINTR is wrong — it is the normal effect of catching a signal. Fix: check errno == EINTR and either retry or use it as your shutdown cue.
Compiler errors / warnings
sigaction" — you forgot #include <signal.h>.struct sigaction has no member named sa_handler" — usually a missing include or a typo (sa_handler, not sa_handle).sig" — add (void)sig; in the handler.Runtime / behavioral problems
sigaction actually succeeded (it returns -1 on failure). Also confirm you did not set the disposition to SIG_IGN somewhere, and that the program is in the foreground (background jobs do not get keyboard SIGINT).-O0, hangs at -O2. You forgot volatile on the flag. This is the single most common cause.SIG_DFL and raise(SIGINT) instead of returning.sleep, read, poll) so the loop only wakes when there is work or a signal.Concrete debugging steps
errno of sigaction once at startup.strace -e trace=signal ./prog (Linux) to see SIGINT delivery and any EINTR returns.kill -INT <pid> — more repeatable than mashing Ctrl+C.echo $? right after the program ends.Questions to ask when it doesn't work: Did sigaction succeed? Is the flag volatile sig_atomic_t? Is there a blocking call inside the loop? Is the process in the foreground process group? Am I accidentally swallowing EINTR?
Signal handling is less about pointers and more about undefined behavior from unsafe operations at unpredictable times. For Ctrl+C handling specifically:
write, _exit, signal, raise, kill, ...). Calling printf, malloc, or anything that takes a lock is undefined behavior if the signal interrupted that same machinery. Keep the handler to flag-setting plus, at most, the SIG_DFL+raise force-quit dance.volatile sig_atomic_t for any variable shared between the handler and main. Anything wider or non-volatile risks torn reads/writes or the compiler optimizing the flag check away — both are observable bugs.struct sigaction. Reading uninitialized sa_mask/sa_flags is undefined behavior; memset it to zero before setting fields.static, so they outlive every call — correct.EINTR correctness, not memory, but easy to get wrong. Blocked I/O can return early; treating the short return as "end of data" or as a hard error can drop data or crash logic downstream. Always distinguish EINTR from a real error.sigaction, fopen, fflush, fclose) and clean up resources you acquired. The example closes its file on the normal path; if your program has multiple exit points, make sure each one releases what it holds.Where this shows up in real software
systemctl stop/docker stop) triggers a drain: stop accepting connections, finish in-flight requests, close sockets, then exit. The same flag-and-poll pattern drives it. (SIGTERM is usually the one to handle for daemons; SIGINT is its interactive sibling and the two are typically handled identically.)git, apt, tar, build systems) catch SIGINT to remove partial output files and print a clean "aborted" message instead of leaving corruption.Professional best-practice habits
Beginner rules:
volatile sig_atomic_t flag.sigaction, check its return value.Advanced rules:
signalfd (Linux) so a single poll()/select() loop can wait on both work and signals without spin-polling or EINTR headaches.SIG_DFL and re-raise rather than returning 0.alarm()); if cleanup exceeds it, force-quit. This prevents a graceful path from hanging forever.Beginner 1 — Catch and report.
Write a program that installs a SIGINT handler (via sigaction) which sets a volatile sig_atomic_t flag, loops printing "tick\n" once per second from main, and on Ctrl+C prints "caught SIGINT, exiting\n" and returns 0.
Requirements: handler sets only the flag; the message is printed from main, not the handler. Hint: poll the flag in the while condition. Concepts: sigaction, flag-and-poll, async-signal-safety.
Beginner 2 — Graceful resource cleanup.
Extend Beginner 1 to open a file at startup, write a counter to it each second, and on SIGINT flush and close the file before exiting. Verify the file contains every line up to the interrupt.
Requirements: check fopen/fclose return values; close on the normal exit path. Hint: fflush before fclose. Concepts: cleanup outside the handler, error checking.
Intermediate 1 — Two-strike force quit.
Implement the policy from the lesson: first Ctrl+C requests graceful shutdown; a second Ctrl+C (during cleanup) restores SIG_DFL and re-raises to terminate immediately. Make cleanup take ~5 seconds (a loop of sleep(1)) so you can actually press Ctrl+C twice.
Requirements: use sig_atomic_t counter; verify echo $? shows 130 after a double Ctrl+C. Hint: the force-quit logic lives in the handler. Concepts: SIG_DFL, raise, exit status.
Intermediate 2 — EINTR-aware read loop.
Write a program that read()s from stdin in a loop and counts bytes, but installs a SIGINT handler. When read() returns -1 with errno == EINTR, distinguish "signal asked us to stop" (flag set -> break and report total) from a spurious interruption (retry the read).
Requirements: do not treat EINTR as a fatal error. Input/output example: type some text, press Ctrl+C, program prints "read N bytes before interrupt\n". Hint: loop on the read when EINTR and flag not set. Concepts: EINTR, blocking I/O, signal interaction.
Challenge — Unified graceful shutdown with a self-pipe.
Build a small event loop that uses poll() on two file descriptors: stdin and the read end of a self-pipe. The SIGINT handler writes one byte to the pipe's write end (using write, which is async-signal-safe). When poll reports the pipe is readable, begin graceful shutdown. The program should never spin-poll and should handle both SIGINT and SIGTERM identically.
Requirements: set both pipe ends non-blocking; handler only does write; clean up both fds. Hint: create the pipe with pipe(), install the same handler for SIGINT and SIGTERM. Concepts: self-pipe trick, poll, async-signal-safe write, multi-signal handling. (Do not just poll a flag — the point is to block efficiently.)
The big idea: Ctrl+C delivers SIGINT, whose default action is to terminate. Install a handler so you can stop gracefully instead — flush buffers, close files, release locks, then exit.
The pattern to remember:
sigaction(SIGINT, ...), after memset-ing the struct and sigemptyset-ing the mask. Check the return value.volatile sig_atomic_t flag (async-signal-safety rules from the prerequisite lesson apply).SIG_DFL and raise(SIGINT) so the kernel force-quits with the conventional status.Most important syntax: volatile sig_atomic_t flag; for shared state, the fixed void handler(int) signature, and sigaction(SIGINT, &sa, NULL) to install.
Common mistakes to avoid: real work (printf, fclose) inside the handler; forgetting volatile; using a plain int; uninitialized sa_mask; and treating EINTR as a fatal error.
What to carry forward: the flag-and-poll model and the force-quit escape hatch generalize to SIGTERM and to robust daemon shutdown — the subject of the next lesson, Common mistakes with signals.