Lesson 21 of 44
Files, Processes, and Signals
Sockets are not the only I/O a program does. It also reads files, starts other programs, and receives signals from the operating system. Tokio wraps each of these in an async API. After this lesson you can read and write files without stalling the runtime, run a child process and capture its output, stop it, and wait for a signal such as SIGTERM.
| Area | APIs covered |
|---|---|
| Whole-file functions | fs::write, fs::read, fs::read_to_string, fs::metadata, fs::try_exists |
| Open files | File::create, File::open, OpenOptions, flush |
| Directories | create_dir_all, read_dir, rename, copy, remove_file, remove_dir_all |
| Processes | Command, output, status, spawn, Stdio::piped, wait, wait_with_output, kill, kill_on_drop |
| Signals | signal::ctrl_c, signal::unix::signal, SignalKind |
Every file example creates a directory under std::env::temp_dir() with the process id in its name and removes it before the program ends. The examples never print paths. The process examples start echo, cat, sh, sleep, and kill, which exist on Linux and macOS. tokio::signal::unix exists on Unix only; Windows has tokio::signal::windows, which this lesson does not use.
Why tokio::fs uses the blocking pool
Operating systems do not offer a portable way to read a regular file without blocking the calling thread. A socket can report "no data yet" and let the thread do other work. A file read waits for the disk.
Tokio therefore runs every tokio::fs operation on the blocking pool, the thread pool behind spawn_blocking (Blocking and CPU-Bound Work). The async function hands a closure to the pool and awaits its result. The Tokio documentation states that it "will always use spawn_blocking on all platforms" and that this may change in the future.
Two consequences follow:
- Each call costs a hop to another thread. Fewer, larger calls are cheaper than many small ones.
tokio::fsis meant for ordinary files. Named pipes and other special files are not covered by it.
Example 1: Write and read a whole file
fs::write replaces a file's contents, and fs::read_to_string returns them as a String. Each is one blocking-pool call.
use std::path::PathBuf;
use tokio::fs;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex1");
fs::create_dir_all(&dir).await?;
let file = dir.join("notes.txt");
fs::write(&file, "first line\nsecond line\n").await?;
let text = fs::read_to_string(&file).await?;
println!("{} bytes, {} lines", text.len(), text.lines().count());
let bytes = fs::read(&file).await?;
println!("first byte: {}", bytes[0]);
fs::remove_dir_all(&dir).await?;
Ok(())
}23 bytes, 2 lines
first byte: 102fs::read returns Vec<u8> and works for files that are not valid UTF-8. fs::read_to_string returns an error of kind InvalidData for such files. The helper scratch builds the directory name from the process id, so two programs running at the same time do not share a directory. The later examples repeat it.
Example 2: The same call written with spawn_blocking
fs::read_to_string does what this code does by hand. The Tokio source for it is one line: it passes std::fs::read_to_string(path) to an internal helper that calls spawn_blocking.
use tokio::task::spawn_blocking;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("tokio-l21-{}-ex2", std::process::id()));
std::fs::create_dir_all(&dir)?;
let file = dir.join("data.txt");
std::fs::write(&file, "hello from disk")?;
let path = file.clone();
let by_hand = spawn_blocking(move || std::fs::read_to_string(path))
.await
.expect("blocking task panicked")?;
let by_tokio = tokio::fs::read_to_string(&file).await?;
println!("same result: {}", by_hand == by_tokio);
std::fs::remove_dir_all(&dir)?;
Ok(())
}same result: trueThe std::fs calls in main here run before any concurrent work exists, so blocking is harmless in this program. Inside a service that handles many connections, the same calls would hold a worker thread.
Example 3: Write with File and flush
File::create opens a file for writing and truncates it. write_all comes from AsyncWriteExt, the trait taught in AsyncRead and AsyncWrite.
use std::path::PathBuf;
use tokio::fs::{self, File};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex3");
fs::create_dir_all(&dir).await?;
let path = dir.join("log.txt");
let mut file = File::create(&path).await?;
file.write_all(b"alpha\n").await?;
file.write_all(b"beta\n").await?;
file.flush().await?;
drop(file);
let mut reader = File::open(&path).await?;
let mut text = String::new();
reader.read_to_string(&mut text).await?;
print!("{text}");
fs::remove_dir_all(&dir).await?;
Ok(())
}alpha
betaflush matters for File. A write call on a Tokio File can return before the data reaches the file, because the write runs on the blocking pool. The documentation says the write still happens if you do not flush, only later, and that a file with unfinished operations is not closed at the moment it is dropped. Calling flush before the drop waits for those operations. flush does not force the operating system to write to the physical disk. File::sync_all does that.
Example 4: Read in chunks and by line
File implements AsyncRead, so read returns a number of bytes and 0 means end of file. Wrapping the file in BufReader adds lines().
use std::path::PathBuf;
use tokio::fs::{self, File};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex4");
fs::create_dir_all(&dir).await?;
let path = dir.join("lines.txt");
fs::write(&path, "one\ntwo\nthree\nfour\n").await?;
let mut file = File::open(&path).await?;
let mut chunk = [0u8; 8];
let mut total = 0;
let mut reads = 0;
loop {
let n = file.read(&mut chunk).await?;
if n == 0 {
break;
}
total += n;
reads += 1;
}
println!("{total} bytes in {reads} reads");
let mut lines = BufReader::new(File::open(&path).await?).lines();
while let Some(line) = lines.next_line().await? {
println!("line: {line}");
}
fs::remove_dir_all(&dir).await?;
Ok(())
}19 bytes in 3 reads
line: one
line: two
line: three
line: fourEach read on a File is a separate blocking-pool call, so reading a small file in 8-byte chunks is wasteful. BufReader fills a whole buffer per call and serves next_line from memory. For a file that fits in memory, one fs::read_to_string is cheaper still.
Example 5: OpenOptions
OpenOptions chooses how a file is opened. It has the same methods as std::fs::OpenOptions, and open is async.
| Method | Effect |
|---|---|
read(true) / write(true) |
allow reading / writing |
append(true) |
every write goes to the end of the file |
create(true) |
create the file if it does not exist |
create_new(true) |
create the file and fail if it already exists |
truncate(true) |
set the length to 0 when opening for writing |
use std::io::ErrorKind;
use std::path::PathBuf;
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex5");
fs::create_dir_all(&dir).await?;
let path = dir.join("journal.txt");
for entry in ["start", "work", "stop"] {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await?;
file.write_all(format!("{entry}\n").as_bytes()).await?;
file.flush().await?;
}
print!("{}", fs::read_to_string(&path).await?);
let again = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.await;
println!("create_new on existing file: {:?}", again.unwrap_err().kind());
fs::remove_dir_all(&dir).await?;
Ok(())
}start
work
stop
create_new on existing file: AlreadyExistsappend(true) with create(true) is the usual way to write a log: the first call creates the file and later calls add to it. create_new fails with ErrorKind::AlreadyExists when the path is taken, which makes it usable as a "create only if absent" check that has no gap between the check and the create.
Example 6: Metadata, existence, and errors
fs::metadata returns size and type. fs::try_exists returns Ok(false) for a missing path and Err only when the check itself fails. Missing files report ErrorKind::NotFound.
use std::io::ErrorKind;
use std::path::PathBuf;
use tokio::fs;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex6");
fs::create_dir_all(&dir).await?;
let file = dir.join("size.bin");
fs::write(&file, vec![7u8; 1500]).await?;
let meta = fs::metadata(&file).await?;
println!("is_file={} is_dir={} len={}", meta.is_file(), meta.is_dir(), meta.len());
println!("dir is_dir={}", fs::metadata(&dir).await?.is_dir());
println!("exists: {}", fs::try_exists(&file).await?);
println!("missing exists: {}", fs::try_exists(dir.join("nope")).await?);
match fs::read_to_string(dir.join("nope")).await {
Ok(_) => println!("unexpected"),
Err(e) if e.kind() == ErrorKind::NotFound => println!("NotFound"),
Err(e) => println!("other error: {:?}", e.kind()),
}
fs::remove_dir_all(&dir).await?;
Ok(())
}is_file=true is_dir=false len=1500
dir is_dir=true
exists: true
missing exists: false
NotFoundMatching on e.kind() separates "the file is not there" from other failures such as PermissionDenied. A try_exists followed by a read can still fail, because another process can delete the file between the two calls. Handling NotFound on the read is the check that cannot race.
Example 7: Create and list directories
create_dir_all creates a directory and every missing parent. read_dir returns a ReadDir, and next_entry yields one DirEntry at a time. Each next_entry is cancel safe according to its documentation.
use std::path::PathBuf;
use tokio::fs;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let root = scratch("ex7");
fs::create_dir_all(root.join("logs/2026")).await?;
fs::write(root.join("a.txt"), "a").await?;
fs::write(root.join("b.txt"), "bb").await?;
fs::write(root.join("logs/app.log"), "log").await?;
let mut entries = fs::read_dir(&root).await?;
let mut found = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let kind = if entry.file_type().await?.is_dir() { "dir " } else { "file" };
found.push(format!("{kind} {}", entry.file_name().to_string_lossy()));
}
found.sort();
for line in &found {
println!("{line}");
}
fs::remove_dir_all(&root).await?;
Ok(())
}dir logs
file a.txt
file b.txtThe operating system decides the order in which read_dir returns entries, and the order can differ between runs and file systems. The example collects the names and sorts them before printing. read_dir lists one level only; a recursive walk calls it again for each directory.
Example 8: Rename, copy, and remove
These functions map one to one onto their std::fs counterparts and each runs as one blocking-pool call.
use std::path::PathBuf;
use tokio::fs;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex8");
fs::create_dir_all(&dir).await?;
let original = dir.join("report.tmp");
let renamed = dir.join("report.txt");
let copy = dir.join("report-copy.txt");
fs::write(&original, "quarterly numbers").await?;
fs::rename(&original, &renamed).await?;
println!("original exists: {}", fs::try_exists(&original).await?);
let copied_bytes = fs::copy(&renamed, ©).await?;
println!("copied {copied_bytes} bytes");
fs::remove_file(&renamed).await?;
println!("left: {}", fs::read_to_string(©).await?);
fs::remove_dir_all(&dir).await?;
println!("dir exists: {}", fs::try_exists(&dir).await?);
Ok(())
}original exists: false
copied 17 bytes
left: quarterly numbers
dir exists: falseWriting to a temporary name and then calling rename is a common way to publish a file: readers see either the old file or the complete new one. On the same file system, rename replaces the target in one step.
Example 9: Read several files concurrently
File reads run on the blocking pool, so tasks that read different files overlap. Each task returns its result through its JoinHandle, and awaiting the handles in order keeps the output in order.
use std::path::PathBuf;
use tokio::fs;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("tokio-l21-{}-{}", std::process::id(), tag))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let dir = scratch("ex9");
fs::create_dir_all(&dir).await?;
for i in 0..4 {
let body = "x".repeat((i + 1) * 100);
fs::write(dir.join(format!("part-{i}.txt")), body).await?;
}
let mut handles = Vec::new();
for i in 0..4 {
let path = dir.join(format!("part-{i}.txt"));
handles.push(tokio::spawn(async move {
let text = fs::read_to_string(&path).await?;
Ok::<_, std::io::Error>(text.len())
}));
}
for (i, handle) in handles.into_iter().enumerate() {
let len = handle.await.expect("task panicked")?;
println!("part-{i}.txt: {len} bytes");
}
fs::remove_dir_all(&dir).await?;
Ok(())
}part-0.txt: 100 bytes
part-1.txt: 200 bytes
part-2.txt: 300 bytes
part-3.txt: 400 bytesThe blocking pool has a maximum number of threads. Reading thousands of files at once queues the extra work in the pool instead of failing. JoinSet and Structured Concurrency shows how to cap the number of tasks in flight.
Processes
tokio::process::Command mirrors std::process::Command. It configures a program, its arguments, environment, and working directory. A finished Command runs in one of three ways:
| Method | Returns | Behavior |
|---|---|---|
output() |
Output (status, stdout, stderr) |
runs to completion, captures both streams |
status() |
ExitStatus |
runs to completion, inherits the parent's streams unless configured |
spawn() |
Child |
starts the process and returns at once |
Tokio waits for the child without blocking a thread: the runtime is told when the process exits. Reading from a child's pipes uses the same AsyncRead traits as sockets.
Example 10: Run a command and capture its output
output() starts the program, waits for it, and returns everything it wrote. Stdout and stderr are piped by default for this method, according to the Command documentation.
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let output = Command::new("echo")
.arg("hello")
.arg("from a child")
.output()
.await?;
println!("success: {}", output.status.success());
println!("stdout: {:?}", String::from_utf8_lossy(&output.stdout));
println!("stderr bytes: {}", output.stderr.len());
let env_output = Command::new("sh")
.arg("-c")
.arg("echo $GREETING")
.env("GREETING", "set by the parent")
.output()
.await?;
print!("{}", String::from_utf8_lossy(&env_output.stdout));
Ok(())
}success: true
stdout: "hello from a child\n"
stderr bytes: 0
set by the parentarg adds one argument. Passing "-C /path" as one arg gives the program a single argument that contains a space, not two arguments. Use one arg call per argument, or args with an array. env adds a variable for the child only; the parent's environment is unchanged.
Example 11: Exit codes and spawn errors
A child that runs and fails is different from a program that cannot start. The first is an Ok(ExitStatus) with a non-zero code. The second is an Err from spawn, status, or output.
use tokio::process::Command;
#[tokio::main]
async fn main() {
let status = Command::new("sh")
.arg("-c")
.arg("echo failing >&2; exit 3")
.status()
.await
.expect("sh should start");
println!("success: {}, code: {:?}", status.success(), status.code());
let missing = Command::new("no-such-program-xyz").status().await;
match missing {
Ok(_) => println!("unexpected"),
Err(e) => println!("could not start: {:?}", e.kind()),
}
}success: false, code: Some(3)
could not start: NotFoundThe failing text goes to the parent's stderr because status() inherits the parent's streams, so it is not part of the standard output shown above. ExitStatus::code() returns None when a signal ended the process, as Example 15 shows.
Example 12: Read a child's output while it runs
spawn() returns a Child immediately. Setting .stdout(Stdio::piped()) makes child.stdout an Option<ChildStdout>, an AsyncRead that can be wrapped in a BufReader.
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg("for i in 1 2 3; do echo step $i; done")
.stdout(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("stdout was piped");
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await? {
println!("child said: {line}");
}
let status = child.wait().await?;
println!("exit: {:?}", status.code());
Ok(())
}child said: step 1
child said: step 2
child said: step 3
exit: Some(0)child.stdout.take() moves the pipe out of the Child and leaves None. Taking it avoids a partial move, which would stop later calls such as child.wait(). The loop ends when the child closes its stdout, normally at exit. wait() then collects the exit status. A finished child that is never waited for stays in the process table as a zombie until Tokio's best-effort cleanup reaps it, so the pattern is to call wait (or kill) on every child.
Example 13: Write to a child's stdin
With .stdin(Stdio::piped()), child.stdin is an AsyncWrite. Dropping it closes the pipe, which tells a program such as cat that its input has ended.
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().expect("stdin was piped");
stdin.write_all(b"alpha\nbeta\n").await?;
drop(stdin);
let output = child.wait_with_output().await?;
print!("{}", String::from_utf8_lossy(&output.stdout));
println!("success: {}", output.status.success());
Ok(())
}alpha
beta
success: truewait_with_output consumes the Child, collects stdout and stderr while it waits, and closes any stdin still held by the Child. Without the drop(stdin), cat would wait for more input while the parent waits for cat. The Child::wait documentation also states that it closes the child's stdin before waiting, but a handle already taken with take() is the caller's to close.
Example 14: Read stdout and stderr together
If a child writes a lot to both streams and the parent reads only one, the other pipe fills, the child blocks on its write, and both sides wait. Reading both at the same time avoids this. join! runs two reads in one task.
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg("echo out-1; echo err-1 >&2; echo out-2; echo err-2 >&2")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
let mut out = String::new();
let mut err = String::new();
let (a, b) = tokio::join!(
stdout.read_to_string(&mut out),
stderr.read_to_string(&mut err),
);
a?;
b?;
child.wait().await?;
println!("stdout lines: {:?}", out.lines().collect::<Vec<_>>());
println!("stderr lines: {:?}", err.lines().collect::<Vec<_>>());
Ok(())
}stdout lines: ["out-1", "out-2"]
stderr lines: ["err-1", "err-2"]The relative order of lines between the two streams is not defined, since they are separate pipes. The example keeps them in separate variables, so its output does not depend on that order. Command::output() and wait_with_output do this concurrent reading internally.
Example 15: Stop a child and inspect how it ended
Child::kill().await sends SIGKILL on Unix and then waits, so the process is reaped. start_kill() only sends the signal. ExitStatusExt::signal from std::os::unix::process reports which signal ended the process.
use std::os::unix::process::ExitStatusExt;
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("sleep").arg("30").spawn()?;
println!("has pid while running: {}", child.id().is_some());
child.kill().await?;
let status = child.wait().await?;
println!("success: {}", status.success());
println!("code: {:?}", status.code());
println!("signal: {:?}", status.signal());
println!("has pid after wait: {}", child.id().is_some());
Ok(())
}has pid while running: true
success: false
code: None
signal: Some(9)
has pid after wait: falseSignal 9 is SIGKILL. code() is None because the process did not exit through exit. id() returns None once the child has been waited for, which prevents using a process id that the operating system may have given to another process. Calling wait after kill is allowed because Tokio remembers the status.
Example 16: Stop a child that runs too long
timeout from Time: sleep, timeout, and interval wraps any future, including child.wait(). When the timeout fires, the child is still running, so the code must stop it.
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("sleep").arg("30").spawn()?;
match timeout(Duration::from_secs(2), child.wait()).await {
Ok(status) => println!("finished: {:?}", status?.code()),
Err(_elapsed) => {
println!("timed out, killing the child");
child.kill().await?;
}
}
let status = child.wait().await?;
println!("success after kill: {}", status.success());
Ok(())
}timed out, killing the child
success after kill: falseThe example runs on the paused clock: while the runtime waits for the child and has no other work, the clock jumps to the timeout instead of waiting two real seconds. The real sleep 30 process is then killed, so the program ends at once. A timeout on wait() does not stop the child; dropping the wait future only stops waiting.
Example 17: kill_on_drop
By default a dropped Child leaves the process running. The Tokio documentation states this explicitly, because Tokio follows the rule that dropping a future cancels it, but a process is not a future. kill_on_drop(true) changes the default: dropping the Child sends SIGKILL.
The check below reads the child's stdout after dropping the Child. The pipe reaches end of file only when the process is gone. The shell command uses exec so that the process holding the pipe is sleep itself and not a shell with a sleep child.
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg("echo started; exec sleep 30")
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let mut stdout = BufReader::new(child.stdout.take().expect("piped"));
let mut first = String::new();
stdout.read_line(&mut first).await?;
print!("child printed: {first}");
drop(child);
let mut rest = Vec::new();
let n = stdout.read_to_end(&mut rest).await?;
println!("pipe closed after drop, {n} more bytes");
Ok(())
}child printed: started
pipe closed after drop, 0 more bytesThe read after the drop returned because the process was killed. Without kill_on_drop(true) the process keeps running after the drop, and the same read_to_end keeps waiting while sleep holds the pipe open. The Tokio documentation adds that the kill on drop is best effort, and recommends awaiting wait() or kill() when stronger cleanup guarantees are needed.
Signals
A signal is a small message the operating system delivers to a process, for example when the user presses Ctrl+C or when a service manager asks a process to stop. By default many signals end the process at once. A program that must finish its work first installs a handler.
Tokio provides signal handling as a future. Waiting for the signal is an .await point, so a select! can race it against other work. Three facts from the Tokio documentation shape the examples:
- The first time a program registers a listener for a signal kind, Tokio installs an OS handler that replaces the default behavior. The default is not restored, even after the listener is dropped.
- A listener receives only signals that arrive after it was created. A
signal(...)call registers when it runs.ctrl_c()registers when its future is first polled. - Signals can coalesce: several arrivals before the next
recvcan produce one notification. Each notification stands for at least one signal.
The first fact makes an unregistered signal dangerous in an example: it would end the program. The examples below register the listener first, and only then send the signal.
Example 18: Receive a signal sent to the current process
signal(SignalKind::user_defined1()) returns a Signal. recv().await waits for the next SIGUSR1. To send the signal, the program runs the kill command with its own process id, which std::process::id() returns. This keeps the example free of C bindings.
use tokio::process::Command;
use tokio::signal::unix::{signal, SignalKind};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut usr1 = signal(SignalKind::user_defined1())?;
let pid = std::process::id().to_string();
for round in 1..=3 {
let status = Command::new("kill").arg("-USR1").arg(&pid).status().await?;
assert!(status.success());
usr1.recv().await;
println!("received SIGUSR1 number {round}");
}
Ok(())
}received SIGUSR1 number 1
received SIGUSR1 number 2
received SIGUSR1 number 3The program waits for each recv before sending the next signal, so no signals coalesce and the count is exact. Without a listener, the first kill -USR1 would end the process, because SIGUSR1 terminates a process by default.
Example 19: SignalKind and signals that cannot be handled
SignalKind names the signal to listen for. It has constructors for common signals: interrupt() (SIGINT), terminate() (SIGTERM), hangup() (SIGHUP), quit(), alarm(), child(), user_defined1(), user_defined2(), and from_raw(number) for others. SIGKILL and SIGSTOP cannot be caught by any program, so Tokio refuses to register them.
use tokio::signal::unix::{signal, SignalKind};
#[tokio::main]
async fn main() {
println!("interrupt raw value: {}", SignalKind::interrupt().as_raw_value());
println!("terminate raw value: {}", SignalKind::terminate().as_raw_value());
println!("hangup raw value: {}", SignalKind::hangup().as_raw_value());
match signal(SignalKind::from_raw(9)) {
Ok(_) => println!("registered SIGKILL"),
Err(e) => println!("SIGKILL: {e}"),
}
let first = signal(SignalKind::user_defined2());
let second = signal(SignalKind::user_defined2());
println!("two listeners for one signal: {}", first.is_ok() && second.is_ok());
}interrupt raw value: 2
terminate raw value: 15
hangup raw value: 1
SIGKILL: Refusing to register signal 9
two listeners for one signal: trueThe last check reflects the signal documentation: a signal kind can have several listeners, and each one is notified. Signal numbers such as 2 for SIGINT are the values on Linux.
Example 20: ctrl_c
signal::ctrl_c() is the portable way to wait for Ctrl+C. It listens for SIGINT on Unix and the console event on Windows. It registers when the future is first polled, so the send must happen after that first poll. join! polls its futures in order on the first poll, so placing ctrl_c() first guarantees that it registers before the second future sends the signal.
use tokio::process::Command;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let pid = std::process::id().to_string();
let (waited, sent) = tokio::join!(
async {
tokio::signal::ctrl_c().await?;
Ok::<_, std::io::Error>("ctrl-c received")
},
async {
let status = Command::new("kill").arg("-INT").arg(&pid).status().await?;
Ok::<_, std::io::Error>(status.success())
},
);
println!("{}", waited?);
println!("kill succeeded: {}", sent?);
Ok(())
}ctrl-c received
kill succeeded: trueIn a real program nothing sends the signal from inside; the user presses Ctrl+C. The ordering trick exists only to make the example deterministic.
Example 21: Race a signal against ongoing work
A service loop usually contains a select! with a shutdown branch. This example processes jobs from a channel and stops when SIGTERM arrives. The program sends the signal itself after job 3. The biased; option makes select! check the signal branch first, so the order of events is fixed. biased was introduced in select!: Racing Futures.
use tokio::process::Command;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut term = signal(SignalKind::terminate())?;
let pid = std::process::id().to_string();
let (tx, mut rx) = mpsc::channel::<u32>(8);
for job in 1..=3 {
tx.send(job).await.expect("receiver is alive");
}
loop {
tokio::select! {
biased;
_ = term.recv() => {
println!("SIGTERM received, leaving the loop");
break;
}
Some(job) = rx.recv() => {
println!("processing job {job}");
if job == 3 {
Command::new("kill").arg("-TERM").arg(&pid).status().await?;
}
}
}
}
println!("cleanup done");
drop(tx);
Ok(())
}processing job 1
processing job 2
processing job 3
SIGTERM received, leaving the loop
cleanup doneThe sender tx stays alive until the end. If it were dropped early, rx.recv() would return None, the pattern Some(job) would not match, and the branch would be disabled. After the signal handler runs, the term.recv() branch is ready and the loop exits. Graceful Shutdown builds a full shutdown sequence on this shape.
Example 22: Handle both Ctrl+C and SIGTERM
Services normally stop on either SIGINT (a person at a terminal) or SIGTERM (a container runtime or service manager). This function waits for whichever arrives first. The program cannot run to completion in the checker without a signal, so the block is compiled but not run, and the text below shows how to test it.
use tokio::signal::unix::{signal, SignalKind};
async fn shutdown_signal() -> std::io::Result<&'static str> {
let mut term = signal(SignalKind::terminate())?;
let mut int = signal(SignalKind::interrupt())?;
let name = tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
};
Ok(name)
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
println!("serving; send SIGTERM or press Ctrl+C to stop");
let name = shutdown_signal().await?;
println!("stopping because of {name}");
Ok(())
}To try it, run the program and press Ctrl+C, or run kill -TERM <pid> from another terminal. Examples 18 to 21 show the same mechanism with the process sending the signal to itself, which is the only reliable way to trigger it inside an automated check.
Interview points
tokio::fsruns each operation on the blocking pool because regular files have no portable non-blocking interface. Each call is a thread hop, so batch work into fewer calls (fs::read,fs::write,BufReader).- A Tokio
Filewrite can complete before the data is written. Callflushbefore dropping the file.flushwaits for pending operations.sync_allasks the operating system to write to disk. read_dirorder is not defined. Sort names before relying on an order.Command::outputcaptures stdout and stderr.statusreturns only the exit status.spawnreturns aChildat once.- Reading only one of two full pipes can deadlock a child. Read both at once, or use
outputorwait_with_output. - Dropping a
Childdoes not stop the process unlesskill_on_drop(true)is set, and that kill is best effort. Awaitwaitorkillfor guaranteed cleanup. Child::killsendsSIGKILLand waits.start_killsends the signal only. An unreaped exited child stays a zombie.- A timeout around
child.wait()stops the waiting, not the process. - Registering a signal listener replaces the default action for the whole process for the rest of its life. Listeners see only signals sent after registration, and signals can coalesce.
SIGKILLandSIGSTOPcannot be handled.
Summary
tokio::fsfunctions callspawn_blockinginternally. Use them for ordinary files.fs::read,fs::read_to_string, andfs::writehandle a whole file in one call.FilewithAsyncReadExt,AsyncWriteExt, andBufReaderhandles streaming access.OpenOptionssupportsappend,create,create_new, andtruncate.create_newfails withAlreadyExists.create_dir_all,read_dirwithnext_entry,rename,copy,remove_file, andremove_dir_allcover directory work.tokio::process::Commandruns programs withoutput,status, orspawn.Stdio::piped()givesAsyncReadandAsyncWritehandles that are taken withtake().- A failed program yields an exit status. A program that cannot start yields an
io::Error, such asNotFound. ExitStatus::code()isNoneandsignal()isSomewhen a signal ended the process.signal::unix::signal(SignalKind)registers when called.signal::ctrl_c()registers on its first poll. Both are futures that fit inselect!.- A test can send a signal to the current process with the
killcommand after the listener exists.
