思考一个问题:aio 怎么通知到最初的线程的?
Linux AIO 的实现
Linux Native AIO
是原生 AIO,Linux 存在很多第三方异步 IO 库,如 libeio
和 glibc AIO
。很多第三方的异步 IO 库不是真正的异步 IO,而是用多线程来模拟异步 IO,如 libeio
就是使用多线程来模拟异步 IO 的。
- 程序调用
io_submit
发起异步 IO 操作后,会向内核的 IO 任务队列中添加一个 IO 任务,并返回成功。 - 内核会在后台处理队列中的任务,把处理结果存储在 IO 任务中。
- 应用程序可调用
io_getevents
系统调用来获取异步 IO 的处理结果,如果 IO 操作还没完成,那么返回失败信息,否则会返回 IO 处理结果。
主要由两个步骤组成:
- 调用
io_submit
函数发起一个异步 IO 操作。 - 调用
io_getevents
函数获取异步 IO 的结果。
在介绍 Linux 原生 AIO 的实现之前,先通过一个简单的例子来介绍其使用过程:
#define _GNU_SOURCE
#include <stdlib.h>
#include <string.h>
#include <libaio.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#define FILEPATH "./aio.txt"
int main()
{
io_context_t context;
struct iocb io[1], *p[1] = {&io[0]};
struct io_event e[1];
unsigned nr_events = 10;
struct timespec timeout;
char *wbuf;
int wbuflen = 1024;
int ret, num = 0, i;
posix_memalign((void **)&wbuf, 512, wbuflen);
memset(wbuf, '@', wbuflen);
memset(&context, 0, sizeof(io_context_t));
timeout.tv_sec = 0;
timeout.tv_nsec = 10000000;
int fd = open(FILEPATH, O_CREAT|O_RDWR|O_DIRECT, 0644); // 1. 打开要进行异步IO的文件
if (fd < 0) {
printf("open error: %d\n", errno);
return 0;
}
if (0 != io_setup(nr_events, &context)) { // 2. 创建一个异步IO上下文
printf("io_setup error: %d\n", errno);
return 0;
}
io_prep_pwrite(&io[0], fd, wbuf, wbuflen, 0); // 3. 创建一个异步IO任务
if ((ret = io_submit(context, 1, p)) != 1) { // 4. 提交异步IO任务
printf("io_submit error: %d\n", ret);
io_destroy(context);
return -1;
}
while (1) {
ret = io_getevents(context, 1, 1, e, &timeout); // 5. 获取异步IO的结果
if (ret < 0) {
printf("io_getevents error: %d\n", ret);
break;
}
if (ret > 0) {
printf("result, res2: %d, res: %d\n", e[0].res2, e[0].res);
break;
}
}
return 0;
}
主要有以下步骤:
- 调用
open
系统调用打开要进行异步 IO 的文件,要注意的是 AIO 操作必须设置O_DIRECT
直接 IO 标志位。 - 调用
io_setup
系统调用创建一个异步 IO 上下文。 - 调用
io_prep_pwrite
或io_prep_pread
函数创建一个异步写或读任务。 - 调用
io_submit
系统调用把异步 IO 任务提交到内核。 - 任务完成后会放到环形缓冲区
ring_info
- 调用
io_getevents
系统调用获取异步 IO 的结果。
在上面的例子中,获取异步 IO 操作的结果是在一个无限循环中进行的, Linux 还支持一种基于 eventfd
事件通知的机制,可以通过 eventfd
和 epoll
结合来实现事件驱动的方式来获取异步 IO 操作的结果。
glibc 的 AIO
- 异步请求被提交到 request_queue 中;request_queue 是个表结构,"行"是 fd、"列"是具体的请求。同一个 fd 的请求会被组织在一起;
- 请求有优先级概念,同一个 fd 的请求会按优先级排序执行。
- 随着异步请求提交,一些异步处理线程被动态创建。其从 request_queue 中取出请求处理;为避免异步处理线程间的竞争,同一 fd 所对应请求只由一个线程来处理;
- 异步处理线程同步处理每个请求,完成后在对应的 aiocb 中填充结果,然后触发可能的信号通知或回调(回调函数是需要创建新线程来调用的);
- 异步处理线程在完成某个 fd 所有请求后,进入闲置状态;如果 queue 中有新 fd 加入,则重新工作。闲置一段后,自动退出。有新请求时,再动态创建;
Libaio 内核级别
- 主线程调用 eio_init 函数,初始化 req_queue、res_queue(响应队列)及对应 mutex(互斥锁)和 cond(pthread,Linux 多线程部分);
- 所有 IO 操作其实都是对 eio_sumbit 的调用,而 eio_sumbit 的职能是将 IO 操作封装为 request 并插入到 req_queue;并调用 cond_signal 向 worker 线程发出 reqwait 已 OK 的信号;
- worker 线程被创建后执行的函数为 etp_proc,其启动后会一直等待 reqwait 条件出现;当 reqwait 条件满足时,etp_proc 从 req_queue 中取得一个 request;并调用 eio_execute 来同步执行该 IO 操作;
- eio_execute 完成后,将 response 插入到 res_queue ;并调用 want_poll 来通知主线程 request 已经处理完毕;(worker 线程通知主线程机制是通过向 pipe[1]写一个 byte 数据);
- 主线程发现 pipe[0]可读时,调用 eio_poll;从 res_queue 里取 response,并调用该 IO 操作在 init 时设置的 callback 函数;
- res_queue 中没有待处理 response 时,调用 done_poll;done_poll 从 pipe[0]读出一个 byte 数据,该 IO 操作完成。
- o_getevents 返回结果
内核级 AIO 与用户线程级别的 AIO(glibc 和 libaio)的比较
- 原理
- linux 版本的 AIO 实际上是利用了 CPU 和 IO 设备可异步工作的特性(IO 请求的提交主要还是在调用者线程上同步完成的)。相比同步 IO,不会占用额外 CPU 资源。
- glibc 版本的异步 IO 则利用了线程与线程之间可以异步工作的特性,使用新的线程来完成 IO 请求,会额外占用 CPU 资源(且调用线程和异步处理线程之间还存在线程间通信开销)。不过,IO 请求提交过程都由异步处理线程完成了,调用者线程可以更快地响应其他事情。如果 CPU 资源富足,还不错。
- 当调用者连续调用异步 IO 接口,提交多个异步 IO 请求时。
- 在 glibc 版本的异步 IO 中,同一个 fd 的读写请求由同一个异步处理线程来同步完成。所以,对底层的 IO 调度器来说,它一次只能看到一个请求。
- 内核实现的异步 IO,则是直接将所有请求都提交给了 IO 调度器,IO 调度器能看到所有的请求。请求多了,IO 调度器使用的类电梯算法就能发挥更大功效。请求少了,极端情况下(如系统中的 IO 请求都集中在同一个 fd 上,且不使用预读),IO 调度器总是只能看到一个请求,那么电梯算法将退化成先来先服务算法,可能会极大的增加碰头移动的开销。
- direct-io
- glibc 版本的异步 IO 支持非 direct-io,可利用内核提供的 page cache 来提高效率。
- 而 linux 版本只支持 direct-io,cache 的工作就只能靠用户程序来实现了。