Tokio I/O 性能调优:从 bufreader 到 vectored I/O 的进化路线
Tokio I/O 性能调优:从 bufreader 到 vectored I/O 的进化路线
一、从裸 read 到 BufReader:为什么缓冲这么重要?
我最初的代码是这样直接读文件的:
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::fs::File;
async fn count_lines_slow(path: &str) -> Result<usize, Box<dyn std::error::Error>> {
let mut file = File::open(path).await?;
let mut count = 0;
// 每次只读一个字节 —— 每个字节触发一次 read() 系统调用!
let mut buf = [0u8; 1];
loop {
match file.read(&mut buf).await? {
0 => break, // EOF
_ => {
if buf[0] == b'\n' {
count += 1;
}
}
}
}
Ok(count)
}
这段代码性能极差——每读一个字节触发一次 read() 系统调用(从用户态切换到内核态),读一个 100KB 的文件就要做 10 万次系统调用。这还只是读,如果要边读边解析就更慢了。
改进方案:BufReader 在内存里维护一个缓冲区(默认 8KB),每次读取时先从缓冲区读,缓冲区空了才做一次系统调用补货:
use tokio::io::{AsyncBufReadExt, BufReader};
async fn count_lines_bufreader(path: &str) -> Result<usize, Box<dyn std::error::Error>> {
let file = File::open(path).await?;
// BufReader 默认 8KB 缓冲区,一次 read() 系统调用读入 8KB 数据
let reader = BufReader::new(file);
let mut lines = reader.lines(); // 按行读取,内部使用缓冲区
let mut count = 0;
while let Some(_line) = lines.next_line().await? {
count += 1;
}
Ok(count)
}
对于大文件,可以把缓冲区调大一点:
// 32KB 缓冲区,适合顺序读取大文件
let reader = BufReader::with_capacity(32 * 1024, file);
核心原理:系统调用(syscall)的开销远大于内存操作。BufReader 用空间换时间,把数千次 syscall 压缩到几十次。
二、搭配 BufWriter:读得快,写也要快
读的问题解决了,写呢?很多场景下我们是边读边写的(比如文件格式转换、日志处理管道):
use tokio::io::{AsyncWriteExt, BufWriter};
async fn copy_file_with_buf(src: &str, dst: &str) -> Result<(), Box<dyn std::error::Error>> {
let src_file = File::open(src).await?;
let dst_file = File::create(dst).await?;
let mut reader = BufReader::with_capacity(32 * 1024, src_file);
let mut writer = BufWriter::with_capacity(32 * 1024, dst_file);
// 读完一个缓冲区就写一个缓冲区,两边都是批量操作
let mut buf = vec![0u8; 32 * 1024];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 { break; }
writer.write_all(&buf[..n]).await?;
}
// BufWriter 析构前一定要 flush,否则缓冲区里残余数据会丢失!
writer.flush().await?;
Ok(())
}
读写缓冲配合使用的性能提升:
三、vectored I/O:一次系统调用,读写多块内存
BufReader 已经不错了,但还有优化空间。来看一个场景:我需要把一段 JSON 数据写入文件,前面是固定的 header,中间是动态的 body,最后是固定的 footer。普通写法:
// 三次 write 系统调用
writer.write_all(HEADER.as_bytes()).await?; // syscall 1
writer.write_all(&body_bytes).await?; // syscall 2
writer.write_all(FOOTER.as_bytes()).await?; // syscall 3
三次 write 就是三次系统调用。vectored I/O(也叫 scatter/gather I/O)可以一次系统调用完成多段不连续内存的读写:
use tokio::io::AsyncWriteExt;
async fn write_json_with_vectored(
writer: &mut (impl AsyncWriteExt + Unpin),
body: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
// 定义三段不连续的内存区域(IoSlice)
let header = tokio::io::IoSlice::new(b"{\"data\":");
let footer = tokio::io::IoSlice::new(b"}");
let body_slice = tokio::io::IoSlice::new(body);
// 一次 write_vectored 系统调用,写入三块内存
writer.write_vectored(&[header, body_slice, footer]).await?;
Ok(())
}
对应的读取端也可以用 read_vectored:
use tokio::io::AsyncReadExt;
async fn read_with_vectored(reader: &mut (impl AsyncReadExt + Unpin))
-> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
// 两个独立的缓冲区,一次 readv 系统调用同时填充
let mut header_buf = vec![0u8; 64]; // 前 64 字节
let mut body_buf = vec![0u8; 1024]; // 接下来的 1024 字节
// 这里需要把 &mut [u8] 转为 IoSliceMut
let mut slices = vec![
tokio::io::IoSliceMut::new(&mut header_buf),
tokio::io::IoSliceMut::new(&mut body_buf),
];
reader.read_vectored(&mut slices).await?;
Ok((header_buf, body_buf))
}
vectored I/O 的优势在于:把 N 次系统调用的开销压缩为 1 次。在需要读写多个不连续内存区域的场景(比如网络协议的 header + body 分离读取、文件格式的分段写入),这个优化非常有意义。
踩坑提醒:write_vectored 不保证一次性写完所有数据。 我在生产环境遇到过——写一个 3 段 buffer(总共 12KB),write_vectored 返回只写了 6KB,剩下的数据丢了。原因是有两个 IoSlice 中的 buffer 没有对齐到内存页边界,内核只写了一部分就返回了。排查了一下午才发现这个坑。解决方案是封装一个 write_vectored_all 函数,循环写入直到全部写完:
async fn write_vectored_all(
writer: &mut (impl tokio::io::AsyncWriteExt + Unpin),
mut bufs: &[std::io::IoSlice<'_>],
) -> std::io::Result<()> {
while !bufs.is_empty() {
let n = writer.write_vectored(bufs).await?;
if n == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "write zero"));
}
// 跳过已写入的字节,继续写剩余部分
bufs = std::io::IoSlice::advance_slices(bufs, n);
}
Ok(())
}
这个坑告诉我一个道理:系统调用的返回值一定要检查,不能想当然认为"写了就等于全写完了"。一线开发者往往会忽略这类边界情况。
四、进化路线全景对比
我把四种方案在同一个场景下做了基准测试,场景是:读取一个 100MB 的日志文件,按行解析并统计错误日志数量:
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::fs::File;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = "test_100mb.log";
// === 方案 A:逐字节读取(最差) ===
let start = Instant::now();
let mut file = File::open(path).await?;
let mut buf = [0u8; 1];
let mut count_a = 0;
loop {
if file.read(&mut buf).await? == 0 { break; }
if buf[0] == b'\n' { count_a += 1; }
}
println!("逐字节读取: {}ms, {} 行", start.elapsed().as_millis(), count_a);
// === 方案 B:BufReader + lines()(较好) ===
let start = Instant::now();
let file = File::open(path).await?;
let reader = BufReader::with_capacity(64 * 1024, file);
let mut lines = reader.lines();
let mut count_b = 0;
while let Some(_) = lines.next_line().await? {
count_b += 1;
}
println!("BufReader(64K): {}ms, {} 行", start.elapsed().as_millis(), count_b);
// === 方案 C:手动缓冲读取(可控性更强) ===
let start = Instant::now();
let mut file = File::open(path).await?;
let mut buf = vec![0u8; 256 * 1024]; // 256KB 大缓冲
let mut count_c = 0;
loop {
let n = file.read(&mut buf).await?;
if n == 0 { break; }
// 在内存中快速统计(避免字节级系统调用)
count_c += buf[..n].iter().filter(|&&b| b == b'\n').count();
}
println!("大缓冲读取(256K): {}ms, {} 行", start.elapsed().as_millis(), count_c);
Ok(())
}
// 典型结果:
// 逐字节读取: 45230ms, 1520000 行
// BufReader(64K): 342ms, 1520000 行
// 大缓冲读取(256K): 178ms, 1520000 行
从 45 秒到 178 毫秒,差距是 250 倍——这就是 I/O 优化的威力。
不过大缓冲区也不是越大越好。我踩过一个坑:为了追求"极致的吞吐量",把缓冲区设成了 1MB,结果在处理大量小文件(每个只有几 KB)时,每个文件都分配了 1MB 的栈上空间,总内存暴涨,反而被 OOM killer 杀了。后来定了规矩:缓冲区大小不超过目标文件大小的 1/4,且最大不超过 256KB。对于不确定文件大小的场景,默认 8KB 或者 32KB 是最稳妥的选择。
另一个边界问题是 BufReader::lines() 在处理超大单行时(比如一行 JSON 有几百 MB),会导致缓冲区被撑爆。这时候需要降级为手动分块读取,自己实现换行符检测。遇到这种场景,别强行用 lines()。
五、总结
I/O 优化的进化路线很清晰:
- 第一步:上
BufReader/BufWriter——这是性价比最高的优化,改几行代码就能消灭 99% 的无效系统调用。 - 第二步:调优缓冲区大小——默认 8KB 适合大多数场景,大文件顺序读写可以调到 32KB-256KB。
- 第三步:vectored I/O——当你的数据天然是分段存储的(header/body/footer、网络协议帧),用
readv/writev减少系统调用次数。
作为程序员,我之前确实不太懂操作系统的系统调用开销,但通过这次调优,我真正理解了"为什么内存操作比磁盘操作快"背后的原因。这些基础概念虽然枯燥,但在性能优化时,它就是最大的杠杆点。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)