Chapter 11 · reading, writing, and not corrupting

File I/O, with the durability bits made explicit.

The basics map almost directly onto System.IO. The interesting parts are the ones .NET also has but you rarely reach for: writing a file atomically so a crash can't leave it half-written, memory-mapping a large file, and locking across processes.

11.1Reading & writing the whole file

The one-shot helpers in std::fs line up with File.ReadAllText / WriteAllText. Everything returns a Result, so the ? operator (ch. 4) carries I/O errors up instead of exceptions.

C#

string text = File.ReadAllText(path);
byte[] bytes = File.ReadAllBytes(path);
File.WriteAllText(path, "hi");

Rust

use std::fs;

let text = fs::read_to_string(path)?;
let bytes = fs::read(path)?;
fs::write(path, "hi")?;

For large files you stream instead of slurping, with a buffered reader — the role StreamReader plays:

use std::io::{BufRead, BufReader};
use std::fs::File;

let f = File::open("big.log")?;
for line in BufReader::new(f).lines() {
    let line = line?;       // each line is a Result
    // ...
}
Paths are typed

Where C# passes paths as string, Rust uses Path / PathBuf (borrowed / owned, like &str / String). PathBuf::push joins with the correct separator per OS — the Path.Combine equivalent — and the type keeps "this is a path" distinct from arbitrary text.

11.2Don't block the runtime: async file I/O

As flagged in chapter 7, calling synchronous std::fs inside an async fn stalls a tokio worker thread. Under an async runtime, use tokio::fs, whose calls mirror the std ones but yield while the OS works.

use tokio::fs;

let text = fs::read_to_string("config.toml").await?;  // ≈ File.ReadAllTextAsync
fs::write("out.bin", &data).await?;

11.3Atomic writes — surviving a crash mid-save

Writing straight over an important file (config, state, a cache index) is a hazard: if the process dies partway through, you're left with a truncated or half-written file. The fix is the same trick databases use — write a temp file, flush it, then rename it over the target. A rename within one filesystem is atomic, so a reader ever only sees the old file or the complete new one, never a torn middle.

use std::fs;
use std::io::Write;

let tmp = "config.json.tmp";        // same directory as the target!
let mut f = fs::File::create(tmp)?;
f.write_all(data)?;
f.sync_all()?;                     // force bytes to disk BEFORE the rename
fs::rename(tmp, "config.json")?;    // atomic replace
The .NET equivalent

This is what File.Replace does for you, and the pattern you'd hand-roll with a temp file plus File.Move(..., overwrite: true). The memmap-free, dependency-free version above is fine; the tempfile crate's NamedTempFile::persist() packages the same idea with safer temp-file naming.

Two easy mistakes

First, the temp file must be on the same filesystem as the target (so put it in the same directory) — a cross-device rename isn't atomic and will copy instead. Second, rename makes the swap atomic but doesn't guarantee durability after a power loss; for that you also sync_all() the file (above) and, on Linux, fsync the containing directory so the rename itself is persisted.

11.4Memory-mapped files

For very large files, or random access without reading the whole thing into a Vec, map the file into memory and treat it as a byte slice. The standard library has no mmap; the memmap2 crate is the standard choice — the counterpart to .NET's MemoryMappedFile.

use memmap2::Mmap;
use std::fs::File;

let file = File::open("big.dat")?;
// SAFETY: we rely on the file not being resized/truncated while mapped
let mmap = unsafe { Mmap::map(&file)? };
let bytes: &[u8] = &mmap;            // index/slice it like any &[u8]
Why mapping is unsafe

The unsafe block is the honest part: an mmap is a window onto bytes the OS can change underneath you. If another process truncates the file while you hold the mapping, touching the vanished pages crashes the process (SIGBUS) — something Rust can't prevent at compile time. Use mmap for large, stable, mostly-read-only files (search indexes, asset blobs); for files other writers mutate, prefer ordinary buffered reads. .NET's MemoryMappedFile carries the same hazards; Rust just makes you acknowledge them.

11.5Locking for cross-process access

Within one process, ownership and Mutex (ch. 10) coordinate access. Across processes — two services touching the same file — you need an OS file lock. The fs2 crate (or its maintained successor fs4) adds advisory locks to File, matching FileStream.Lock / the FileShare options.

use fs2::FileExt;
use std::fs::File;

let f = File::create("app.lock")?;
f.lock_exclusive()?;     // blocks until no other process holds it
// ... exclusive section ...
f.unlock()?;
Advisory, not mandatory

Like FileStream locks on most platforms, these are advisory: they only stop other code that also asks for the lock. They don't physically prevent an unrelated program from opening and writing the file. The convention works only if every participant takes the lock — fine for coordinating your own services, not a security boundary.