14、异步编程:异步I/O的概念、aio_read/aio_write、异步编程模型

各位同学,今天我们来聊聊异步I/O。说实话,这个话题在C语言并发编程里,算是一个「硬骨头」。我早年做网络服务器开发时,就被同步阻塞I/O坑过不少次——一个慢速的磁盘操作,能把整个事件循环卡死。后来用了异步I/O,才算是真正把CPU的潜力榨干了。

14.1 为什么需要异步I/O?

先问大家一个问题:你写一个程序,要从磁盘读一个大文件,同时还要处理网络请求。你会怎么做?

传统做法是开线程。一个线程读文件,另一个线程处理网络。这没问题,但线程多了,上下文切换的开销可不小。尤其在高并发场景下,成千上万个线程同时跑,光是切换就能把CPU吃光。

另一种做法是用非阻塞I/O + 事件循环。比如epoll、select这些。但问题是,磁盘I/O在Linux上并不支持真正的非阻塞——你调用read,它还是会阻塞在等待数据上。

嗯,这就是异步I/O要解决的问题。它让你可以发起一个I/O操作,然后立刻返回去做别的事。等I/O完成了,系统会通知你。说白了,就是「我发起,我不管,你好了叫我」。

核心思想:异步I/O把I/O操作的等待时间,从你的代码里剥离出去。你的线程不用傻等,可以继续处理其他任务。

14.2 POSIX异步I/O:aio_read / aio_write

POSIX标准提供了一套异步I/O接口,核心就是aio_readaio_write。它们定义在头文件中。

先看一个简单的例子:

#include <aio.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main() {
    int fd = open("data.bin", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }

    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(1);
    }

    printf("读操作已发起,我可以做别的事了...\n");

    // 模拟做其他工作
    sleep(1);

    // 检查操作是否完成
    while (aio_error(&cb) == EINPROGRESS) {
        printf("还在读...\n");
        usleep(100000);
    }

    // 获取返回值
    ssize_t ret = aio_return(&cb);
    if (ret == -1) {
        perror("aio_return");
    } else {
        printf("成功读取 %zd 字节\n", ret);
    }

    close(fd);
    return 0;
}

这段代码里,我调用了aio_read后,程序立刻打印「读操作已发起」,然后去睡了1秒。这1秒里,内核在后台帮我读数据。我不用等,想干嘛干嘛。

个人习惯:我一般会在发起异步I/O后,立刻记录下aiocb的指针,放到一个任务队列里。这样后面可以统一检查所有未完成的操作。

14.3 异步I/O的核心接口

除了aio_readaio_write,还有几个接口你一定会用到:

函数 作用
aio_read 发起异步读操作
aio_write 发起异步写操作
aio_error 检查异步操作的状态(返回0表示完成,EINPROGRESS表示进行中)
aio_return 获取异步操作的返回值(类似read/write的返回值)
aio_suspend 阻塞等待一个或多个异步操作完成
aio_cancel 取消一个正在进行的异步操作

你想想看,有了这些接口,你就可以同时发起多个I/O操作,然后统一等待。比如同时读10个文件,每个文件都调用aio_read,然后调用aio_suspend等它们全部完成。

14.4 异步编程模型:事件驱动

异步I/O最常用的编程模型,就是事件驱动。说白了,就是「你发起操作,然后注册一个回调,等操作完成时回调被调用」。

POSIX异步I/O支持两种通知方式:

  1. 信号通知:操作完成时,向进程发送一个信号(比如SIGIO)。你需要在信号处理函数里处理结果。
  2. 回调通知:通过aio_suspend主动等待,或者使用struct sigevent指定一个线程函数来执行回调。

我个人更推荐回调方式。信号处理函数里能做的事太有限,而且容易出问题。用回调函数,代码逻辑更清晰。

看一个使用回调的例子:

#include <aio.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>

void aio_completion_handler(sigval_t sigval) {
    struct aiocb *req = (struct aiocb *)sigval.sival_ptr;
    ssize_t ret = aio_return(req);
    printf("异步操作完成,读取了 %zd 字节\n", ret);
    // 这里可以处理数据...
}

int main() {
    int fd = open("data.bin", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }

    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;

    // 设置回调通知
    cb.aio_sigevent.sigev_notify          = SIGEV_THREAD;
    cb.aio_sigevent.sigev_notify_function = aio_completion_handler;
    cb.aio_sigevent.sigev_value.sival_ptr = &cb;

    if (aio_read(&cb) == -1) {
        perror("aio_read");
        close(fd);
        exit(1);
    }

    printf("主线程继续做其他事...\n");
    sleep(2);  // 模拟其他工作

    close(fd);
    return 0;
}

这里我用了SIGEV_THREAD,意思是操作完成时,系统会创建一个新线程来执行aio_completion_handler。注意,这个回调函数是在新线程里跑的,所以要注意线程安全问题。

我曾经踩过的坑:回调函数里直接操作了全局变量,没加锁。结果两个异步操作同时完成,两个线程同时修改同一个变量,数据全乱了。后来老老实实加了互斥锁。

14.5 异步I/O的完整流程图

下面这张图展示了异步I/O的完整流程,从发起操作到完成通知:

主线程 aio_read() 内核 提交I/O请求 后台处理I/O I/O完成 通知(信号/回调) 回调函数执行 处理结果 继续执行其他任务

从图里可以看到,主线程发起aio_read后,内核在后台处理I/O,主线程可以继续做其他事。等I/O完成,内核通过信号或回调通知主线程,主线程再处理结果。整个过程是异步的,没有阻塞。

14.6 异步I/O的注意事项

说实话,异步I/O虽然强大,但用起来有不少坑。我总结了几点:

  • 缓冲区必须保持有效:异步操作完成前,你不能释放或修改缓冲区。否则数据会乱。我一般用动态分配的内存,操作完成后再释放。
  • 文件描述符不能关闭:异步操作进行中,不要关闭文件描述符。后果很严重——可能读到别的文件的数据。
  • 注意线程安全:回调函数是在另一个线程里执行的。如果你在回调里访问共享数据,记得加锁。
  • 系统资源限制:每个异步操作都会占用内核资源。同时发起太多,可能会达到系统上限。可以用ulimit -a查看。
  • 性能不一定比多线程好:对于少量大块I/O,多线程可能更简单。异步I/O的优势在于大量并发的小I/O操作。

我的经验:在项目中,我通常把异步I/O和事件循环结合起来用。主循环里用aio_suspend等待一组操作,超时了就处理其他事件。这样既不会阻塞,又能及时响应。

14.7 异步编程模型总结

异步I/O的核心,就是把「等待」这件事从你的代码里移出去。你发起操作,然后去做别的事。等操作完成,系统通知你。这种模型特别适合I/O密集型的应用,比如文件服务器、数据库引擎、高性能网络服务。

但要注意,异步I/O不是银弹。如果你的程序主要是CPU计算,那异步I/O帮不了你多少。它只解决I/O等待的问题,不解决计算瓶颈。

嗯,最后说一句:异步编程的思维方式和同步编程完全不同。你需要习惯「发起-回调」的模式,而不是「调用-等待-返回」。刚开始可能会觉得别扭,但用多了就会发现,它真的很优雅。


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