Whole files
nuclei::fs mirrors std::fs, with each call a future: read, read_to_string, write, copy, rename, remove_file, metadata, try_exists and the rest take a path and do the whole job.
use nuclei::fs;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
// Whole files in one call each.
fs::write("notes.txt", "first line\n").await?;
let text = fs::read_to_string("notes.txt").await?;
assert_eq!(text, "first line\n");
fs::copy("notes.txt", "copy.txt").await?;
fs::rename("copy.txt", "moved.txt").await?;
assert!(fs::try_exists("moved.txt").await?);
assert_eq!(fs::read("moved.txt").await?, b"first line\n");
let meta = fs::metadata("moved.txt").await?;
assert!(meta.is_file());
assert_eq!(meta.len(), 11);
fs::remove_file("moved.txt").await?;
fs::remove_file("notes.txt").await
}Opening a file
A File is std's file in a Handle: File::open reads, File::create writes (creating or cutting the file), File::create_new creates a file to read and write and fails if it exists, and OpenOptions sets the rest (append, truncate, the mode and the open flags on Unix). A file reads and writes through the IO helpers and has a cursor that seek moves.
use std::io::SeekFrom;
use nuclei::fs::{File, OpenOptions};
use nuclei::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
#[nuclei::main]
async fn main() -> std::io::Result<()> {
let mut file = File::create("log.txt").await?;
file.write_all(b"one\n").await?;
// flush hands the bytes to the system; sync_all puts them on the device.
file.flush().await?;
file.sync_all().await?;
drop(file);
// Open for appending: every write lands at the end.
let mut log = OpenOptions::new().append(true).open("log.txt").await?;
log.write_all(b"two\n").await?;
log.flush().await?;
drop(log);
let mut file = File::open("log.txt").await?;
let mut text = String::new();
file.read_to_string(&mut text).await?;
assert_eq!(text, "one\ntwo\n");
// A file has a cursor: seek moves it.
file.seek(SeekFrom::Start(4)).await?;
let mut second = String::new();
file.read_to_string(&mut second).await?;
assert_eq!(second, "two\n");
nuclei::fs::remove_file("log.txt").await
}flushwaits until the system has the bytes written behind;sync_allandsync_dataput them on the device (Surviving a crash).metadata,set_len,set_permissions,set_timesandset_modifiedwork on the open file;try_clonegives a second handle with a cursor of its own.- A read at the cursor sees the changes made beside it: bytes it read ahead before a positional write, a cut by
set_len, anallocateor a copy into the file are never handed out after the change.
Lending buffers
As on streams (How it works), read_owned and write_owned take your buffer and give it back, so no byte is copied. The buffer is a Vec<u8>, an AlignedBuf, or a type of your own (IO utilities).
use nuclei::fs::File;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
// Lend a vector to the write: the bytes are written from it, no copy.
let file = File::create("lent.bin").await?;
let (written, _buf) = file.write_owned(vec![7u8; 4096]).await;
assert_eq!(written?, 4096);
drop(file);
// And to the read: they land in its spare capacity.
let file = File::open("lent.bin").await?;
let (n, buf) = file.read_owned(Vec::with_capacity(8192)).await;
assert_eq!(n?, 4096);
assert!(buf.iter().all(|&b| b == 7));
nuclei::fs::remove_file("lent.bin").await
}Reads and writes at an offset
read_at and write_at read and write at an offset you give, on a shared reference to the handle, so any number of tasks may use one file at once. They never read or move the cursor. read_exact_at and write_all_at do the whole length, and read_at_owned and write_at_owned lend your buffer.
use std::sync::Arc;
use nuclei::fs::File;
use nuclei::io::AsyncReadExt;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
// One handle shared by four tasks, each writing its record at its offset.
let file = Arc::new(File::create_new("records.bin").await?);
let writers: Vec<_> = (0..4u8)
.map(|i| {
let file = Arc::clone(&file);
nuclei::spawn(async move { file.write_all_at(&[i; 16], u64::from(i) * 16).await })
})
.collect();
for writer in writers {
writer.await?;
}
let mut record = [0u8; 16];
file.read_exact_at(&mut record, 32).await?;
assert_eq!(record, [2; 16]);
// Many reads in one call, each into a buffer of its own, out together.
let reads = (0..4u64)
.map(|i| (Vec::<u8>::with_capacity(16), i * 16))
.collect();
for (i, (n, buf)) in (0u8..).zip(file.read_many_at(reads).await) {
assert_eq!(n?, 16);
assert!(buf.iter().all(|&b| b == i));
}
// Positional calls never move the cursor: a read at it starts at 0.
let mut all = Vec::new();
(&*file).read_to_end(&mut all).await?;
assert_eq!(all.len(), 64);
nuclei::fs::remove_file("records.bin").await
}- A positional call lands after every write accepted at the cursor before it, and a read sees every positional write that returned before it. Two positional writes running at once land in either order, as two threads'
pwritecalls do. - A positional write whose future is dropped still lands, and every later call of the handle waits for it.
read_atandwrite_atgo through a buffer of the runtime's pool, at most 64 KiB a call; the owned forms use your buffer itself.read_many_atmakes many reads in one call and gives each buffer back in order with its result. On io_uring they go to the kernel together, at most 64 out at once.- These are the async calls, and they shadow std's
FileExt::read_atandwrite_at, which the handle reaches throughDerefand which block the worker. std's stay reachable as(*file).read_at(..).
Surviving a crash
sync_all writes the file's data and metadata through to the device, and sync_data its data, once every write accepted before the call landed. A file's sync does not cover its name in its directory; fs::sync_dir does. To replace a file so a crash leaves the old contents or the new and never a mix, write a temporary file in the same directory, sync it, rename it over the target, then sync the directory.
use nuclei::fs::{self, File};
use nuclei::io::AsyncWriteExt;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
fs::write("config.toml", "version = 1\n").await?;
// Replace it so a crash leaves the old contents or the new, never a mix.
let mut tmp = File::create("config.toml.tmp").await?;
tmp.write_all(b"version = 2\n").await?;
tmp.sync_all().await?; // the bytes, on the device
drop(tmp);
fs::rename("config.toml.tmp", "config.toml").await?;
fs::sync_dir(".").await?; // the new name, on the device
assert_eq!(fs::read_to_string("config.toml").await?, "version = 2\n");
fs::remove_file("config.toml").await
}Once a sync of a handle failed, every later sync of it fails with the same error, since the system reports a lost write-back only once. Open the file again to start over.
Space, write-back and copies
allocate changes the space a range of the file uses, as its Allocate mode says: Extend reserves it and grows the file, KeepSize reserves it and keeps the length, PunchHole frees it, and ZeroRange makes it read as zeros. A system that cannot do a mode answers Unsupported (macOS cannot zero a range in place), and a file system may refuse one with its own error.
sync_range writes a range back and waits for it; it writes no metadata, so it is not a durability guarantee. copy_range_to copies a range into another file, in the kernel where the system can, in chunks of at most 16 MiB, and moves neither cursor.
use nuclei::fs::{Allocate, File};
#[nuclei::main]
async fn main() -> std::io::Result<()> {
let log = File::create_new("log.bin").await?;
// Reserve 1 MiB up front, so writes into it cannot run out of space.
log.allocate(0, 1 << 20, Allocate::KeepSize).await?;
assert_eq!(log.metadata().await?.len(), 0);
log.write_all_at(b"entry", 0).await?;
// Start writing the range back now; sync_data is what makes it durable.
log.sync_range(0, 5).await?;
// Copy the range into another file, in the kernel where it can.
let copy = File::create_new("copy.bin").await?;
assert_eq!(log.copy_range_to(©, 0, 0, 5).await?, 5);
let mut back = [0u8; 5];
copy.read_exact_at(&mut back, 0).await?;
assert_eq!(&back, b"entry");
nuclei::fs::remove_file("log.bin").await?;
nuclei::fs::remove_file("copy.bin").await
}Locks
lock_file takes an advisory lock on the whole file, shared or exclusive, and waits for it on the blocking pool so the worker runs on. A future dropped while it waits never leaves the file locked with nobody to release it. try_lock_file answers at once, with WouldBlock when another holder has the lock, and unlock_file releases it once the writes before it landed.
use nuclei::fs::{File, TryLockError};
#[nuclei::main]
async fn main() -> std::io::Result<()> {
let holder = File::create("app.lock").await?;
let other = File::open("app.lock").await?;
// Wait for the lock on the blocking pool; the worker runs on meanwhile.
holder.lock_file(false).await?;
// While it is held, another handle cannot have it.
let refused = other.try_lock_file(false).await;
assert!(matches!(refused, Err(TryLockError::WouldBlock)));
// Released once the writes before it landed; then free for the other.
holder.unlock_file().await?;
other.try_lock_file(true).await?;
other.unlock_file().await?;
nuclei::fs::remove_file("app.lock").await
}std's own lock, try_lock and unlock stay reachable through Deref; std's lock blocks the worker while it waits.
Direct IO
A file opened for direct IO (O_DIRECT on Linux, FILE_FLAG_NO_BUFFERING on Windows) skips the system's page cache. Nuclei reads and writes it with exactly your lengths and offsets, with no read-ahead and no write-behind, so the kernel's alignment rules apply to your calls as they would to your own system calls: the memory's address, the offset and the length are multiples of the device's block size. AlignedBuf gives memory at the alignment you ask for.
use nuclei::fs::OpenOptions;
use nuclei::io::AlignedBuf;
// Direct IO asks the memory, the offset and the length to be multiples of
// the device's block size; 4096 meets that on the common ones.
const BLOCK: usize = 4096;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.custom_flags(libc::O_DIRECT)
.open("direct.bin")
.await?;
// Two blocks in memory aligned to a block, handed to the kernel as they are.
let mut blocks = AlignedBuf::new(2 * BLOCK, BLOCK)?;
blocks.extend_from_slice(&[7u8; 2 * BLOCK]);
let (written, _blocks) = file.write_at_owned(blocks, 0).await;
assert_eq!(written?, 2 * BLOCK);
// The second block, read straight into an aligned buffer of its own.
let room = AlignedBuf::new(BLOCK, BLOCK)?;
let (n, block) = file.read_at_owned(room, BLOCK as u64).await;
assert_eq!(n?, BLOCK);
assert!(block.iter().all(|&b| b == 7));
nuclei::fs::remove_file("direct.bin").await
}Directories
create_dir, create_dir_all and DirBuilder (recursive, with a mode on Unix) make them; remove_dir and remove_dir_all take them away. read_dir streams the entries a page at a time, so a directory of any size is read without holding it all.
use nuclei::fs::{self, DirBuilder};
#[nuclei::main]
async fn main() -> std::io::Result<()> {
fs::create_dir_all("site/assets/img").await?;
DirBuilder::new()
.recursive(true)
.create("site/pages")
.await?;
fs::write("site/index.html", "<h1>hi</h1>").await?;
// read_dir is a stream of entries, read a page at a time.
let mut names = Vec::new();
let mut entries = fs::read_dir("site").await?;
while let Some(entry) = entries.next_entry().await? {
let mut name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().await?.is_dir() {
name.push('/');
}
names.push(name);
}
names.sort();
assert_eq!(names, ["assets/", "index.html", "pages/"]);
fs::remove_dir_all("site").await
}Links: hard_link, read_link and canonicalize, symlink on Unix, symlink_file and symlink_dir on Windows.
Where file IO runs
A regular file is always ready to the kernel, so no poller can say when its IO would wait. On io_uring a file's reads and writes, at the cursor and at an offset, its syncs, sync_range and allocate are operations of the shard's ring; on the other backends each is a job of the blocking pool. Calls by path, lock_file, copy_range_to and the file's other calls run on the blocking pool; try_lock_file runs on the calling thread, since it never waits. On Linux, File::open opens a regular file on a local file system on the calling thread when the kernel's caches resolve its path at once (openat2 with RESOLVE_CACHED, Linux 5.12), with no job; any other open, a FIFO, a device or a file on a network file system among them, goes to the blocking pool. A File that is not a regular file (a FIFO, a terminal, a pipe) is a byte stream the driver serves like a socket.