9.9 文件的异步I/O

本章之前提到的事件驱动模块都是在处理网络事件,而没有涉及磁盘上文件的操作。本节将讨论Linux内核2.6.2x之后版本中支持的文件异步I/O,以及ngx_epoll_module模块是如何与文件异步I/O配合提供服务的。这里提到的文件异步I/O并不是glibc库提供的文件异步I/O。glibc库提供的异步I/O是基于多线程实现的,它不是真正意义上的异步I/O。而本节说明的异步I/O是由Linux内核实现,只有在内核中成功地完成了磁盘操作,内核才会通知进程,进而使得磁盘文件的处理与网络事件的处理同样高效。

使用这种方式的前提是Linux内核版本中必须支持文件异步I/O。当然,它带来的好处也非常明显,Nginx把读取文件的操作异步地提交给内核后,内核会通知I/O设备独立地执行操作,这样,Nginx进程可以继续充分地占用CPU。而且,当大量读事件堆积到I/O设备的队列中时,将会发挥出内核中“电梯算法”的优势,从而降低随机读取磁盘扇区的成本。

注意 Linux内核级别的文件异步I/O是不支持缓存操作的,也就是说,即使需要操作的文件块在Linux文件缓存中存在,也不会通过读取、更改缓存中的文件块来代替实际对磁盘的操作,虽然从阻塞worker进程的角度上来说有了很大好转,但是对单个请求来说,还是有可能降低实际处理的速度,因为原先可以从内存中快速获取的文件块在使用了异步I/O后则一定会从磁盘上读取。异步文件I/O是把“双刃剑”,关键要看使用场景,如果大部分用户请求对文件的操作都会落到文件缓存中,那么不要使用异步I/O,反之则可以试着使用文件异步I/O,看一下是否会为服务带来并发能力上的提升。

目前,Nginx仅支持在读取文件时使用异步I/O,因为正常写入文件时往往是写入内存中就立刻返回,效率很高,而使用异步I/O写入时速度会明显下降。

9.9.1 Linux内核提供的文件异步I/O

Linux内核提供了5个系统调用完成文件操作的异步I/O功能,见表9-7。

9.9.1 Linux内核提供的文件异步I/O - 图1

表9-7中列举的这5种方法提供了内核级别的文件异步I/O机制,使用前需要先调用io_setup方法初始化异步I/O上下文。虽然一个进程可以拥有多个异步I/O上下文,但通常有一个就足够了。调用io_setup方法后会获得这个异步I/O上下文的描述符(aio_context_t类型),这个描述符和epoll_create返回的描述符一样,是贯穿始终的。注意,nr_events只是指定了异步I/O至少初始化的上下文容量,它并没有限制最大可以处理的异步I/O事件数目。为了便于理解,不妨将io_setup与epoll_create进行对比,它们还是很相似的。

既然把epoll和异步I/O进行对比,那么哪些调用相当于epoll_ctrl呢?就是io_submit和io_cancel。其中io_submit相当于向异步I/O中添加事件,而io_cancel则相当于从异步I/O中移除事件。io_submit中用到了一个结构体iocb,下面简单地看一下它的定义。


struct iocb{

/存储着业务需要的指针。例如,在Nginx中,这个字段通常存储着对应的ngx_event_t事件的指针。它实际上与io_getevents方法中返回的io_event结构体的data成员是完全一致的/

u_int64_t aio_data;

//不需要设置

u_int32_t PADDED(aio_key,aio_reserved1);

//操作码,其取值范围是io_iocb_cmd_t中的枚举命令

u_int16_t aio_lio_opcode;

//请求的优先级

int16_t aio_reqprio;

//文件描述符

u_int32_t aio_fildes;

//读/写操作对应的用户态缓冲区

u_int64_t aio_buf;

//读/写操作的字节长度

u_int64_t aio_nbytes;

//读/写操作对应于文件中的偏移量

int64_t aio_offset;

//保留字段

u_int64_t aio_reserved2;

/表示可以设置为IOCB_FLAG_RESFD,它会告诉内核当有异步I/O请求处理完成时使用eventfd进行通知,可与epoll配合使用,其在Nginx中的使用方法可参见9.9.2节/

u_int32_t aio_flags;

//表示当使用IOCB_FLAG_RESFD标志位时,用于进行事件通知的句柄

u_int32_t aio_resfd;

};


因此,在设置好iocb结构体后,就可以向异步I/O提交事件了。aio_lio_opcode操作码指定了这个事件的操作类型,它的取值范围如下。


typedef enum io_iocb_cmd{

//异步读操作

IO_CMD_PREAD=0,

//异步写操作

IO_CMD_PWRITE=1,

//强制同步

IO_CMD_FSYNC=2,

//目前未使用

IO_CMD_FDSYNC=3,

//目前未使用

IO_CMD_POLL=5,

//不做任何事情

IO_CMD_NOOP=6,

}io_iocb_cmd_t;


在Nginx中,仅使用了IO_CMD_PREAD命令,这是因为目前仅支持文件的异步I/O读取,不支持异步I/O的写入。这其中一个重要的原因是文件的异步I/O无法利用缓存,而写文件操作通常是落到缓存中的,Linux存在统一将缓存中“脏”数据刷新到磁盘的机制。

这样,使用io_submit向内核提交了文件异步I/O操作的事件后,再使用io_cancel则可以将已经提交的事件取消。

如何获取已经完成的异步I/O事件呢?io_getevents方法可以做到,它相当于epoll中的epoll_wait方法。这里用到了io_event结构体,下面看一下它的定义。


struct io_event{

//与提交事件时对应的iocb结构体中的aio_data是一致的

uint64_t data;

//指向提交事件时对应的iocb结构体

uint64_t obj;

//异步I/O请求的结构。res大于或等于0时表示成功,小于0时表示失败

int64_t res;

//保留字段

int64_t res2;

};


这样,根据获取的io_event结构体数组,就可以获得已经完成的异步I/O操作了,特别是iocb结构体中的aio_data成员和io_event中的data,可用于传递指针,也就是说,业务中的数据结构、事件完成后的回调方法都在这里。

进程退出时需要调用io_destroy方法销毁异步I/O上下文,这相当于调用close关闭epoll的描述符。

Linux内核提供的文件异步I/O机制用法非常简单,它充分利用了在内核中CPU与I/O设备是各自独立工作的这一特性,在提交了异步I/O操作后,进程完全可以做其他工作,直到空闲再来查看异步I/O操作是否完成。