Rust 的 I/O 编程避坑指南
wxk1991 Lv5

Rust 的 I/O 编程避坑指南

Rust 做 I/O 编程并不难,真正容易出问题的是细节:一次性读大文件、忘记处理缓冲、把阻塞 I/O 放进异步任务、错误信息丢失,以及路径和编码在不同系统上的差异。

这篇文章不讲完整标准库文档,只整理实际项目里最常见的坑。


一、不要默认一次性读完整文件

小配置文件可以直接读取:

1
let content = std::fs::read_to_string("config.toml")?;

但日志、CSV、导出文件这类内容可能很大,不应该随手 read_to_string。更稳妥的方式是按行或按块处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn read_lines(path: &str) -> io::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);

for line in reader.lines() {
let line = line?;
println!("{}", line);
}

Ok(())
}

BufReader 可以减少系统调用次数,也避免一次性把文件全部加载进内存。


二、写文件时记得使用缓冲

频繁小块写入时,直接写 File 可能效率很差。推荐包一层 BufWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::fs::File;
use std::io::{BufWriter, Write};

fn write_report(path: &str, rows: &[String]) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);

for row in rows {
writeln!(writer, "{}", row)?;
}

writer.flush()?;
Ok(())
}

flush 很重要。程序正常结束时缓冲区通常会被写出,但显式 flush 能让错误更早暴露出来,比如磁盘满、权限异常、网络文件系统中断。


三、区分字节和字符串

不是所有文件都是合法 UTF-8。日志、图片、压缩包、从旧系统导出的文本,都可能无法用 read_to_string 正确读取。

如果不确定内容编码,先按字节处理:

1
2
let bytes = std::fs::read("data.bin")?;
println!("size = {}", bytes.len());

需要解析文本时,再明确转换:

1
let text = String::from_utf8(bytes)?;

这样错误边界更清楚。不要在底层 I/O 函数里偷偷 unwrap,否则线上遇到一个非法字节就会直接崩。


四、错误不要只返回字符串

很多初学者会把 I/O 错误写成:

1
2
3
fn load_config(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|_| "read config failed".to_string())
}

这样会丢掉真正有价值的信息,比如文件不存在、权限不足、路径是目录。至少保留原始错误:

1
2
3
fn load_config(path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path)
}

业务层需要补上下文时,可以使用 anyhow

1
2
3
4
5
6
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read config: {}", path))
}

排查问题时,有上下文的错误信息比一句 failed 有用得多。


五、注意路径的跨平台差异

不要手动拼路径:

1
let path = format!("{}/{}", dir, filename);

更推荐使用 PathPathBuf

1
2
3
4
use std::path::PathBuf;

let mut path = PathBuf::from(dir);
path.push(filename);

Windows、macOS、Linux 的路径分隔符、根目录表示、文件名大小写规则都不完全一样。用标准库的路径类型,可以少踩很多跨平台坑。


六、异步项目里不要混用阻塞 I/O

Tokio 项目里,如果在 async handler 中直接调用 std::fs::read_to_string,这个操作会阻塞当前运行时线程。

异步文件 I/O 可以用 Tokio 的版本:

1
let content = tokio::fs::read_to_string("config.toml").await?;

如果必须调用阻塞库,可以放到阻塞线程池:

1
2
3
let content = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("big-file.txt")
}).await??;

原则很简单:异步任务里不要长时间占住运行时线程。


七、文件替换要考虑原子性

更新配置或缓存文件时,不建议直接覆盖原文件。程序写到一半崩溃,可能留下半个文件。

更稳妥的做法是先写临时文件,再 rename:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::fs;
use std::io::Write;

fn atomic_write(path: &str, content: &[u8]) -> std::io::Result<()> {
let tmp_path = format!("{}.tmp", path);

{
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
}

fs::rename(tmp_path, path)?;
Ok(())
}

这不是所有场景都绝对完美,但比直接覆盖可靠很多。关键配置、索引文件、任务状态文件都值得这样处理。


八、实践建议

Rust 的 I/O 编程最重要的是把边界想清楚:文件可能很大,内容可能不是 UTF-8,写入可能失败,路径可能跨平台,错误信息要能帮助定位问题。

如果只是写 demo,read_to_string 很方便。如果是生产代码,就应该尽早引入 BufReaderBufWriter、明确的错误上下文,以及对阻塞 I/O 的控制。

I/O 问题通常不是编译期错误,而是运行一段时间后才暴露。提前把这些坑避开,程序会稳定很多。