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_read和aio_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_read和aio_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支持两种通知方式:
- 信号通知:操作完成时,向进程发送一个信号(比如SIGIO)。你需要在信号处理函数里处理结果。
- 回调通知:通过
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完成,内核通过信号或回调通知主线程,主线程再处理结果。整个过程是异步的,没有阻塞。
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