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。写操作也是异步的——你提交一个写请求,函数立刻返回,系统在后台把数据从你的缓冲区搬出去。

注意:异步操作期间,你的缓冲区(aio_buf指向的内存)不能被释放或重用。系统还在后台读写它呢。你想想看,如果你在主线程里把buf给free了,系统那边正往里面写数据,那不就野指针了?我见过有人犯这个错,程序跑着跑着就段错误,查了好久才发现是缓冲区生命周期没管理好。

回调处理:三种通知方式

异步操作完成后,系统怎么通知你?POSIX AIO提供了三种方式:

  1. SIGEV_NONE:不通知。你得自己轮询检查状态。
  2. SIGEV_SIGNAL:发一个信号。信号处理函数里处理结果。
  3. SIGEV_THREAD:启动一个新线程,调用你指定的回调函数。

我个人最推荐 SIGEV_THREAD。为什么?信号处理太麻烦,有很多限制(比如不能调用某些非重入函数)。而回调函数跑在一个独立线程里,你可以在里面随便调用 aio_returnaio_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的完整生命周期。从发起请求到完成通知,一目了然。

POSIX AIO 核心流程 1. 初始化 aiocb 2. 设置 sigevent 通知方式 3. aio_read / aio_write 4. 等待完成(回调/轮询) 5. aio_return 获取结果 关键注意点 • 缓冲区生命周期管理 • aio_return 只能调一次 • socket 忽略 aio_offset • 回调线程注意线程安全 • 不要混用同步/异步操作 • 检查 aio_error 返回值 • 大并发时注意系统限制 • 使用 SIGEV_THREAD 更安全

避坑指南

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

  • 缓冲区生命周期:异步操作完成前,绝对不要动 aio_buf 指向的内存。我曾经在循环里重用了一个栈上的缓冲区,结果前一个异步操作还没完成,数据就被覆盖了。
  • aio_return 只能调用一次:这个前面说过。如果你忘了这个限制,第二次调用会返回一个错误码,甚至导致程序崩溃。
  • 不要混用同步和异步操作:对同一个文件描述符,不要同时用 readaio_read。内核的I/O状态机可能会乱掉。我见过有人这么干,结果读出来的数据顺序全错了。
  • 线程安全:回调函数跑在独立线程里,如果你的主线程也在操作同一个数据结构,记得加锁。别问我怎么知道的。

POSIX AIO 虽然不如 epoll 那么流行,但在某些场景下——比如你需要同时读写大量文件,或者想避免线程池的上下文切换开销——它是个非常优雅的解决方案。说白了,它就是「把I/O这件事彻底交给系统,你只管等结果」。用好了,性能提升很明显。

总结:POSIX AIO 的核心是 struct aiocb + aio_read/aio_write + 回调处理。记住三点:缓冲区生命周期、aio_return只调一次、回调线程注意安全。掌握了这些,你就能写出真正的异步I/O程序。
公众号:蓝海资料掘金营,微信deep3321