Guide contents

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::fs is 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.

Rust
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(())
}
Output
23 bytes, 2 lines
first byte: 102

fs::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.

Rust
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(())
}
Output
same result: true

The 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.

Rust
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(())
}
Output
alpha
beta

flush 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().

Rust
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(())
}
Output
19 bytes in 3 reads
line: one
line: two
line: three
line: four

Each 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
Rust
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(())
}
Output
start
work
stop
create_new on existing file: AlreadyExists

append(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.

Rust
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(())
}
Output
is_file=true is_dir=false len=1500
dir is_dir=true
exists: true
missing exists: false
NotFound

Matching 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.

Rust
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(())
}
Output
dir  logs
file a.txt
file b.txt

The 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.

Rust
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, &copy).await?;
    println!("copied {copied_bytes} bytes");

    fs::remove_file(&renamed).await?;
    println!("left: {}", fs::read_to_string(&copy).await?);

    fs::remove_dir_all(&dir).await?;
    println!("dir exists: {}", fs::try_exists(&dir).await?);
    Ok(())
}
Output
original exists: false
copied 17 bytes
left: quarterly numbers
dir exists: false

Writing 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.

Rust
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(())
}
Output
part-0.txt: 100 bytes
part-1.txt: 200 bytes
part-2.txt: 300 bytes
part-3.txt: 400 bytes

The 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.

Rust
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(())
}
Output
success: true
stdout: "hello from a child\n"
stderr bytes: 0
set by the parent

arg 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.

Rust
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()),
    }
}
Output
success: false, code: Some(3)
could not start: NotFound

The 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.

Rust
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(())
}
Output
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.

Rust
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(())
}
Output
alpha
beta
success: true

wait_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.

Rust
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(())
}
Output
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.

Rust
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(())
}
Output
has pid while running: true
success: false
code: None
signal: Some(9)
has pid after wait: false

Signal 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.

Rust
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(())
}
Output
timed out, killing the child
success after kill: false

The 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.

Rust
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(())
}
Output
child printed: started
pipe closed after drop, 0 more bytes

The 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 recv can 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.

Rust
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(())
}
Output
received SIGUSR1 number 1
received SIGUSR1 number 2
received SIGUSR1 number 3

The 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.

Rust
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());
}
Output
interrupt raw value: 2
terminate raw value: 15
hangup raw value: 1
SIGKILL: Refusing to register signal 9
two listeners for one signal: true

The 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.

Rust
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(())
}
Output
ctrl-c received
kill succeeded: true

In 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.

Rust
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(())
}
Output
processing job 1
processing job 2
processing job 3
SIGTERM received, leaving the loop
cleanup done

The 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.

Rust
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::fs runs 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 File write can complete before the data is written. Call flush before dropping the file. flush waits for pending operations. sync_all asks the operating system to write to disk.
  • read_dir order is not defined. Sort names before relying on an order.
  • Command::output captures stdout and stderr. status returns only the exit status. spawn returns a Child at once.
  • Reading only one of two full pipes can deadlock a child. Read both at once, or use output or wait_with_output.
  • Dropping a Child does not stop the process unless kill_on_drop(true) is set, and that kill is best effort. Await wait or kill for guaranteed cleanup.
  • Child::kill sends SIGKILL and waits. start_kill sends 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.
  • SIGKILL and SIGSTOP cannot be handled.

Summary

  • tokio::fs functions call spawn_blocking internally. Use them for ordinary files.
  • fs::read, fs::read_to_string, and fs::write handle a whole file in one call. File with AsyncReadExt, AsyncWriteExt, and BufReader handles streaming access.
  • OpenOptions supports append, create, create_new, and truncate. create_new fails with AlreadyExists.
  • create_dir_all, read_dir with next_entry, rename, copy, remove_file, and remove_dir_all cover directory work.
  • tokio::process::Command runs programs with output, status, or spawn. Stdio::piped() gives AsyncRead and AsyncWrite handles that are taken with take().
  • A failed program yields an exit status. A program that cannot start yields an io::Error, such as NotFound.
  • ExitStatus::code() is None and signal() is Some when 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 in select!.
  • A test can send a signal to the current process with the kill command after the listener exists.