15、I/O性能优化:缓冲区设置、mmap vs read/write、异步I/O(aio)基础

说到I/O性能,很多嵌入式开发的朋友第一反应就是「慢」。确实,在CPU和内存面前,磁盘I/O简直就是龟速。我这些年调过不少I/O相关的性能问题,说实话,大部分瓶颈都不是硬件不行,而是软件层面没用好。

这一章,咱们就聊聊三个核心话题:缓冲区怎么设mmap到底比read/write强在哪、以及异步I/O怎么玩。嗯,都是实战中绕不开的东西。

15.1 缓冲区设置:别小看这块内存

先问个问题:你写文件的时候,是一次写1字节快,还是一次写4KB快?

答案显而易见。但很多人实际写代码时,偏偏就喜欢小碎块地写。我在项目中遇到过一位同事,他写日志是一行一行fprintf,结果日志文件一上GB,程序卡得不行。后来改成攒够4KB再flush,性能直接翻了三倍。

为什么会这样?因为每次系统调用(read/write)都有开销——上下文切换、内核态用户态切换。你调用次数越多,开销越大。

核心原则:减少系统调用次数,就是提升I/O性能最直接的手段。

那缓冲区设多大合适?我个人习惯是:

  • 块设备(如硬盘、SD卡):4KB ~ 64KB。因为文件系统块大小通常是4KB,对齐了效率最高。
  • 网络I/O:1KB ~ 16KB。太大反而容易阻塞,太小又浪费带宽。
  • 字符设备(如串口):64字节 ~ 256字节。串口速度慢,缓冲区太大没意义。

另外,别忘了setvbuf这个函数。标准C库默认是行缓冲或全缓冲,你可以手动改成无缓冲或自定义大小。比如:

#include <stdio.h>

int main() {
    FILE *fp = fopen("data.bin", "wb");
    if (!fp) return -1;

    // 设置64KB缓冲区
    char *buf = malloc(64 * 1024);
    setvbuf(fp, buf, _IOFBF, 64 * 1024);

    // 现在写数据,会攒到64KB才真正写入磁盘
    for (int i = 0; i < 1000000; i++) {
        fwrite(&i, sizeof(int), 1, fp);
    }

    fclose(fp);
    free(buf);
    return 0;
}

小技巧:如果你不确定缓冲区大小,可以用stat命令查看文件系统的st_blksize,那就是最优块大小。

15.2 mmap vs read/write:谁更快?

这个问题我经常被问到。说实话,没有绝对的答案,得看场景。

read/write 是传统的读写方式。数据从磁盘到内核缓冲区,再从内核缓冲区拷贝到用户空间。两次拷贝,开销不小。

mmap 则是把文件直接映射到进程的地址空间。你操作内存,就是操作文件。省去了内核到用户空间的拷贝。说白了,就是「零拷贝」的一种实现。

我在项目中遇到过这样一个场景:需要频繁读取一个几百MB的配置文件,每次只读其中一小部分。用read/write的话,每次都要从头读到尾,太浪费了。换成mmap后,按需加载,内存占用小,速度也快。

但mmap也不是万能的。它有几个坑:

  • 小文件不划算:mmap本身有页对齐和映射开销,文件小于4KB时,不如直接read。
  • 随机写性能差:mmap的写操作会触发缺页中断,频繁随机写反而比write慢。
  • 内存压力:映射大文件会占用虚拟地址空间,32位系统上尤其要注意。

来看个对比代码:

// read/write方式
int fd = open("data.bin", O_RDONLY);
char buf[4096];
read(fd, buf, 4096);
close(fd);

// mmap方式
int fd = open("data.bin", O_RDONLY);
char *map = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
// 直接使用 map[0] 到 map[4095]
munmap(map, 4096);
close(fd);

注意:mmap映射的文件如果被截断或删除,访问映射区域会触发SIGBUS信号,程序直接崩溃。我曾经因为这个踩过坑,后来加了个信号处理函数才搞定。

总结一下我的选择原则:

场景 推荐方式 原因
顺序读写大文件(>1MB) read/write 流式处理,缓存友好
随机访问大文件 mmap 按需加载,省去拷贝
小文件(<4KB) read/write mmap开销太大
需要共享内存 mmap (MAP_SHARED) 进程间通信利器

15.3 异步I/O(aio)基础

前面说的read/write和mmap,都是同步的。你调用read,线程就卡在那,直到数据回来。如果I/O很慢,CPU就闲着,浪费啊。

异步I/O(AIO)就是来解决这个问题的。你发起一个I/O请求,然后立刻返回,继续干别的事。等I/O完成了,系统通知你。说白了,就是「你忙你的,我忙我的」。

Linux下的AIO主要有两套:

  • POSIX AIO(aio_read/aio_write):用户态实现,线程池模拟异步。简单但性能一般。
  • Linux AIO(io_submit/io_getevents):内核原生支持,性能好,但接口复杂。

我个人更推荐POSIX AIO,因为跨平台性好,而且对于大多数嵌入式场景,性能已经够用了。

来看个例子:

#include <aio.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main() {
    int fd = open("data.bin", O_RDONLY);
    if (fd == -1) return -1;

    char buf[4096];
    struct aiocb cb;
    memset(&cb, 0, sizeof(cb));
    cb.aio_fildes = fd;
    cb.aio_buf = buf;
    cb.aio_nbytes = 4096;
    cb.aio_offset = 0;

    // 发起异步读
    if (aio_read(&cb) == -1) {
        close(fd);
        return -1;
    }

    // 这里可以做别的事
    printf("I/O已发起,先干点别的...\n");

    // 等待完成
    while (aio_error(&cb) == EINPROGRESS) {
        // 可以轮询,也可以用信号或回调
    }

    // 检查结果
    ssize_t ret = aio_return(&cb);
    if (ret > 0) {
        printf("读取了 %zd 字节\n", ret);
    }

    close(fd);
    return 0;
}

避坑指南:我曾经在项目中用AIO读串口数据,结果发现aio_read返回后,数据还没完全到。后来才意识到,串口是流式设备,aio_read只保证「发起读」,不保证「读完」。需要配合aio_suspend或信号来确认。

AIO的典型应用场景:

  • 高并发网络服务器:同时处理大量连接,每个连接都有I/O操作。
  • 数据库系统:日志写入、数据页读取,需要异步完成。
  • 多媒体播放器:一边解码一边读文件,不卡顿。

15.4 知识体系总览

下面这张图,帮你理清本章的核心脉络:

I/O性能优化知识体系 缓冲区设置 块设备4KB~64KB 网络1KB~16KB 串口64B~256B mmap vs read/write 顺序读写→read/write 随机访问→mmap 小文件→read/write 异步I/O(AIO) POSIX AIO Linux AIO 高并发场景 核心目标:减少系统调用 + 避免不必要拷贝 + 充分利用等待时间 选择策略:看场景、看数据量、看并发度

嗯,I/O优化这块,说白了就是「减少等待、减少拷贝、减少调用」。你想想看,每次系统调用都是成本,每次数据拷贝都是浪费。把这些细节抠好了,性能自然就上去了。

我在实际项目中,经常是先用read/write把功能跑通,然后根据性能瓶颈,再决定要不要换成mmap或AIO。别一开始就上最复杂的方案,简单够用才是王道。


公众号:蓝海资料掘金营,微信deep3321