一、概述 在存储场景中,对 IO 性能要求非常高,所以存储引擎底层的 IO 模型的选型往往是一款 存储产品设计时首要考虑的因素。IO 模型分为阻塞 IO、非阻塞 IO、多路复用 IO、SignalIO、 异步 IO、Libevent 等等,这些不同的 IO 模型有不同的适用场景,通常阻塞、非阻塞、异步 IO 适合块设备,其它更多的是适合字符设备、socket 等(因为主要是监听事件)。本文我们 主要来介绍一下异步 IO。 二、AIO 思想 AIO 主要的思想是实现 CPU 和 IO 的并行。CPU 与 IO 需要并行问题源于 CPU 与 IO 是分 属于不同的硬件,两者互不干扰。将二者并行操作,能最大程度的充分利用硬件资源、节省 时间。程序运行比较忌讳的是 CPU 等 IO、IO 等 CPU 这类互等的场景,从红蓝图上直观来看, 比较好的系统状态是红/蓝并行,让“红的更红、蓝的更蓝”。 三、AIO 分类 模拟 AIO:用户空间模拟的异步(假 AIO),性能和稳定性差,工程使用较少。 Kernel AIO:内核实现的 AIO,内核 2.6 以上版本支持 libaio,5.x 以上版本支持 io_uring。 虽然 io_uring 应用场景更广,但成熟稳定性还欠缺一些,目前还在不断迭代中。因此业界通 常说 AIO 的时候,默认指的就是 libaio 这种实现。本文也主要介绍 libaio(下文“AIO”,均代 表 libaio)。 四、AIO 内核实现 (一)系统调用 API 接口 // 创建一个异步 IO 上下文(io_context_t 是一个句柄) int io_setup(int maxevents, io_context_t *ctxp); // 销毁一个异步 IO 上下文(如果有正在进行的异步 IO,取消并等待它们完成) int io_destroy(io_context_t ctx); long io_submit(aio_context_t ctx_id, long nr, struct iocb **iocbpp); // 提交异步 IO 请求 long io_cancel(aio_context_t ctx_id, struct iocb *iocb, struct io_event *result); // 取消一个异步 IO long io_getevents(aio_context_t ctx_id, long min_nr, long nr, struct io_event *events, struct timespec // 等待并获取异步 IO 请求的事件 (也就是异步请求的处理结果) *timeout) 其中,struct iocb 主要包含以下字段: __u16 aio_lio_opcode; // 请求类型(如:IOCB_CMD_PREAD=读、IOCB_CMD_PWRITE=写、等) __u32 aio_fildes; // 要被操作的 fd __u64 aio_buf; // 读写操作对应的内存 buffer __u64 aio_nbytes; // 需要读写的字节长度 __s64 aio_offset; // 读写操作对应的文件偏移 __u64 aio_data; // 请求可携带的私有数据(在 io_getevents 时能够从 io_event 结果中取得) __u32 aio_flags; // 可选 IOCB_FLAG_RESFD 标记,表示异步请求处理完成时使用 eventfd 进行通知 __u32 aio_resfd; // 有 IOCB_FLAG_RESFD 标记时,接收通知的 eventfd 其中,struct io_event 主要包含以下字段: __u64 data; // 对应 iocb 的 aio_data 的值 __u64 obj; // 指向对应 iocb 的指针 __s64 res; // 对应 IO 请求的结果(>=0: 相当于对应的同步调用的返回值;<0: -errno) (二)创建异步 IO 上下文 要使用 linux AIO,首先需要创建一个异步 IO 上下文,在内核中,异步 IO 上下文使用 struct kioctx 表示,一个 io_context_t 句柄在内核中对应一个 struct kioctx 结构。其 主要包含以下字段: struct mm_struct* mm; // 调用者进程对应的内存管理结构(代表了调用者的虚拟地址空间) unsigned long user_id; // 上下文 ID,也就是 io_context_t 句柄的值(等于 ring_info.mmap_base) struct hlist_node list; // 属于同一地址空间所有 kioctx 结构通过这个 list 串连起来,链表头是 mm->ioctx_list wait_queue_head_t wait; // 等待队列(io_getevents 系统调用可能需要等待,调用者就在该等待队列上睡眠) int reqs_active; // 进行中的请求数目 struct list_head active_reqs; // 进行中的请求队列 unsigned max_reqs; // 最大请求数(对应 io_setup 调用的 int maxevents 参数) struct list_head run_list; // 需要 aio 线程处理的请求列表(某些情况下,IO 请求可能交给 aio 线程来提交) struct delayed_work wq; // 延迟任务队列(当需要 aio 线程处理请求时,将 wq 挂入 aio 线程对应的请求队列) struct aio_ring_info ring_info; // 存放请求结果 io_event 结构的 ring buffer 其中,active_reqs 保存了所有正在进行的异步 IO 操作,ring_info 是用于存放请求结果 io_event 结构的 ring buffer。ring_info 主要包含了如下字段: unsigned long mmap_base; /* ring buffer 的地始地址 */ unsigned long mmap_size; /* ring buffer 分配空间的大小 */ struct page** ring_pages; /* ring buffer 对应的 page 数组 */ long nr_pages; /* 分配空间对应的页面数目(nr_pages * PAGE_SIZE = mmap_size) */ unsigned nr, tail; /* 包含 io_event 的数目及存取游标 */ Ring_info 这个数据结构看起来有些奇怪,直接弄一个 io_event 数组不就完事了么? 为什么要维护 mmap_base、mmap_size、ring_pages、nr_pages 这么复杂的一组信息,而又 把 io_event 结构隐藏起来呢? 这里的巧妙之处就在于,io_event 结构的 buffer 是在用户态地址空间上分配的。注意, 我们在内核里面看到了诸多数据结构都是在内核地址空间上分配的,因为这些结构都是内核 专有的,没必要给用户程序看到,更不能让用户程序去修改。而这里的 io_event 却是有意 让用户程序看到,而且用户就算修改了也不会对内核的正确性造成影响。于是这里使用了这 样一个有些取巧的办法,由内核在用户态地址空间上分配 buffer。 按照这样的思路,io_setup 时,内核会通过 mmap 在对应的用户空间分配一段内存, mmap_base、mmap_size 就是这个内存映射对应的位置和大小。然后,光有映射还不行,还 必须立马分配物理内存,ring_pages、nr_pages 就是分配好的物理页面。(因为这些内存 是要被内核直接访问的,内核会将异步 IO 的结果写入其中。如果物理页面延迟分配,那么 内核访问这些内存的时候会发生缺页异常。而处理内核态的缺页异常又很麻烦,所以还不如 直接分配物理内存的好。 其二, 内核在访问这个 buffer 里的信息时,也并不是通过 mmap_base 这个虚拟地址去直接访问的。既然是异步,那么结果写回的时候可能是在另一个上下文上面, 虚拟地址空间都不同。为了避免进行虚拟地址空间的切换,内核干脆直接通过 kmap 将 ring_pages 映射到高端内存上去访问好了。) 然后,在 mmap_base 指向的用户空间的地址上,会存放着一个 struct aio_ring 结构, 用来管理这个 ring buffer。其主要包含了如下字段: unsigned id; /* 等于 aio_ring_info 中的 user_id */ unsigned nr; /* 等于 aio_ring_info 中的 nr */ unsigned head,tail; /* io_events 数组的游标 */ unsigned magic,compat_features,incompat_features; unsigned header_length; /* aio_ring 结构的大小 */ struct io_event io_events[0]; /* io_event 的 buffer */ 终于,我们期待的 io_event 数组出现了。 看到这里,你一定会有个疑问:既然整个 aio_ring 结构及其中的 io_event 缓冲都是放 在用户空间的,内核还提供 io_getevents 系统调用干什么?用户程序不是直接就可以取用 io_event,并且修改游标了么(内核作为生产者,修改 aio_ring->tail;用户作为消费者, 修改 aio_ring->head)?我想,aio_ring 之所以要放在用户空间,其原本用意应该就是这 样的。 那么,用户空间如何知道 aio_ring 结构的地址(aio_ring_info->mmap_base)呢?其 实 kioctx 结构中的 user_id,也就是 io_setup 返回给用户的 io_context_t,就等于 aio_ring_info->mmap_base。然后,aio_ring 结构中还有诸如 magic、compat_features、 incompat_features 这样的字段,用户空间可以读这些 magic,以确定数据结构没有被异常 篡改。如果一切可控,那么就自己动手、丰衣足食;否则就还是走 io_getevents 系统调用。 而 io_getevents 系统调用通过 aio_ring_info->ring_pages 得到 aio_ring 结构,再将相应 的 io_event 拷贝到用户空间。 以上就是异步 IO 的 context 的结构。那么,为什么 linux 版本的异步 IO 需要“上下文” 这么个概念,而 glibc 版本则不需要呢?在 glibc 版本中,异步处理线程是 glibc 在调用者 进程中动态创建的线程,它和调用者必定是在同一个虚拟地址空间中的。这里已经隐含了“同 一上下文”这么个关系。而对于内核来说,要面对的是任意的进程,任意的虚拟地址空间。 当处理一个异步请求时,内核需要在调用者对应的地址空间中存取数据,必须知道这个虚拟 地址空间是什么。不过当然,如果设计上要想把“上下文”这个概念隐藏了也是肯定可以的 (比如让每个 mm 隐含一个异步 IO 上下文)。 (三)异步 IO 提交: 异步 IO 提交涉及到结构体 struct iocb,struct iocb 在内核中又对应到 struct kiocb 结构,主要包含以下字段: struct kioctx* ki_ctx; /* 请求对应的 kioctx(上下文结构) */ struct list_head ki_run_list; /* 需要 aio 线程处理的请求,通过该字段链入 ki_ctx->run_list */ struct list_head ki_list; /* 链入 ki_ctx->active_reqs */ struct file* ki_filp; /* 对应的文件指针 */ void __user* ki_obj.user; /* 指向用户态的 iocb 结构 */ __u64 ki_user_data; /* 等于 iocb->aio_data */ loff_t ki_pos; /* 等于 iocb->aio_offset */ unsigned short ki_opcode; /* 等于 iocb->aio_lio_opcode */ size_t ki_nbytes; /* 等于 iocb->aio_nbytes */ char __user * ki_buf; /* 等于 iocb->aio_buf */ size_t ki_left; /* 该请求剩余字节数(初值等于 iocb->aio_nbytes) */ struct eventfd_ctx* ki_eventfd; /* 由 iocb->aio_resfd 对应的 eventfd 对象 */ ssize_t (*ki_retry)(struct kiocb *); /*由 ki_opcode 选择的请求提交函数*/ 调用 io_submit 后,对应于用户传递的每一个 iocb 结构,会在内核态生成一个与之对 应的 kiocb 结构,并且在对应 kioctx 结构的 ring_info 中预留一个 io_events 的空间。之 后,请求的处理结果就被写到这个 io_event 中。然后,对应的异步读写请求就被提交到了 虚拟文件系统,实际上就是调用了 file->f_op->aio_read 或 file->f_op->aio_write。提 交流程如下图所示: 1. io_submi 最终会调用内核函数 sys_io_submit 来实现提供异步 IO 操作。 2. sys_io_submit 函数主要从用户空间复制异步 IO 操作信息到内核空间,然后调用 io_submit_one。 3. io_submit_one 主要流程如下: 1) 通过调用 fget 获取文件句柄对应的文件对象。 2) 调用 aio_get_req 获取一个类型 kiocb 结构的异步 IO 操作对象。另外,aio_get_req 函数还会把异步 IO 操作对象添加到异步 IO 上下文的 active_reqs 队列中。 3) 根据不同的异步 IO 操作类型来进行不同的处理,如异步读操作会调用文件对象的 aio_read。 4) 如果异步 IO 操作被添加到内核的 IO 请求队列中,那么就直接返回。否则代表 IO 操作 已完成,那么就调用 aio_complete 函数完成收尾工作。 (四)异步 IO 执行 1. 在提交时执行 AIO 异步 I/O 操作通过 io_submit()提交后,首先会在提交时尝试执行 aio_run_iocb(),若 不能立即执行完毕, 返回, 则放到 AIO 上下文 kioctx 的 run_list 中执行__aio_run_iocbs()。 但是,大部分的 AIO 操作是在工作队列中完成的。 2. 在工作队列中执行 AIO 这里,需要简单介绍一下工作队列。工作队列(work queue)是内核中的一种机制,内核 开发者可以通过 create_workqueue()创建工作队列。开发者可以选择创建单个内核线程, 也可以选择创建多个线程, 每个 CPU 上绑定一个线程。 开发者可以把任务封装到 work_struct 结构体中(包括函数,参数和定时器等等),然后调用 queue_work()把任务放到工作队列中。 当工作队列的内核线程被内核调度到时,里面的任务会得到执行。 运行 ps aux 可以看到 aio/0、aio/1、aio/2 等等,这些就是内核启动时创建的工作队 列在每个 CPU 核上对应的内核线程。它是如何生成的呢?在系统启动时,内核会调用 aio_setup(),该函数会创建一个名叫”aio”的工作队列。 该工作队列会在每个 CPU 核上创建一个内核线程。用户通过 ps aux 命令可以看到, aio/0、aio/1、aio/2、aio/3 等等。当内核在执行系统调用 io_submit 时不能立即把 AIO 操作处理完毕,就会把这些任务放到工作队列中,内核会在适当的时机执行它们。工作队列 的对应的处理函数是 aio_kick_handler()。它会调用__aio_run_iocbs()完成 IO 操作。 3. 负责 AIO 执行的核心函数 aio_run_iocb __aio_run_iocbs() 负 责 把 每 个 kiocb 结 构 体 从 kioctx 中 取 出 来 , 然 后 调 用 aio_run_iocb()完成 AIO 操作。由此可见,最核心的函数是 aio_run_iocb(),对异步 I/O 的操作最后都会落实到这个函数。aio_run_iocb()的功能是调用每个 AIO 操作的 retry 方法, 这个方法必须要尽可能的非阻塞,防止其它等待处理的 AIO 操作等待时间过长。 aio_run_iocb()在调用 retry 方法时,会判断返回值。当返回值是 EIOCBRETRY 时,这 意味着 AIO 操作只是部分完成,需要把 kiocb 的状态标记为 Kicked,并且把该操作放入 ki_run_list 队列中。这样在工作队列中,aio 内核线程会尝试着去再次执行它。当返回值 是 EIOCBQUEUED 时,这意味着 AIO 操作是以 direct Io 的方式进行的。请求已经提交,但是 还没有完成。当返回值是其它值时,表示 AIO 操作结束,可能是正常结束,也可能是发生 I/O 错误。这时调用 aio_complete 完成 I/O 操作。 那么 AIO 操作的 retry 方法是什么呢?以读操作为例,retry 方法实际上指向的是 aio_pread 函数。它走的流程与普通文件的读写并没有什么不同。 (五)异步 IO 操作完成 从前面的分析可以看到,当 AIO 操作结束时,会调用 aio_complete 来进行善后操作。 这个函数的功能是把 AIO 操作的返回值写到 AIO ring 中。前面提到,这是一块内核态与用 户态都可以访问的缓冲区。它其实是一个循环数组,里面存放了 io_event。当应用程序调 用 io_getevents 系统调用时,就会从 AIO ring 中读取 AIO 操作的状态和返回值。 io_getevents 流程如下图: 五、AIO 与 epoll 的讨论 (一)AIO 和 epoll 的区别 我们知道 Linux AIO 是为了实现 CPU 和 IO 设备并行运行,充分利用硬件资源。同样能 达到该目的还有 epoll,那 epoll 和 AIO 有什么区别呢? Epoll 本质上其实还是先等待 IO 的读写可以发生,而后再以同步 BIO(由于 epoll 已经 判断数据可读写,所以 BIO 不会阻塞)去发起 IO 请求。而 AIO 则是不管三七二十一,直接 发 IO 请求,它是通过 io_submit 把 IO 请求发送给 kernel,并不需要等待 IO 结束,后面使 用 io_getevents 去同步结果。Epoll 是一个事件获取机制,获取事件后,之后的 read()、 write()还是走的 Linux buffer IO 的路径,进过 Linux 内核本身的各个层次(pagecache、 IO 调度等)。而 AIO 是骨子里面,自己就是一个 IO 方式,它的 IO 路径没有经过内核的 pagecache、IO 调度等中间过程。这也是为何 AIO 只支持 direct IO 的原因。 (二)AIO 与 epoll 的结合: 为了获得最优的性能,我们在使用 AIO 时,应该尽量避免 io_getevents 的阻塞。那有 什么机制可以在 IO 完成后,应用程序立即就能够感知到呢?有两种方案: 1. 使用多线程,用专门的线程调用 io_getevents。 2. 对于单线程程序,可以结合 epoll、eventfd 来使用 AIO。 eventfd 的作用是内核用来通知应用程序发生的事件的数量,从而使应用程序不用频繁 地去轮询内核是否有时间发生,而是有内核将发生事件的数量写入到该 fd,应用程序发现 fd 可读后,从 fd 读取该数值,并马上去内核读取。有了 eventfd,就可以很好地将 libaio 和 epoll 事件循环结合起来: 1. 创建一个 eventfd efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); 2. 将 eventfd 设置到 iocb 中 io_set_eventfd(iocb, efd); 3. 交接 AIO 请求 io_submit(ctx, NUM_EVENTS, iocb); 4. 创建一个 epollfd,并将 eventfd 加到 epoll 中 epfd = epoll_create(1); epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &epevent); epoll_wait(epfd, &epevent, 1, -1); 5. 当 eventfd 可读时,从 eventfd 读出完成 IO 请求的数量,并调用 io_getevents 获取这 些 IO read(efd, &finished_aio, sizeof(finished_aio); r = io_getevents(ctx, 1, NUM_EVENTS, events, &tms); 六、AIO 定制特性 AIO 可以让应用程序根据自身的特点来定制自己的 iO 行为。 1. 用户级别 cache: AIO 屏蔽了 Linux 内核的 pagecache,用户可以根据自己的缓存特性,实现用户级别的 cache。 2. 用户级别 Io-scheduling: 用户可以控制发送给内核的 IO 请求,从而控制 IO 的优先级。内核固然有它的 IO 调度 算法,但它是通用的调度算法,并不能在特定的场景发挥最优的性能。 3. 用户级别 Read_ahead、write_behind 控制: Linux 内核本身会根据用户的读请求去进行预读,但这种预读的 page 可能并不一定是 上层应用需要的 page。而内核的 write-behind 机制,也可能导致内核累积过多的 dirty 数 据,导致写负载突发性增大。 七、总结 正是由于 AIO 的灵活定制特性,所以在数据库、特定存储引擎等系统的开发时会更倾向 于使用 AIO,这样就可以根据特定存储场景设计最优的 IO 方案。反过来说,也正是由于这 些特性,让 AIO 在普通的存储场景下,可能效率不一定能达到最优,因为它没有 kernle buffer,没有 kernle io-scheduling。所以 AIO 的这些特性需要结合应用场景辩证的来看。 参考: http://lse.sourceforge.net/io/aio.html https://www.cnblogs.com/hustcat/archive/2013/02/05/2893488.html https://juejin.cn/post/6956566854500515870 https://blog.acean.vip/post/linux-kernel/gai-shu-linuxnei-he-san-jia-ma-che-zhi-ioguan-li https://www.sohu.com/a/253204125_467784 https://www.mysmth.net/nForum/#!article/KernelTech/51982
0
You can add this document to your study collection(s)
Sign in Available only to authorized usersYou can add this document to your saved list
Sign in Available only to authorized users(For complaints, use another form )