Tokio I/O 性能调优:从 bufreader 到 vectored I/O 的进化路线

一、从裸 readBufReader:为什么缓冲这么重要?

我最初的代码是这样直接读文件的:

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 优化的进化路线很清晰:

  1. 第一步:上 BufReader / BufWriter——这是性价比最高的优化,改几行代码就能消灭 99% 的无效系统调用。
  2. 第二步:调优缓冲区大小——默认 8KB 适合大多数场景,大文件顺序读写可以调到 32KB-256KB。
  3. 第三步:vectored I/O——当你的数据天然是分段存储的(header/body/footer、网络协议帧),用 readv/writev 减少系统调用次数。

作为程序员,我之前确实不太懂操作系统的系统调用开销,但通过这次调优,我真正理解了"为什么内存操作比磁盘操作快"背后的原因。这些基础概念虽然枯燥,但在性能优化时,它就是最大的杠杆点。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐