40、异步IO:aio_read/aio_write。标准库与POSIX AIO的结合。

说到文件IO,大家脑子里蹦出来的多半是freadfwrite这些同步函数。调用下去,线程就卡在那儿,直到数据从磁盘搬到内存里。这在很多场景下没问题,但如果你在写高性能服务器、数据库引擎,或者嵌入式里做数据采集——嗯,这种“等”就太奢侈了。

异步IO,说白了就是“你先忙着,我回头再找你拿结果”。POSIX标准里提供了aio_readaio_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的核心流程和各个组件之间的关系:

POSIX AIO 异步IO核心流程 用户程序 struct aiocb aio_fildes | aio_buf | aio_nbytes | aio_offset aio_read() aio_write() aio_suspend() aio_error() / aio_return()

避坑指南:我踩过的几个雷

  • 缓冲区生命周期:异步操作完成前,缓冲区必须有效。栈上分配的局部变量尤其危险。
  • 文件偏移量:每次操作前都要显式设置 aio_offset。系统不会帮你维护。
  • 错误检查:aio_read 返回 -1 只表示请求提交失败。操作本身的错误要通过 aio_error 获取。
  • 信号安全:如果使用信号通知,信号处理函数里只能做异步安全的事。别在里面调用 printf
  • 平台差异:POSIX AIO 在 Linux 上由 glibc 实现,但底层可能退化为同步。macOS 和 BSD 上的行为也有差异。写跨平台代码时要小心。

什么时候该用,什么时候不该用?

说实话,POSIX AIO 并不是万能的。我个人的经验是:

  • 适合:需要同时处理多个文件IO、IO延迟较高、不想为每个IO开线程的场景。
  • 不适合:IO操作非常快(比如内存映射文件)、需要大量并发IO(此时 io_uringlibaio 更合适)。

如果你在Linux上做高性能开发,我建议也了解一下 io_uring。它是内核原生支持的异步IO框架,性能比POSIX AIO好很多。但POSIX AIO的优势在于可移植性——几乎所有类Unix系统都支持。

一个小技巧:aio_suspend 替代轮询。它可以同时等待多个异步操作,而且不占CPU。传一个 aiocb 指针数组进去,它会阻塞直到其中任何一个操作完成。

好了,关于POSIX AIO的核心用法就讲到这里。这套接口虽然不如 io_uring 那么新潮,但在很多老项目里依然活跃。理解它的设计思想,对你理解整个异步编程模型都有帮助。


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