21、异步I/O(AIO):POSIX AIO接口、aio_read/aio_write、回调处理
说到网络编程里的I/O模型,大家肯定都听过同步、阻塞、非阻塞这些词。但今天聊的这个——异步I/O(AIO),说实话,在C语言网络编程里用得不算特别多。但一旦你碰上高并发、低延迟的场景,它就是个杀手锏。
我个人习惯把AIO理解成「真正的异步」。什么意思呢?你调用一个读操作,函数立刻返回,然后系统在后台帮你把数据搬进缓冲区。搬完了,它再通知你。这期间你的CPU可以干别的事。嗯,这才是异步该有的样子。
POSIX AIO 接口概览
POSIX标准定义了一套异步I/O接口,头文件是 <aio.h>,链接时需要加 -lrt。核心接口就这几个:
| 函数 | 作用 |
|---|---|
aio_read |
发起异步读操作 |
aio_write |
发起异步写操作 |
aio_error |
检查异步操作的状态(进行中/完成/错误) |
aio_return |
获取异步操作的返回值(读了多少字节等) |
aio_suspend |
阻塞等待一组异步操作完成 |
aio_cancel |
取消一个正在进行的异步操作 |
这些函数操作的核心是一个结构体——struct aiocb。它描述了你要读/写哪个文件描述符、数据放哪、读多少、以及怎么通知你。
struct aiocb 结构体
这个结构体你得记住,因为所有AIO操作都围着它转。关键字段如下:
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量(对socket无效)
volatile void *aio_buf; // 数据缓冲区
size_t aio_nbytes; // 要读写的字节数
int aio_reqprio; // 请求优先级(通常设为0)
struct sigevent aio_sigevent; // 异步事件通知方式
int aio_lio_opcode; // 仅用于lio_listio
};
这里有个坑——aio_offset。对于socket、pipe这类不支持随机访问的描述符,这个字段会被忽略。但如果你操作的是普通文件,它就有用了。我曾经在项目里犯过这个错:给socket设了个非零偏移量,结果读回来的数据全是乱的。排查了半天才发现是偏移量的问题。
aio_read 与 aio_write 实战
先看一个最简单的异步读例子。假设我们要从一个socket描述符里读数据:
#include <aio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>
void read_handler(union sigval val) {
// 回调函数,后面细讲
struct aiocb *cb = (struct aiocb *)val.sival_ptr;
ssize_t ret = aio_return(cb);
printf("读取完成,共 %zd 字节\n", ret);
}
int main() {
int sockfd; // 假设已经创建并连接好了
char buf[1024];
struct aiocb cb = {0};
struct sigevent ev = {0};
cb.aio_fildes = sockfd;
cb.aio_buf = buf;
cb.aio_nbytes = sizeof(buf) - 1;
// 设置回调通知
ev.sigev_notify = SIGEV_THREAD;
ev.sigev_notify_function = read_handler;
ev.sigev_value.sival_ptr = &cb;
cb.aio_sigevent = ev;
if (aio_read(&cb) == -1) {
perror("aio_read");
return -1;
}
// 这里可以做其他事情
printf("异步读已发起,主线程继续工作...\n");
pause(); // 等待回调触发
return 0;
}
你看,aio_read 调用完就返回了,不阻塞。数据什么时候到?由系统决定。到了之后,通过回调通知你。
aio_write 的用法一模一样,只是把 aio_read 换成 aio_write。写操作也是异步的——你提交一个写请求,函数立刻返回,系统在后台把数据从你的缓冲区搬出去。
回调处理:三种通知方式
异步操作完成后,系统怎么通知你?POSIX AIO提供了三种方式:
- SIGEV_NONE:不通知。你得自己轮询检查状态。
- SIGEV_SIGNAL:发一个信号。信号处理函数里处理结果。
- SIGEV_THREAD:启动一个新线程,调用你指定的回调函数。
我个人最推荐 SIGEV_THREAD。为什么?信号处理太麻烦,有很多限制(比如不能调用某些非重入函数)。而回调函数跑在一个独立线程里,你可以在里面随便调用 aio_return、aio_error,甚至发起新的AIO操作。
回调函数的签名是固定的:
void callback_function(union sigval val);
这个 val 里你可以传任何东西。我通常传 struct aiocb 的指针,这样回调里就能拿到这次操作的所有信息。
sival_ptr 里传一个自定义结构体,里面包含aiocb、上下文信息、错误码等。这样回调函数里就能区分是哪个操作完成了。
检查操作状态:aio_error 与 aio_return
有时候你不想用回调,而是想自己检查操作是否完成。这时候用 aio_error:
int err = aio_error(&cb);
if (err == EINPROGRESS) {
// 还在进行中
} else if (err == 0) {
// 完成了!调用 aio_return 获取结果
ssize_t ret = aio_return(&cb);
printf("读取了 %zd 字节\n", ret);
} else {
// 出错了
printf("AIO错误: %s\n", strerror(err));
}
注意:aio_return 每个aiocb只能调用一次。调用之后,系统会释放内部资源。你再调第二次,行为是未定义的。嗯,这个坑我也踩过。
完整流程图:AIO核心流程
下面这张图总结了POSIX AIO的完整生命周期。从发起请求到完成通知,一目了然。
避坑指南
最后,分享几个我踩过的坑:
- 缓冲区生命周期:异步操作完成前,绝对不要动
aio_buf指向的内存。我曾经在循环里重用了一个栈上的缓冲区,结果前一个异步操作还没完成,数据就被覆盖了。 - aio_return 只能调用一次:这个前面说过。如果你忘了这个限制,第二次调用会返回一个错误码,甚至导致程序崩溃。
- 不要混用同步和异步操作:对同一个文件描述符,不要同时用
read和aio_read。内核的I/O状态机可能会乱掉。我见过有人这么干,结果读出来的数据顺序全错了。 - 线程安全:回调函数跑在独立线程里,如果你的主线程也在操作同一个数据结构,记得加锁。别问我怎么知道的。
POSIX AIO 虽然不如 epoll 那么流行,但在某些场景下——比如你需要同时读写大量文件,或者想避免线程池的上下文切换开销——它是个非常优雅的解决方案。说白了,它就是「把I/O这件事彻底交给系统,你只管等结果」。用好了,性能提升很明显。