How to Handle Signals (Ctrl+C) in Rust (ctrlc crate)

Cli
Handle Ctrl+C in Rust by adding the ctrlc crate, setting a signal handler to flip an atomic flag, and checking that flag in your main loop to exit gracefully.

When Ctrl+C means more than "die"

You're running a script that processes thousands of images. Halfway through, you realize the output directory is wrong. You hit Ctrl+C. The program vanishes instantly. The last image is half-written garbage. A lock file remains, blocking the next run. Or worse, the program crashes with a panic because the signal interrupted a mutex unlock.

Signals are the operating system's way of saying "stop." If you don't handle them, "stop" means "die immediately and leave a mess." Rust gives you the tools to catch the signal, set a flag, and shut down gracefully. You just have to respect the rules of asynchronous interruption.

The signal problem

When you press Ctrl+C, the terminal sends a signal to your process. On Unix, this is SIGINT. On Windows, it's a console control event. The OS delivers this signal asynchronously. It can interrupt your code at almost any instruction.

This breaks Rust's mental model. Rust assumes code runs sequentially and respects ownership. A signal handler runs in parallel, potentially while you're holding a lock or in the middle of a function call. You can't just put arbitrary Rust code inside a signal handler. The compiler won't stop you from doing something dangerous, but the OS might corrupt your memory.

The safe pattern is to set a flag and check it cooperatively. The signal handler does the bare minimum: it flips a boolean. Your main code checks the boolean and stops. This keeps the handler tiny and safe. It also gives you control over where the shutdown happens. You can finish the current file, close the database connection, and remove the lock file before exiting.

The ctrlc crate makes this easy. It abstracts away the differences between Unix signals and Windows console events. It also solves a hidden trap. Raw signal handlers on Unix cannot call many standard library functions. You can't safely print or allocate memory inside a raw handler. The ctrlc crate avoids this by spawning a background thread. When the signal arrives, the thread wakes up and runs your closure. Since it's a normal thread, you can call println and use Rust types safely. The trade-off is a tiny bit of overhead for the thread, which is negligible for any CLI tool.

The flag pattern

The core of signal handling is an atomic flag shared between the signal handler and your main loop. AtomicBool provides a boolean that can be safely read and written from multiple threads without locks. Arc wraps the flag so you can clone the reference and pass it to the handler.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
    // AtomicBool allows safe sharing between threads without locks.
    // The signal handler and main loop run on different threads.
    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();

    // ctrlc::set_handler registers a closure to run when Ctrl+C is pressed.
    // The closure runs on a separate thread managed by the crate.
    ctrlc::set_handler(move || {
        // Print is safe here because ctrlc spawns a thread.
        // In a raw signal handler, printing can cause undefined behavior.
        println!("Received Ctrl+C, shutting down...");
        r.store(false, Ordering::SeqCst);
    }).expect("Error setting Ctrl-C handler");

    // Simulate work in a loop.
    while running.load(Ordering::SeqCst) {
        println!("Working...");
        thread::sleep(Duration::from_millis(100));
    }

    println!("Shutdown complete.");
}

Add ctrlc = "3.4" to your Cargo.toml dependencies. The community convention is to use ctrlc for simple CLIs and scripts. It's cross-platform, requires no unsafe code, and handles the thread management for you. For complex daemons that need to handle SIGTERM, SIGHUP, or custom signals, developers often switch to the signal-hook crate, which offers more POSIX-specific control.

The flag is the contract. The handler sets it, the loop respects it.

How the pieces fit

The Arc wraps the AtomicBool. Arc stands for Atomic Reference Counted. It lets multiple owners share the boolean. running.clone() bumps the count and gives the handler its own handle. The move keyword forces the closure to take ownership of r. This is crucial. The handler runs later, potentially after main continues. Without move, r would be dropped when set_handler returns, and the handler would try to access freed memory. The compiler catches this with E0373 (closure may outlive the current function, but it borrows r). Adding move fixes the error by transferring ownership.

The loop loads the value. SeqCst ensures the load sees the store from the handler immediately. It's the strongest ordering, which prevents the CPU from reordering reads and writes in weird ways. For a shutdown flag, SeqCst is the right choice. It's slightly slower than relaxed orderings, but the difference is invisible in a shutdown loop. You don't need SeqCst for correctness here. Release on the store and Acquire on the load is sufficient. SeqCst is the convention because it's easier to reason about and the performance cost is zero for a single flag. Stick with SeqCst unless you're profiling and found the atomic operations are the bottleneck, which is almost never the case.

On Windows, ctrlc registers a console control handler. On Unix, it uses sigaction. The result is the same: a thread wakes up and sets the flag. You don't need to write platform-specific code. The crate handles the divergence.

Real-world shutdown

Real programs have resources to clean up. Worker threads need to stop. Files need to close. Temporary data needs to vanish. The flag pattern scales to this. You clone the Arc to every thread. Each thread checks the flag periodically. When the flag drops, threads exit their loops and return. The main thread joins the workers and runs cleanup.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

/// Worker that processes items until shutdown signal.
fn worker(id: usize, running: Arc<AtomicBool>) {
    while running.load(Ordering::SeqCst) {
        // Simulate work.
        // Check the flag periodically to avoid blocking forever.
        thread::sleep(Duration::from_millis(50));
        println!("Worker {id} is busy...");
    }
    println!("Worker {id} shutting down.");
}

fn main() {
    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();

    ctrlc::set_handler(move || {
        println!("Ctrl+C received. Initiating shutdown.");
        r.store(false, Ordering::SeqCst);
    }).expect("Failed to set handler");

    let mut handles = Vec::new();
    for i in 0..3 {
        let r = running.clone();
        let handle = thread::spawn(move || {
            worker(i, r);
        });
        handles.push(handle);
    }

    // Main thread also waits.
    while running.load(Ordering::SeqCst) {
        thread::sleep(Duration::from_millis(100));
    }

    // Join threads to ensure they finish.
    for handle in handles {
        handle.join().unwrap();
    }

    println!("All workers stopped. Cleanup done.");
}

The killer pitfall is blocking calls. thread::sleep blocks the thread. If the user hits Ctrl+C while you're sleeping for ten seconds, the flag gets set, but your thread won't check it until the sleep finishes. The program hangs. You have to break long sleeps into short ones and check the flag. Or use a channel to wake up. Never trust a sleep to be interruptible. Check the flag or get stuck.

If you try to share a plain bool instead of AtomicBool, the compiler rejects you with E0277 (trait bound not satisfied). bool doesn't implement Send or Sync in a way that allows sharing across threads without synchronization. AtomicBool fixes this. If you forget to clone the Arc before passing it to the thread, you get E0382 (use of moved value). The Arc is consumed by the first move. Clone it for each owner.

Pitfalls and compiler errors

Signals introduce concurrency. Concurrency brings race conditions and deadlocks. The flag pattern minimizes risk, but mistakes still happen.

The most common error is assuming the handler runs on the main thread. It doesn't. ctrlc spawns a thread. Your handler runs in parallel. If you try to access non-thread-safe data, you'll get data races. AtomicBool prevents this. If you need to share more complex state, use Mutex or channels. Keep the handler simple. Set the flag and return. Do the heavy lifting in the main code.

Another trap is ignoring the result of set_handler. The function returns a Result. If the OS refuses to register the handler, the program continues without shutdown support. Always unwrap or handle the error. expect is fine for CLIs. In a library, propagate the error.

On Unix, you can receive other signals like SIGTERM. ctrlc only handles SIGINT by default. If you need to handle SIGTERM, you'll need signal-hook or raw libc calls. Mixing signal crates can cause conflicts. Pick one strategy and stick to it.

The compiler helps with lifetimes. If you capture a reference in the handler closure, you get E0597 (borrowed value does not live long enough). The handler must own its data. Use move and Arc to transfer ownership.

Raw signal handlers are dangerous. If you use libc::signal, you can't call malloc, printf, or most Rust functions. The handler runs in a restricted context. ctrlc avoids this by using a thread. That's why you can print safely. That's also why it has a tiny overhead. The overhead is negligible for CLIs. If you're writing a high-frequency trading engine, you might care about the thread spawn. For 99% of Rust programs, ctrlc is the right choice.

Choosing your signal strategy

Use ctrlc for simple CLIs and scripts where cross-platform support matters and you want a quick setup. Use signal-hook when you need fine-grained control over POSIX signals, want to handle SIGTERM or SIGHUP explicitly, or are building a daemon that follows Unix conventions. Use tokio::signal when you're already in an async runtime and want to await the signal without spinning a thread. Use a manual flag pattern when you're writing a library that shouldn't depend on external crates or need to integrate with a custom event loop.

Pick the tool that matches your runtime. Don't pull in a signal crate if your async runtime already handles it.

Where to go next