The bottleneck you did not ask for
You are parsing a two-gigabyte log file. You call std::fs::read, hand the bytes to a parser, and watch your CPU spike while the cooling fan kicks on. The bottleneck is not your parsing logic. It is the data movement. The operating system copies the file from disk into a kernel buffer. Then it copies that buffer into your program's memory. Your parser reads the user-space copy, does its work, and drops it. You just paid for two memory copies before your code even ran.
Traditional I/O treats files like streams. You ask for a chunk, the OS shuffles it through internal buffers, and hands it to you. That design works fine for small files or sequential reads. It falls apart when you need random access, when the file dwarfs your RAM, or when you are building a system where latency matters more than throughput.
Zero-copy I/O removes the middleman. Instead of the OS shuttling data through its own buffers, the OS maps the file directly into your program's virtual address space. Your program gets a pointer to the exact same memory the disk controller uses. When you read a byte, the OS triggers a page fault, loads that chunk from disk into memory, and hands you the address. No intermediate buffer. No memcpy. The data lives in one place and two different contexts share the view.
What zero-copy actually means
Virtual memory is the foundation. Your program thinks it has a contiguous block of RAM starting at address zero. The OS and CPU translate those virtual addresses to physical RAM pages. When you map a file, you are telling the OS to link a range of virtual addresses to a file on disk. The OS reserves the address space and returns immediately. Nothing is loaded yet.
The first time you touch an address in that range, the CPU's memory management unit checks the page table. It sees the page is not resident in RAM. It raises a page fault. The OS catches the fault, reads the corresponding block from storage into a physical page, updates the page table, and resumes your instruction. Your program continues as if the data was always there.
This is why zero-copy is a misnomer. The data still moves from disk to RAM. The copy that disappears is the kernel-to-user-space copy. You avoid duplicating the buffer in memory. You also avoid the CPU cycles spent copying it. The tradeoff is that you hand over control of memory layout to the OS. You get exactly what the storage device gives you, aligned to page boundaries, with no guarantees about when pages will be loaded.
Mapping a file into memory
The modern Rust ecosystem uses the memmap2 crate for this. The older mmap crate is unmaintained and lacks platform fixes. The community convention is to keep the unsafe block tight around the actual mapping call and immediately hand the result to safe abstractions.
use std::fs::File;
use memmap2::MmapOptions;
fn main() {
// Open the file in read-only mode.
// We need a valid file descriptor for the OS to map.
let file = File::open("data.bin").expect("file not found");
// Map the file into memory. This requires unsafe because the OS
// controls the mapping, and a bad mapping could crash the process.
let mmap = unsafe {
// SAFETY:
// 1. `file` is a valid, open file descriptor.
// 2. We request a read-only mapping, preventing accidental writes.
// 3. The OS guarantees the mapping stays valid while `file` is open.
MmapOptions::new().map(&file).expect("mmap failed")
};
// The Mmap object dereferences to a &[u8]. No copy happened here.
// We can read directly from the mapped region.
let first_byte = mmap[0];
println!("First byte: {}", first_byte);
}
The Mmap struct implements Deref<Target = [u8]>. That means you can use standard slice methods, indexing, and iterators. The compiler enforces bounds checking exactly like it does for a Vec. The only difference is where the bytes live. They live in a region the OS manages, not in a heap allocation you own.
Keep the File alive for as long as you hold the Mmap. The mapping is tied to the file descriptor. If you drop the file early, the OS may invalidate the mapping on some platforms. The compiler will not catch this. It is a runtime contract with the kernel.
Walking through the memory layout
When you call MmapOptions::new().map(&file), you are not reading the file. You are asking the OS to reserve a range of virtual addresses in your process and link them to the inode on disk. The OS says yes and returns a Mmap wrapper. The file contents are still on the storage device.
When you access mmap[0], the CPU tries to read that virtual address. The memory management unit checks the page table, sees the page is not loaded, and triggers a page fault. The OS catches the fault, reads the corresponding chunk from disk into physical RAM, updates the page table, and resumes your program. Your println! runs against freshly loaded data. Every subsequent access to that page hits RAM directly.
This lazy loading is a feature, not a bug. If you map a ten-gigabyte file, you do not consume ten gigabytes of RAM. You consume virtual address space. Physical RAM is only allocated for the pages you actually touch. The OS evicts pages when memory pressure rises. Your program continues to work, just with more page faults.
The cost shows up in random access patterns. If you jump around the file unpredictably, you will trigger page faults constantly. The OS will thrash, swapping pages in and out. Sequential access or cache-friendly algorithms will keep pages resident and fly. Memory mapping rewards spatial locality and punishes scatter reads.
A realistic search example
You rarely map a file just to read the first byte. You map it to search, parse, or index. Here is a pattern-matching example that stays entirely in safe Rust after the initial mapping.
use std::fs::File;
use memmap2::MmapOptions;
/// Searches for a byte pattern in a memory-mapped file.
/// Returns the byte offset of the first match, or None.
fn find_pattern(file_path: &str, pattern: &[u8]) -> Option<usize> {
let file = File::open(file_path).expect("file not found");
// Map the file once. The OS handles page loading on demand.
let mmap = unsafe {
// SAFETY:
// 1. `file` is valid and open in read-only mode.
// 2. The mapping is read-only, preventing undefined behavior on write.
// 3. We hold `file` for the lifetime of `mmap`.
MmapOptions::new().map(&file).expect("mmap failed")
};
// Use a safe sliding window over the mapped slice.
// saturating_sub prevents underflow if the file is smaller than the pattern.
let limit = mmap.len().saturating_sub(pattern.len());
for i in 0..limit {
// Slice comparison is optimized by the compiler.
// It reads bytes directly from the mapped region.
if &mmap[i..i + pattern.len()] == pattern {
return Some(i);
}
}
None
}
The loop bounds are safe. The compiler guarantees we never read past the end of the file. The slice comparison &mmap[i..i + pattern.len()] == pattern compiles to a tight loop that checks bytes sequentially. If the pattern is long, you will want to use a proper string search algorithm like Boyer-Moore or SIMD-accelerated matching. The memory mapping layer does not care. It just hands you bytes.
Convention aside: always prefer memmap2::Mmap over raw libc::mmap calls. The crate handles platform differences, alignment quirks, and provides a safe Deref interface. You get the performance without wrestling with FFI signatures.
Where things go wrong
Memory mapping breaks standard assumptions about I/O. The biggest trap is assuming the data is immediately available. If you map a file and try to iterate it in a tight loop, you will trigger thousands of page faults. The OS will thrash, and your program will crawl. Always process mapped data in chunks or use algorithms that benefit from spatial locality.
Another trap is file lifetime. The Mmap struct borrows the file descriptor implicitly through the OS mapping. If you drop the File before the Mmap, the mapping becomes invalid on some platforms. Keep the File alive for as long as you hold the Mmap. The compiler will not catch this. It is a runtime contract with the OS.
If you try to mutate a read-only mapping, the OS sends a segmentation fault. Rust will not save you here. The unsafe block only tells the compiler to trust you. The kernel enforces the actual permissions. If you accidentally write to a read-only Mmap, your process dies with a SIGSEGV. Use MmapMut only when you explicitly need write access, and always sync changes with flush() or flush_async() before expecting them on disk.
You will also run into alignment issues when casting mapped bytes to structs. If you map a binary file and try to transmute a slice into &[u32], the compiler rejects you with E0606 (cannot transmute to a type with a different alignment) or E0512 (cannot transmute between types of different sizes). Use bytemuck or explicit byte parsing instead of raw transmutation. The OS does not guarantee that file offsets align to struct boundaries.
Empty files are another silent killer. Mapping a zero-byte file succeeds on most platforms, but indexing it panics. Always check mmap.is_empty() before indexing. The compiler cannot know the file size at compile time. You must handle it at runtime.
When to use mmap vs other I/O
Use memmap2 when you need random access across a large file and want to avoid loading the entire contents into a Vec. Use memmap2 when you are building a database engine, a log parser, or a binary format reader where seeking is frequent. Use std::fs::read when the file fits comfortably in RAM and you need a simple, linear pass without managing OS mappings. Use BufReader when you are streaming data sequentially and want the OS to handle buffering efficiently. Use read_to_string only for small text files where UTF-8 validation is required upfront.
Memory mapping is not a silver bullet. It shines when you treat the file like an array. It struggles when you treat it like a stream. Pick the tool that matches your access pattern.