文件操作性能优化:减少系统调用次数、使用大缓冲区、异步IO简介

做嵌入式开发这些年,我踩过最多的坑就是文件IO性能。你想想看,一个嵌入式设备动不动就要写日志、存配置、记录传感器数据,如果IO效率上不去,整个系统都会被拖慢。今天咱们就聊聊怎么把文件操作跑得更快。

一、性能瓶颈到底在哪?

先看一个典型场景。假设你要往文件里写1000个字节,每次写1个字节。你猜会发生什么?

// 低效写法
for (int i = 0; i < 1000; i++) {
    write(fd, buf + i, 1);
}

这段代码会触发1000次系统调用。每次系统调用都要从用户态切换到内核态,保存上下文、检查权限、拷贝数据……开销非常大。我在一个数据采集项目里就吃过这个亏,当时每秒要写几千条记录,结果CPU全耗在系统调用上了,采集任务反而跟不上。

核心结论:系统调用次数越少,IO性能越好。减少系统调用是优化的第一要务。

二、减少系统调用的三板斧

1. 合并读写操作

把多次小数据量的读写合并成一次大数据量的操作。说白了就是攒够了再干。

// 高效写法
char buffer[1024];
// 先填充buffer
write(fd, buffer, 1024);  // 一次系统调用搞定

我建议你在设计上层接口时就考虑这一点。比如日志模块,可以先把日志拼成一行,攒够4KB再一次性写入。

2. 使用标准库的缓冲机制

C标准库的fread/fwrite内部已经做了缓冲。默认情况下,它们会维护一个缓冲区,满了才真正调用系统调用。

FILE *fp = fopen("data.bin", "wb");
fwrite(data, 1, 100, fp);  // 可能还在缓冲区里
fclose(fp);                 // 关闭时才会刷到磁盘

嗯,这里要注意:fclose会自动刷新缓冲区。但如果你程序突然崩溃,缓冲区里的数据就丢了。所以关键数据记得手动fflush

3. 调整缓冲区大小

缓冲区不是越大越好,但太小了肯定不行。我做过测试,缓冲区从1字节增加到4KB,性能提升非常明显。再往上加,收益就递减了。

缓冲区大小 写入100MB耗时 系统调用次数
1字节 约12秒 1亿次
1KB 约0.3秒 10万次
4KB 约0.08秒 2.5万次
64KB 约0.06秒 1600次

从表格能看出来,4KB到64KB的提升已经不明显了。我个人习惯用4KB或8KB,兼顾性能和内存占用。

三、大缓冲区策略

大缓冲区说白了就是「空间换时间」。但怎么用有讲究。

1. 预分配缓冲区

不要在循环里反复malloc/free。一次性分配好,重复使用。

#define BUF_SIZE (64 * 1024)  // 64KB
static char buffer[BUF_SIZE];
static int pos = 0;

void buffered_write(int fd, const char *data, int len) {
    if (pos + len > BUF_SIZE) {
        write(fd, buffer, pos);  // 刷出去
        pos = 0;
    }
    memcpy(buffer + pos, data, len);
    pos += len;
}

这种模式在嵌入式里很常见。我曾经在一个RTOS项目里用这个方案,把SD卡的写入速度从200KB/s提升到了1.2MB/s。

2. 对齐问题

警告:很多嵌入式存储设备(如SD卡、NAND Flash)要求缓冲区地址对齐。比如SD卡通常要求4字节对齐,有些甚至要求512字节对齐。不对齐的话,驱动层会做额外的拷贝,性能直接打折扣。

我建议你分配缓冲区时用posix_memalign或者自己实现对齐分配函数。

四、异步IO简介

前面讲的都是同步IO——程序发出读写请求后,必须等操作完成才能继续。异步IO就不一样了:你发起请求后,可以继续干别的事,等IO完成了再通知你。

1. 为什么需要异步IO?

你想想看,如果程序一边要处理网络数据,一边要写日志。同步IO的话,写日志时网络数据就堆积了。异步IO可以让你「同时」做这两件事。

2. Linux下的异步IO方式

Linux提供了几种异步IO机制:

  • aio系列函数aio_read/aio_write,POSIX标准,但实际用起来坑不少。
  • io_uring:Linux 5.1引入的新机制,性能非常好,但需要较新内核。
  • epoll + 非阻塞IO:虽然不是严格意义上的异步IO,但很多场景下够用了。

我个人更推荐io_uring,前提是你的内核版本够新。它在高并发场景下表现非常出色。

3. 一个简单的异步IO示例

#include <aio.h>
#include <errno.h>

struct aiocb cb;
char buffer[4096];

// 准备异步读
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = sizeof(buffer);
cb.aio_offset = 0;

if (aio_read(&cb) == -1) {
    perror("aio_read");
    return -1;
}

// 这里可以做其他事情
do_other_work();

// 检查是否完成
while (aio_error(&cb) == EINPROGRESS) {
    // 等待或做其他事
}

// 获取结果
ssize_t ret = aio_return(&cb);

提示:异步IO的编程模型比同步复杂得多。我建议你先用同步+大缓冲区的方式优化,如果性能还不够,再考虑异步IO。不要一开始就上异步,容易把自己绕进去。

五、知识体系总览

下面这张图总结了文件操作性能优化的核心思路:

文件操作性能优化核心思路 性能优化 减少系统调用 大缓冲区策略 异步IO 合并读写操作 使用标准库缓冲 调整缓冲区大小 预分配缓冲区 内存对齐 空间换时间 aio系列函数 io_uring epoll+非阻塞IO 实践建议 1. 优先用大缓冲区+合并操作,简单有效 2. 注意内存对齐,避免驱动层额外拷贝 3. 异步IO虽好,但复杂度高,按需使用

六、避坑指南

最后分享几个我踩过的坑:

  • 不要迷信大缓冲区:我曾经把缓冲区设到1MB,结果内存碎片严重,系统反而变慢了。4KB到64KB是比较合理的范围。
  • 注意文件系统差异:FAT32和ext4的IO特性完全不同。我在一个项目里用FAT32,频繁小文件写入导致文件系统碎片化严重,后来改成ext4才解决。
  • 异步IO的坑:aio系列函数在glibc的实现里,底层还是用线程池模拟的,并不是真正的异步。io_uring才是真异步,但需要内核支持。

嗯,文件操作性能优化就聊到这里。记住一个原则:减少系统调用次数,合理使用缓冲区。大部分场景下,这两招就够用了。


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