Rust 的 I/O 编程避坑指南
Rust 做 I/O 编程并不难,真正容易出问题的是细节:一次性读大文件、忘记处理缓冲、把阻塞 I/O 放进异步任务、错误信息丢失,以及路径和编码在不同系统上的差异。
这篇文章不讲完整标准库文档,只整理实际项目里最常见的坑。
一、不要默认一次性读完整文件
小配置文件可以直接读取:
1 | let content = std::fs::read_to_string("config.toml")?; |
但日志、CSV、导出文件这类内容可能很大,不应该随手 read_to_string。更稳妥的方式是按行或按块处理:
1 | use std::fs::File; |
BufReader 可以减少系统调用次数,也避免一次性把文件全部加载进内存。
二、写文件时记得使用缓冲
频繁小块写入时,直接写 File 可能效率很差。推荐包一层 BufWriter:
1 | use std::fs::File; |
flush 很重要。程序正常结束时缓冲区通常会被写出,但显式 flush 能让错误更早暴露出来,比如磁盘满、权限异常、网络文件系统中断。
三、区分字节和字符串
不是所有文件都是合法 UTF-8。日志、图片、压缩包、从旧系统导出的文本,都可能无法用 read_to_string 正确读取。
如果不确定内容编码,先按字节处理:
1 | let bytes = std::fs::read("data.bin")?; |
需要解析文本时,再明确转换:
1 | let text = String::from_utf8(bytes)?; |
这样错误边界更清楚。不要在底层 I/O 函数里偷偷 unwrap,否则线上遇到一个非法字节就会直接崩。
四、错误不要只返回字符串
很多初学者会把 I/O 错误写成:
1 | fn load_config(path: &str) -> Result<String, String> { |
这样会丢掉真正有价值的信息,比如文件不存在、权限不足、路径是目录。至少保留原始错误:
1 | fn load_config(path: &str) -> std::io::Result<String> { |
业务层需要补上下文时,可以使用 anyhow:
1 | use anyhow::{Context, Result}; |
排查问题时,有上下文的错误信息比一句 failed 有用得多。
五、注意路径的跨平台差异
不要手动拼路径:
1 | let path = format!("{}/{}", dir, filename); |
更推荐使用 Path 和 PathBuf:
1 | use std::path::PathBuf; |
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 | let content = tokio::task::spawn_blocking(|| { |
原则很简单:异步任务里不要长时间占住运行时线程。
七、文件替换要考虑原子性
更新配置或缓存文件时,不建议直接覆盖原文件。程序写到一半崩溃,可能留下半个文件。
更稳妥的做法是先写临时文件,再 rename:
1 | use std::fs; |
这不是所有场景都绝对完美,但比直接覆盖可靠很多。关键配置、索引文件、任务状态文件都值得这样处理。
八、实践建议
Rust 的 I/O 编程最重要的是把边界想清楚:文件可能很大,内容可能不是 UTF-8,写入可能失败,路径可能跨平台,错误信息要能帮助定位问题。
如果只是写 demo,read_to_string 很方便。如果是生产代码,就应该尽早引入 BufReader、BufWriter、明确的错误上下文,以及对阻塞 I/O 的控制。
I/O 问题通常不是编译期错误,而是运行一段时间后才暴露。提前把这些坑避开,程序会稳定很多。