Skip to content

Catching a signal — std has none, and the handler may do almost nothing

Level: 301 · deep dive

One line: There is no std::signal, so Ctrl-C in a Rust program does what the kernel says by default — the process ends, with no unwinding and no Drop — and catching it means declaring signal from libc yourself, in a handler that may not print, allocate or lock.

Press Ctrl-C at a Python program and you get a traceback, because Python installed a SIGINT handler before your first line ran. Press it at a Rust program and you get nothing: no message, no stack, no destructors. The difference is not a design disagreement about interruption — it is that Rust's standard library has no signal API at all, so the kernel's default is what you get.

The default action for SIGINT is to end the process, and end here is not std::process::exit. No Drop impl runs, no buffered writer is flushed, no lock guard releases. A program half-way through writing a file leaves it half-written.

The whole setup is a declaration

std already links the platform C library, so getting at signal(3) needs no crate, no build script and no C compiler — the same three lines of setup any other libc call needs:

use std::ffi::c_int;

unsafe extern "C" {
    fn signal(sig: c_int, handler: usize) -> usize;
    fn raise(sig: c_int) -> c_int;
}

const SIGINT: c_int = 2;
const SIG_DFL: usize = 0;   // the kernel's default action: die
const SIG_IGN: usize = 1;   // discard it before anyone is woken

The handler argument is a machine word holding one of three things: 0, 1, or the address of a function. That overloading is C's, not Rust's, and it is why the parameter is a usize rather than an Option<extern "C" fn(c_int)> — 0 and 1 are not valid function pointers. signal hands back whatever was installed before, which is the only record of it that exists.

The handler itself is an extern "C" fn, and it is where the interesting restriction is:

use std::sync::atomic::{AtomicBool, Ordering};

static INTERRUPTED: AtomicBool = AtomicBool::new(false);

extern "C" fn on_sigint(_signum: c_int) {
    INTERRUPTED.store(true, Ordering::SeqCst);
}

One atomic store, and that is the whole function. Not because the example is small — because that is close to the entire list of what a signal handler is allowed to do.

The handler is a C signal handler, with everything that implies

Python's handler is not the C handler. CPython's C handler sets a flag and returns; your Python function is called afterwards, from the main thread, at a bytecode boundary, where the whole language works and print is fine.

Rust has no such layer. on_sigint is the C handler. It runs at whatever instruction the process had reached, on whichever thread the kernel chose, possibly in the middle of a malloc or while Stdout's lock is held. The rule that follows is C's rule — only async-signal-safe calls — and it rules out most of what a Rust programmer reaches for:

In the handler Why not
println! takes the Stdout lock; the signal may have arrived while main was holding it
format!, vec![], String::from ↗ allocates, and the allocator has locks of its own
Mutex::lock the same deadlock, spelled out
Atomic*::store ↗ lock-free, and what the handler above does

The failure mode is a deadlock, not a compile error. The signal arrives while main holds the Stdout lock, the handler asks for that same lock on that same thread, and the process stops there — not always, only when the timing lands, which is the worst schedule for finding it. Nothing in the type system marks a function as a signal handler, so nothing checks any of this. unsafe bought you the call across the ABI; it did not buy you a check on what the callee does.

So the idiom is a note and a reader. The handler sets the flag; ordinary code polls it and does the work, back where locking and allocating are allowed again.

The verified output

Every signal below is delivered on purpose with raise(3), so nothing waits for a key:

Verified output of catching_a_signal.rs — regenerated by tools/run_examples.py, never hand-typed.

1. What std offers
--------------------------------------------------------------
std::process............................ exit, abort, Command
std::thread............................. park, unpark, sleep
a way to hear about SIGINT.............. none

There is no std::signal. Ctrl-C is therefore whatever the kernel
does by default: the process ends, no unwinding, no Drop, and no
destructor for the file you were half-way through writing.

2. Installing a handler through the C ABI
--------------------------------------------------------------
signal(SIGINT, on_sigint) returned...... 0
...which is SIG_DFL..................... true

SIG_DFL is 0 and SIG_IGN is 1; anything else is a function
pointer. That return value is the only way back to what was there,
and nothing keeps a copy for you.

3. A real SIGINT, delivered to ourselves
--------------------------------------------------------------
INTERRUPTED before...................... false
raise(SIGINT) returned.................. 0
INTERRUPTED after....................... true
the number the handler was passed....... 2

The handler ran, returned, and execution carried on at the next
line. A handler that returns resumes the code it interrupted: it is
a call, not an unwind, so nothing was dropped on the way through.

4. The flag is the program, not the handler
--------------------------------------------------------------
round 1................................. working
round 2................................. working
round 3................................. saw the flag, shutting down

This is the whole Rust idiom. The handler sets an atomic; the work
happens back in ordinary code, where allocating, locking and
printing are allowed again. Everything a Python handler may do
directly, a Rust handler has to ask for by leaving a note.

5. What the handler may not do
--------------------------------------------------------------
It is a C signal handler, so the rule is C's rule: only
async-signal-safe calls. That list is short, and nothing which
locks or allocates is on it.

    println!(...)....................... takes the Stdout lock
    String::from, vec![], format!....... allocates
    Mutex::lock......................... locks
    an atomic store..................... fine, and is all this one does

The failure is a deadlock, not a compile error: the signal arrives
while main holds the Stdout lock, the handler asks for the same
lock on the same thread, and the process stops there. Nothing in
the type system says a fn is a signal handler, so nothing checks it.

6. Ignoring, and putting the default back
--------------------------------------------------------------
signal(SIGINT, SIG_IGN) returned........ a function pointer
...neither SIG_DFL nor SIG_IGN.......... true
handler calls before a raise............ 2
handler calls after it.................. 2

SIG_IGN is the kernel discarding the signal before anyone is woken.

signal(SIGINT, SIG_DFL) returned........ 1, which is SIG_IGN

SIGINT is back to ending the process. Raising it now would stop this
program with no further output, which is why this line is the last.

Section 4 is the pattern worth taking away: four rounds of a loop, a SIGINT raised during the third, and the shutdown happening after the handler returned, in code that may do whatever it likes. Section 6 shows the third option — SIG_IGN, where the kernel discards the signal and the handler count does not move.

Three signals this cannot catch, and one it must not

SIGKILL and SIGSTOP cannot be caught, blocked or ignored by any process in any language; signal() refuses them, and so does every shell ↗ — which accepts trap … KILL and then quietly never runs it. That is deliberate — it is what guarantees an administrator can always end a process, however badly it is behaving. The Rust-specific one is SIGSEGV: a handler for it can run, but the state it runs in is one the language has no rules for, and returning from it resumes the instruction that faulted.

If you are coming from another language

Python is the sharp contrast, and it is worth reading beside Ctrl-C is a signal ↗ in the Python library. Python ships a handler installed before your code runs, signal.default_int_handler, whose whole job is to raise KeyboardInterrupt — so the exception you catch is a library decision, not a kernel one, and signal.signal() takes it away. Two further differences follow from CPython's deferral: a Python handler always runs in the main thread whichever thread received the signal, and a syscall interrupted by one is retried rather than cut short (PEP 475 ↗), so time.sleep(0.25) interrupted at 0.05s still sleeps the full quarter second. Rust's handler runs on the interrupted thread and read comes back with EINTR for you to deal with. The trade is the usual one: Python gives you a handler you can write normally and charges latency and a global, process-wide side effect; Rust gives you the real thing and charges a rule the compiler cannot enforce.

C is where this API comes from, and the Rust version is the same call with one edition-2024 formality: the extern block must be unsafe extern, because the declaration is the promise — nothing compares your signature against libc's. C programmers should carry across one habit that Rust does not change: sig_atomic_t for the flag, which becomes AtomicBool here, and for the same reason. Prefer sigaction(2) over signal(2) in production code on both sides; signal is used here because it is one line and the semantics it varies on (whether the handler stays installed) are not what the page is about.

Go made the opposite choice to both: os/signal.Notify turns a signal into a value on a channel, so the handler problem disappears — the receiving goroutine is ordinary code, and there is no restricted context to get wrong. That is the design most people reach for in Rust too, by way of a crate; the AtomicBool here is the same shape without the crate.

ABAP has no counterpart. A report runs in a work process with no controlling terminal; the interruptions it gets are the runtime's TIME_OUT, a user pressing Cancel, or an administrator in SM50, and none of them is deliverable to a point in your code that you choose. The transferable half is the discipline rather than the API — work that must survive an interruption is committed in units small enough to resume.

See also

  • Calling C — the unsafe extern block, what edition 2024 moved, and why the call is free but the data is not.
  • What unsafe turns off — the five things it permits, and the much longer list it does not stop checking.
  • RwLock and atomics — what Ordering::SeqCst is choosing, and when a weaker one is honest.
  • Lock poisoning — the lock the handler above must not touch.
  • Ctrl-C is a signal ↗ — the same program in Python, where the default handler is the whole lesson.
  • The signals you cannot catch ↗ — the same three rules from the shell's side: trap, exit status 128 + N, and the third way a handler silently never runs.
  • Signals ↗ — signals as inter-process communication, and which thread receives one.
  • signal(2) ↗ and signal-safety(7) ↗ — the second is the async-signal-safe list itself, and it is shorter than most people expect.

Po polsku

W bibliotece standardowej Rusta nie ma obsługi sygnałów — i to jest cała pierwsza połowa tej lekcji. Nie ma std::signal, nic w std::process nie instaluje procedury obsługi, więc Ctrl-C robi to, co mówi jądro: proces się kończy. Bez odwijania stosu, bez Drop, bez opróżnienia bufora. Plik pisany w tym momencie zostaje napisany do połowy. Polskie kursy Rusta rzadko o tym mówią, bo temat wygląda na uniksowy, a nie językowy — tyle że właśnie ta nieobecność jest decyzją projektową i widać ją dopiero wtedy, gdy porówna się z Pythonem, gdzie KeyboardInterrupt bierze się z procedury zainstalowanej, zanim uruchomiła się pierwsza linijka twojego kodu.

Druga połowa jest ciekawsza. Żeby sygnał przechwycić, deklarujesz signal z libc samodzielnie — żadnej biblioteki zewnętrznej nie trzeba, bo std i tak linkuje bibliotekę C. Argument handler jest zwykłym słowem maszynowym mieszczącym trzy różne rzeczy: 0 (SIG_DFL, domyślne działanie jądra), 1 (SIG_IGN, odrzucenie sygnału) albo adres funkcji. To przeciążenie pochodzi z C i dlatego parametr jest usize, a nie Option<extern "C" fn(c_int)>.

Najważniejsze zdanie: to jest prawdziwa procedura obsługi sygnału w rozumieniu C. W Pythonie funkcja, którą piszesz, jest wywoływana później, w wątku głównym, na granicy bajtkodu — więc print w środku jest zupełnie w porządku. W Ruscie nie ma tej warstwy pośredniej: twoja funkcja wykonuje się w dowolnym miejscu programu, w wątku wybranym przez jądro, być może w środku malloc albo gdy main trzyma blokadę Stdout. Obowiązuje więc reguła z C — wolno wołać tylko funkcje async-signal-safe — a to wyklucza println!, format!, vec![] i Mutex::lock. Zostaje zapis do zmiennej atomowej, i na tym polega cały idiom: procedura zostawia karteczkę (AtomicBool), a zwykły kod ją odczytuje i dopiero tam robi robotę.

Objaw błędu jest przy tym paskudny, bo to zakleszczenie, a nie błąd kompilacji: sygnał przychodzi, gdy main trzyma blokadę strumienia, procedura prosi o tę samą blokadę w tym samym wątku i proces staje. Zdarza się tylko przy pewnym rozłożeniu w czasie, czyli najgorzej, jak się da. Nic w systemie typów nie oznacza funkcji jako procedury obsługi sygnału, więc nic tego nie sprawdza — unsafe kupiło ci wywołanie przez granicę ABI, nie kontrolę nad tym, co robi wywoływany kod.

Na koniec dwa sygnały, których nie złapie żaden proces w żadnym języku: SIGKILL i SIGSTOP. signal() ich po prostu odmawia, i to jest celowe — dzięki temu administrator zawsze może zakończyć proces, niezależnie od tego, jak bardzo ten się stawia.

Szukaj po polsku: obsługa sygnałów w Ruscie · rust signal handler · rust ctrlc SIGINT · funkcje async-signal-safe · AtomicBool Ordering::SeqCst · unsafe extern C 2024