A signal is a software interrupt sent to a process by the operating system or another process to notify it of an event. For example, when you try pressing Control+C while your program runs on a terminal, it terminates the process, correct? That’s one of the most common signals and signal handling you can see in action. We’ll explore how to handle that signal and others in Rust.
A signal can be triggered by different things such as hardware, the operating system, user input, or other processes. When a process receives a signal, it means that an event has occurred, and the process can take a specific action depending on the type of signal. For example, the process may need to stop running, restart, or handle an error.
In this article, we’ll discover the purpose of signals and how to handle signals in the Rust programming language. Let’s get started, shall we? 🦀
Jump ahead:
- Introduction to signals and signal handling in Rust
- Signal handling in Rust
- Exploring signal masking in Rust
Introduction to signals and signal handling in Rust
It has been established that signals serve as notifications of events. Just like how we react to notifications in our daily lives, when you are notified of an event, you are expected to either take responsibility and address it or choose to ignore it. Similarly, OS signals enable a process to take action or do nothing in response to a triggered event.
For instance, signals can pause or halt a running process, notify the user of an error such as a floating point exception, or provide information such as a system alarm wake-up call. When such signals are received, an application may need to close open handles to free up system resources or terminate any activity the event could impact. Such is the case of an application quitting when a user presses Control+C
.
Exploring the types of signals
There are several types of signals. Some can be handled, while others can’t. The table below shows some signal types with their available codes based on POSIX standards. This standard is a set of standards that defines APIs for Unix-like operating systems, including Linux, macOS, and various flavors of Unix. Refer to the following table:
Signal type | Use |
---|---|
SIGHUP , code: 1 | This signal is sent to a process when its controlling terminal is closed or disconnected |
SIGINT , code: 2 | This signal is sent to a process when the user presses Control+C to interrupt its execution |
SIGQUIT , code: 3 | This signal is similar to SIGINT but is used to initiate a core dump of the process, which is useful for debugging |
SIGILL , code: 4 | This signal is sent to a process when it attempts to execute an illegal instruction |
SIGABRT , code 6 | This signal is sent to a process when it calls the abort() function |
SIGFPE , code: 8 | This signal is sent to a process when it attempts to perform an arithmetic operation that is not allowed, such as division by zero |
SIGKILL , code: 9 | This signal is used to terminate a process immediately and cannot be caught or ignored |
SIGSEGV , code: 11 | This signal is sent to a process when it attempts to access memory that is not allocated to it |
SIGTERM | This signal is sent to a process to request that it terminate gracefully. Code: 15 |
SIGUSR1 , code: 10 | These signals can be used by a process for custom purposes |
SIGUSR2 , code: 12 | Same as SIGUSR1 , code: 10 |
Before discussing handling signals in Rust, let’s talk about signal dispositions.
Understanding signal dispositions
Signal disposition refers to the default action that the OS takes when a process receives a particular signal. The three possible signal dispositions are:
Terminate
: The process is terminated immediately without any chance to clean up or save stateIgnore
: The process does nothing in response to the signalCatch
: The process runs a user-defined signal handler function to handle the signal
This means that not all signals can be handled. Your application can only handle the signals the OS permits it to handle. All of the pre-defined signals we mentioned above can be handled. However, there are other signals, such as SIGKILL
, SIGSTOP
, and SIGCONT
, that cannot be handled. For example, SIGKILL
is used to forcefully terminate a process and cannot be caught, blocked, or ignored.
Signal handling in Rust
Now that we have covered the fundamentals of signals, let’s delve into the world of handling signals in Rust! Unlike C, where signal handling is built into the language modules, Rust provides several libraries that enable developers to handle signals with ease. Libraries such as signal_hook, nix, libc, and tokio handle signals that primarily use C bindings to make it possible to work with signals.
Signal handling with tokio
Let’s take a look at an example to show how we can handle signals in Rust with the tokio crate. The tokio signal crate is a perfect choice for handling signals because it is asynchronous and safe. By the way, it uses libc behind the scenes.
First, create a Rust project with Cargo and install tokio by running the following command:
init && cargo add tokio
Once your installation is complete, open the cargo.toml
file and activate tokio full
features by updating the features
flag with the full
argument like so: features=["full"]
.
Your code should look like this:
[package] name = "practice" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] tokio = { version="1.25.0", features=["full"] }
Then, let’s write a sample code to handle the SIGINT
signal — the signal triggered when you press Control+C
against a running process in your terminal. Here’s the code:
use tokio::signal::unix::{signal, SignalKind}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut sigint = signal(SignalKind::interrupt())?; match sigint.recv().await { Some(()) => println!("Received SIGINT signal"), None => eprintln!("Stream terminated before receiving SIGINT signal"), } for num in 0..10000 { println!("{}", num) } Ok(()) }
Now, run cargo run
on your terminal to test the code. While the code is running, press Control+C
, and you’ll see a response like this:
In the above code, we initialize the type of signal by calling its signalKind
method. SIGINT
is referred to as interrupt()
, and SIGTERM
is referred to as terminate()
. You can find the methods for others in the documentation. In our case, we are calling the interrupt()
kind:
let mut sigint = signal(SignalKind::interrupt())?;
Once the method is called, you’ll be ready to listen to that signal and handle it using the .recv()
method, as shown below:
match sigint.recv().await { Some(()) => println!("Received SIGINT signal"), None => eprintln!("Stream terminated before receiving SIGINT signal"), }
That’s basically how you handle signals in Rust in just a few lines of code.
Exploring signal masking in Rust
Signal masking is the process of temporarily blocking the delivery of certain signals to a process or a thread. When masked, a signal is added to a set of blocked signals and will not be delivered to the process or thread until it is unblocked.
Signal masking is often used to prevent the interruption of critical sections of code that must execute without being interrupted by a signal handler. For example, in a multi-threaded program, a critical section of code may need to execute atomically without being interrupted by a signal handler. In this case, the programmer can temporarily mask the signals that could interrupt the critical section and then unmask them once the critical section has been completed.
Blocking and unblocking signals with nix
Let’s look at an example of how to block and unblock signals using the nix crate. For this example, we’ll use the libc crate. So, start by installing it with cargo add libc
on your terminal. Then, add this to your src/main.rs file
:
use libc::{sigaddset, sigemptyset, sigprocmask, SIGINT, SIG_BLOCK, SIG_UNBLOCK}; use std::thread; use std::time::Duration; fn main() { unsafe { // Create an empty signal mask let mut mask: libc::sigset_t = std::mem::zeroed(); sigemptyset(&mut mask); // Add the SIGINT signal to the signal mask sigaddset(&mut mask, SIGINT); // Block the SIGINT signal using the signal mask sigprocmask(SIG_BLOCK, &mask as *const libc::sigset_t, std::ptr::null_mut()); } println!("Blocked SIGINT signal for 5 seconds"); thread::sleep(Duration::from_secs(5)); unsafe { // Unblock the SIGINT signal using the signal mask let mut mask: libc::sigset_t = std::mem::zeroed(); sigemptyset(&mut mask); sigaddset(&mut mask, SIGINT); sigprocmask(SIG_UNBLOCK, &mask as *const libc::sigset_t, std::ptr::null_mut()); } println!("Unblocked SIGINT signal"); }
Notice that we mark the function as unsafe
. We do this because it involves direct interaction with the OS signal handling mechanisms through the C standard library’s libc
interface. As you can see, we are de-referencing the sigset_t
raw pointer *const libc::sigset_t
because that part of the code is unsafe.
In the above code, we are blocking the delivery of the SIGINT
signal until after 5
seconds. Within those 5
seconds, if you press Control+C
, nothing will happen. However, the SIGINT
signal will be triggered after the 5
seconds elapse.
If you run this code with cargo run
and press Control+C
and also run it without pressing Control+C
, you’ll get something like this:
For the first executed command in the image above, the signal did execute after 5
seconds because we pressed Control+C
. However, for the second one, it did not. As you can see, the code executed to the end.
Conclusion
Handling signals in Rust is very straightforward. Despite the limited documentation available for the Rust signal crates, I trust that the insights provided here will serve as a useful starting point for implementing signal handling using Rust.
Happy hacking!
Was originally published on the LogRocket Blog