40、异步IO:aio_read/aio_write。标准库与POSIX AIO的结合。
说到文件IO,大家脑子里蹦出来的多半是fread、fwrite这些同步函数。调用下去,线程就卡在那儿,直到数据从磁盘搬到内存里。这在很多场景下没问题,但如果你在写高性能服务器、数据库引擎,或者嵌入式里做数据采集——嗯,这种“等”就太奢侈了。
异步IO,说白了就是“你先忙着,我回头再找你拿结果”。POSIX标准里提供了aio_read和aio_write这套接口,让我们能在C语言里实现真正的非阻塞文件读写。今天我就带大家把这套东西捋清楚。
为什么需要异步IO?
我刚开始做项目时,也用过select+非阻塞IO那套。但那是针对网络socket的,对普通文件描述符并不适用。你想想看,磁盘IO的延迟是毫秒级的,CPU在这段时间里完全可以干别的事。如果每个读写都同步阻塞,并发能力就被锁死了。
举个例子:一个日志采集程序,要从多个文件里同时读数据。如果用同步IO,就得一个文件一个文件地轮着读。读第一个文件时,其他文件的数据可能已经丢了。异步IO可以同时发起多个读请求,然后统一等待结果——效率完全不是一个量级。
核心区别一句话:同步IO是“你等我做完”,异步IO是“你做你的,做完通知我”。
POSIX AIO 的核心接口
POSIX AIO 定义在 aio.h 头文件中。主要函数就这几个:
| 函数 | 作用 |
|---|---|
aio_read |
发起异步读请求 |
aio_write |
发起异步写请求 |
aio_error |
检查异步操作的状态 |
aio_return |
获取异步操作的返回值(读到的字节数等) |
aio_suspend |
等待一个或多个异步操作完成 |
aio_cancel |
取消一个正在进行的异步操作 |
每个异步操作都需要一个 struct aiocb 结构体来描述。这个结构体里包含了文件描述符、缓冲区指针、请求的字节数、偏移量等信息。
struct aiocb 结构体详解
#include <aio.h>
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 缓冲区
size_t aio_nbytes; // 请求的字节数
int aio_reqprio; // 请求优先级(通常设为0)
struct sigevent aio_sigevent; // 完成通知方式
int aio_lio_opcode; // 仅用于lio_listio
};
这里有个细节我特别想提醒你:aio_buf 被声明为 volatile。为什么?因为异步操作是在后台进行的,编译器可能会优化掉对缓冲区的访问。加上 volatile 就是告诉编译器:“别乱动这块内存,后台线程还在用呢。”
我的习惯:每次使用 aiocb 前,我都会用 memset 把整个结构体清零。不然有些字段残留了旧值,排查起来非常痛苦。我曾经因为这个浪费了整整一个下午。
异步读:aio_read 实战
来看一个完整的例子。我们要从一个文件中异步读取前1024个字节:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <aio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
int main() {
int fd = open("data.bin", O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
char buf[1024];
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = sizeof(buf);
cb.aio_offset = 0;
// 发起异步读
if (aio_read(&cb) == -1) {
perror("aio_read");
close(fd);
exit(EXIT_FAILURE);
}
printf("异步读请求已提交,做点别的事...\n");
// 轮询检查状态
while (aio_error(&cb) == EINPROGRESS) {
// 可以在这里做其他工作
printf("还在读...\n");
usleep(100000); // 100ms
}
// 获取结果
ssize_t ret = aio_return(&cb);
if (ret == -1) {
perror("aio_return");
} else {
printf("成功读取 %zd 字节\n", ret);
}
close(fd);
return 0;
}
这段代码里,aio_read 一调用就返回了,不会阻塞。然后我们可以用 aio_error 轮询操作是否完成。当返回不再是 EINPROGRESS 时,就说明操作结束了。
注意:在异步操作完成之前,绝对不能释放或修改 aio_buf 指向的缓冲区。我曾经犯过这个错——在栈上分配了缓冲区,函数返回后缓冲区被销毁,后台线程还在往里面写数据,结果就是段错误。排查了整整两天。
异步写:aio_write 用法
写操作和读操作几乎对称。区别在于数据流向:从缓冲区写到文件。来看代码:
void async_write_example() {
int fd = open("output.bin", O_WRONLY | O_CREAT, 0644);
if (fd == -1) {
perror("open");
return;
}
const char *data = "Hello, Async World!";
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = (void*)data;
cb.aio_nbytes = strlen(data);
cb.aio_offset = 0;
if (aio_write(&cb) == -1) {
perror("aio_write");
close(fd);
return;
}
// 等待完成
while (aio_error(&cb) == EINPROGRESS) {
// 干点别的
}
ssize_t written = aio_return(&cb);
printf("写入了 %zd 字节\n", written);
close(fd);
}
这里有个坑:aio_offset 必须显式设置。对于普通文件,系统不会自动维护文件位置指针。如果你连续发起两次写操作,第二次的偏移量需要你自己计算。我一般会在每次操作前重新设置 aio_offset,避免出错。
完成通知:不用轮询行不行?
轮询虽然简单,但浪费CPU。POSIX AIO 提供了更优雅的方式——信号或回调。通过设置 aio_sigevent 字段,可以在操作完成时收到通知。
struct aiocb cb;
// ... 其他字段设置 ...
cb.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
cb.aio_sigevent.sigev_signo = SIGUSR1;
cb.aio_sigevent.sigev_value.sival_ptr = &cb;
// 或者使用线程回调
cb.aio_sigevent.sigev_notify = SIGEV_THREAD;
cb.aio_sigevent.sigev_notify_function = my_callback;
cb.aio_sigevent.sigev_notify_attributes = NULL;
我个人更倾向于用信号方式。回调函数在线程中执行,要注意线程安全问题。信号处理函数里能做的事有限,但至少能设置一个标志位,让主循环去处理结果。
知识体系结构图
下面这张图展示了POSIX AIO的核心流程和各个组件之间的关系:
避坑指南:我踩过的几个雷
- 缓冲区生命周期:异步操作完成前,缓冲区必须有效。栈上分配的局部变量尤其危险。
- 文件偏移量:每次操作前都要显式设置
aio_offset。系统不会帮你维护。 - 错误检查:
aio_read返回 -1 只表示请求提交失败。操作本身的错误要通过aio_error获取。 - 信号安全:如果使用信号通知,信号处理函数里只能做异步安全的事。别在里面调用
printf。 - 平台差异:POSIX AIO 在 Linux 上由 glibc 实现,但底层可能退化为同步。macOS 和 BSD 上的行为也有差异。写跨平台代码时要小心。
什么时候该用,什么时候不该用?
说实话,POSIX AIO 并不是万能的。我个人的经验是:
- 适合:需要同时处理多个文件IO、IO延迟较高、不想为每个IO开线程的场景。
- 不适合:IO操作非常快(比如内存映射文件)、需要大量并发IO(此时
io_uring或libaio更合适)。
如果你在Linux上做高性能开发,我建议也了解一下 io_uring。它是内核原生支持的异步IO框架,性能比POSIX AIO好很多。但POSIX AIO的优势在于可移植性——几乎所有类Unix系统都支持。
一个小技巧:用 aio_suspend 替代轮询。它可以同时等待多个异步操作,而且不占CPU。传一个 aiocb 指针数组进去,它会阻塞直到其中任何一个操作完成。
好了,关于POSIX AIO的核心用法就讲到这里。这套接口虽然不如 io_uring 那么新潮,但在很多老项目里依然活跃。理解它的设计思想,对你理解整个异步编程模型都有帮助。
公众号:蓝海资料掘金营,微信deep3321