2.非阻塞IO模型

特点:非阻塞IO通过进程反复调用IO函数

3.IO复用模型

特点:对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听

4.信号驱动IO模型

特点:两次调用,两次返回

5.异步IO模型

特点:数据拷贝的时候进程无需阻塞

五种IO模型对比

glibc异步IO版本

接口

//glibc版本主要包含如下接口:
intaio_read(structaiocb aiocbp);
intaio_write(structaiocb
aiocbp);
intaio_cancel(intfildes,structaiocb aiocbp);
intaio_error(conststructaiocb
aiocbp);
ssize_t aio_return(structaiocb aiocbp);
intaio_suspend(conststructaiocb
constlist[],intnent,conststructtimespec timeout);
//其中,struct aiocb主要包含以下字段:
intaio_fildes;
void
aio_buf;
__off64_t aio_offset;
size_taio_nbytes;
intaio_reqprio;
structsigevent aio_sigevent;

实现

  1. 异步请求被提交到request_queue中。
  2. request_queue实际上是一个表结构,”行”是fd、”列”是具体的请求。也就是说,同一个fd的请求会被组织在一起
  3. 异步请求有优先级概念,属于同一个fd的请求会按优先级排序,并且最终被按优先级顺序处理
  4. 随着异步请求的提交,一些异步处理线程被动态创建。这些线程要做的事情就是从request_queue中取出请求,然后处理之
  5. 为避免异步处理线程之间的竞争,同一个fd所对应的请求只由一个线程来处理
  6. 异步处理线程同步地处理每一个请求,处理完成后在对应的aiocb中填充结果,然后触发可能的信号通知或回调函数,回调函数是需要创建新线程来调用的(在当前函数调用可能会造成阻塞)
  7. 异步处理线程在完成某个fd的所有请求后,进入闲置状态
  8. 异步处理线程在闲置状态时,如果request_queue中有新的fd加入,则重新投入工作,去处理这个新fd的请求,新fd和它上一次处理的fd可以不是同一个
  9. 异步处理线程处于闲置状态一段时间后,没有新的请求,则会自动退出。等到再有新的请求时,再去动态创建

    linux内核异步IO版本

    接口
//下面再来看看linux版本的异步IO。它主要包含如下系统调用接口:
intio_setup(intmaxevents, io_context_t ctxp);
intio_destroy(io_context_t ctx);
longio_submit(aio_context_t ctx_id,longnr,structiocb iocbpp);
long**io_cancel(aio_context_t ctx_id,structiocb
iocb,structio_event result);
longio_getevents(aio_context_t ctx_id,longmin_nr,longnr,structio_event
events,structtimespec timeout)
structiocb {
void
data;/ Return in the io completion event /
unsigned key;/r use in identifying io requests /
shortaio_lio_opcode;
shortaio_reqprio;
intaio_fildes;
union{
structio_iocb_common c;
structio_iocb_vector v;
structio_iocb_poll poll;
structio_iocb_sockaddr saddr;
} u;
};
structio_iocb_common {
voidbuf;
unsignedlongnbytes;
longlongoffset;
unsigned flags;
unsigned resfd;
};
//iocb是提交IO任务时用到的,可以完整地描述一个IO请求:
//data是留给用来自定义的指针:可以设置为IO完成后的callback函数;
//aio_lio_opcode表示操作的类型:IO_CMD_PWRITE | IO_CMD_PREAD;
//aio_fildes是要操作的文件:fd;
//io_iocb_common中的buf, nbytes, offset分别记录的IO请求的mem buffer,大小和偏移。
structio_event {
void
data;
structiocb obj;
unsignedlongres;
unsigned*long
res2;
};
//io_event是用来描述返回结果的:
//obj就是之前提交IO任务时的iocb;
//res和res2来表示IO任务完成的状态。

实现

//io_context_t句柄在内核中对应一个struct kioctx结构,用来给一组异步IO请求提供一个上下文。其主要包含以下字段:
structmm_struct mm;
unsignedlonguser_id;
structhlist_node list;
wait_queue_head_t wait;
intreqs_active;
structlist_head active_reqs;
unsigned max_reqs;
structlist_head run_list;
structdelayed_work wq;
structaio_ring_info ring_info;
//其中,这个aio_ring_info结构比较值得一提,它是用于存放请求结果io_event结构的ring buffer。它主要包含了如下字段:
unsignedlongmmap_base;
unsignedlongmmap_size;
structpage ring_pages;
long*
nr_pages;
unsigned nr, tail;

这个数据结构看起来有些奇怪,直接弄一个io_event数组不就完事了么?为什么要维护mmap_base、mmap_size、ring_pages、nr_pages这么复杂的一组信息,而又把io_event结构隐藏起来呢?这里的奇妙之处就在于,io_event结构的buffer是在用户态地址空间上分配的。注意,我们在内核里面看到了诸多数据结构都是在内核地址空间上分配的,因为这些结构都是内核专有的,没必要给用户程序看到,更不能让用户程序去修改。而这里的io_event却是有意让用户程序看到,而且用户就算修改了 也不会对内核的正确性造成影响。于是这里使用了这样一个有些取巧的办法,由内核在用户态地址空间上分配buffer。(如果换一个保守点的做法,内核态可以维护io_event的buffer,然后io_getevents的时候,将对应的io_event复制一份到用户空间。)按照这样的思路,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;
unsigned nr;
unsigned head,tail;
unsigned magic,compat_features,incompat_features;
unsigned header_length;
structio_event io_events[0];
//终于,我们期待的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 拷贝到用户空间。

//下面贴一段libaio中的io_getevents的代码(前面提到过,linux版本的异步IO是由用户态的libaio来封装的):
intio_getevents_0_4(io_context_t ctx,longmin_nr,longnr,structio_event events,structtimespec timeout){
structaio_ring ring;
ring = (structaio_ring
)ctx;
if(ring==NULL || ring->magic != AIO_RING_MAGIC)
gotodo_syscall;
if(timeout!=NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {
if(ring->head == ring->tail)
return0;
}
do_syscall:
return__io_getevents_0_4(ctx, min_nr, nr, events, timeout);
}
//其中确实用到了用户空间上的aio_ring结构的信息,不过尺度还是不够大。

以上就是异步IO的context的结构。那么,为什么linux版本的异步IO需要“上下文”这么个概念,而glibc版本则不需要呢?
在glibc版本中,异步处理线程是glibc在调用者进程中动态创建的线程,它和调用者必定是在同一个虚拟地址空间中的。这里已经隐含了“同一上下文”这么个关系。
而对于内核来说,要面对的是任意的进程,任意的虚拟地址空间。当处理一个异步请求时,内核需要在调用者对应的地址空间中存取数据,必须知道这个虚拟地址空间 是什么。不过当然,如果设计上要想把“上下文”这个概念隐藏了也是肯定可以的(比如让每个mm隐含一个异步IO上下文)。具体如何选择,只是设计上的问题。

//struct iocb在内核中又对应到struct kiocb结构,主要包含以下字段:
structkioctx ki_ctx;
structlist_head ki_run_list;
structlist_head ki_list;
structfile
ki_filp;
voiduser* ki_obj.user;
u64 ki_user_data;
loff_t ki_pos;
unsignedshortki_opcode;
size_tki_nbytes;
char__user ki_buf;
size_tki_left;
structeventfd_ctx
ki_eventfd;
ssize_t (ki_retry)(structkiocb );

调用iosubmit后,对应于用户传递的每一个iocb结构,会在内核态生成一个与之对应的kiocb结构,并且在对应kioctx结构的ring_info中预留一个io_events的空间。之后,请求的处理结果就被写到这个io_event中。
然后,对应的异步读写(或其他)请求就被提交到了虚拟文件系统,实际上就是调用了file->f_op->aio_read或 file->f_op->aio_write(或其他)。也就是,在经历磁盘高速缓存层、通用块层之后,请求被提交到IO调度层,等待被处 理。这个跟普通的文件读写请求是类似的。
对于非direct-io的读请求来说,如果page cache不命中,那么IO请求会被提交到底层。之后,do_generic_file_read会通过lock_page操作,等待数据最终读完。这一 点跟异步IO是背道而驰的,因为异步就意味着请求提交后不能等待,必须马上返回。而对于非direct-io的写请求,写操作一般仅仅是将数据更新作用到 page cache上,并不需要真正的写磁盘。page cache写回磁盘本身是一个异步的过程。可见,对于非direct-io的文件读写,使用linux版本的异步IO接口完全没有意义(就跟使用同步接口 效果一样)。为什么会有这样的设计呢?因为非 direct-io的文件读写是只跟page cache打交道的。而page cache是内存,跟内存打交道又不会存在阻塞,那么也就没有什么异步的概念了。至于读写磁盘时发生的阻塞,那是page cache跟磁盘打交道时发生的事情,跟应用程序又没有直接关系。然而,对于direct-io来说,异步则是有意义的。因为direct-io是应用程序的buffer跟磁盘的直接交互(不使用page cache)。
这里,在使用direct-io的情况下,file->f_op->aio
{read,write}提交完IO请求就直接返回了,然后iosubmit系统调用返回。(见后面的执行流程。)通过linux内核异步触发的IO调度(如:被时钟中断触发、被其他的IO请求触发、等),已经提交的IO请求被调度,由对应的设备驱动程序提交给具体的设 备。对于磁盘,一般来说,驱动程序会发起一次DMA。然后又经过若干时间,读写请求被磁盘处理完成,CPU将收到表示DMA完成的中断信号,设备驱动程序 注册的处理函数将在中断上下文中被调用。这个处理函数会调用end_request函数来结束这次请求。不同的是,对于同步非direct-io,end_request将通过清除page结构的PG_locked标记来唤醒被阻塞的读操作流程,异步IO和同 步IO效果一样。而对于direct-io,除了唤醒被阻塞的读操作流程(同步IO)或io_getevents流程(异步IO)之外,还需要将IO请求 的处理结果填回对应的io_event中。最后,等到调用者调用io_getevents的时候,就能获取到请求对应的结果(io_event)。而如果调用io_getevents的时候结果还没出来,流程也会被阻塞,并且会在direct-io的end_request过程中得到唤醒。
linux版本的异步IO也有aio线程(每CPU一个),但是跟glibc版本中的异步处理线程不同,这里的aio线程是用来处理请求重试的。某些情况下,file->f_op->aio
{read,write}可能会返回-EIOCBRETRY,表示需要重试(只有一些特殊的IO设备会 这样)。而调用者既然使用的是异步IO接口,肯定不希望里面会有等待/重试的逻辑。所以如果遇到-EIOCBRETRY,内核就在当前CPU对应的aio线程添加一个任务,让aio线程来完成请求的重新提交。而调用流程可以直接返回,不需要阻塞。
请求在aio线程中提交和在调用者进程中提交相比,有一个最大的不同,就是aio线程使用的地址空间可能跟调用者线程不一样。需要利用kioctx->mm切换到正确的地址空间,然后才能发请求。
内核处理流程
最后,整理一下direct-io异步读操作的处理流程:

  • io_submit。对于提交的iocbpp数组中的每一个iocb(异步请求),调用io_submit_one来提交它们;
  • io_submit_one。为请求分配一个kiocb结构,并且在对应的kioctx的ring_info中为它预留一个对应的io_event。然后调用aio_rw_vect_retry来提交这个读请求;
  • aio_rw_vect_retry。调用file->f_op->aio_read。这个函数通常是由generic_file_aio_read或者其封装来实现的;
  • generic_file_aio_read。对于非direct-io,会调用do_generic_file_read来处理请求。而对于direct-io,则是调用mapping->a_ops->direct_IO。这个函数通常就是blkdev_direct_IO;
  • blkdev_direct_IO。调用filemap_write_and_wait_range将相应位置可能存在的page cache废弃掉或刷回磁盘(避免产生不一致),然后调用direct_io_worker来处理请求;
  • direct_io_worker。一次读可能包含多个读操作(对应于类readv系统调用),对于其中的每一个,调用do_direct_IO;
  • do_direct_IO。调用submit_page_section;
  • submit_page_section。调用dio_new_bio分配对应的bio结构,然后调用dio_bio_submit来提交bio;
  • dio_bio_submit。 调用submit_bio提交请求。后面的流程就跟非direct-io是一样的了,然后等到请求完成,驱动程序将调用 bio->bi_end_io来结束这次请求。对于direct-io下的异步IO,bio->bi_end_io等于 dio_bio_end_aio;
  • dio_bio_end_aio。调用wake_up_process唤醒被阻塞的进程(异步IO下,主要是io_getevents的调用者)。然后调用aio_complete;
  • aio_complete。将处理结果写回到对应的io_event中;

    glibc与linux内核版本比较

    从上面的流程可以看出,linux版本的异步IO实际上只是利用了CPU和IO设备可以异步工作的特性(IO请求提交的过程主要还是在调用者线程上同步完成的,请求提交后由于CPU与IO设备可以并行工作,所以调用流程可以返回,调用者可以继续做其他事情)。相比同步IO,并不会占用额外的CPU资源。而 glibc版本的异步IO则是使用了线程与线程之间可以异步工作的特性,使用了新的线程来完成IO请求,这种做法会额外占用CPU资源(对线程的创建、销 毁、调度都存在CPU开销,并且调用者线程和异步处理线程之间还存在线程间通信的开销)。不过,IO请求提交的过程都由异步处理线程来完成了(而 linux版本是调用者来完成的请求提交),调用者线程可以更快地响应其他事情。如果CPU资源很富足,这种实现倒也还不错。
    还有一点,当调用者连续调用异步IO接口,提交多个异步IO请求 时。在glibc版本的异步IO中,同一个fd的读写请求由同一个异步处理线程来完成。而异步处理线程又是同步地、一个一个地去处理这些请求。所以,对于 底层的IO调度器来说,它一次只能看到一个请求。处理完这个请求,异步处理线程才会提交下一个。而内核实现的异步IO,则是直接将所有请求都提交给了IO 调度器,IO调度器能看到所有的请求。请求多了,IO调度器使用的类电梯算法就能发挥更大的功效。请求少了,极端情况下(比如系统中的IO请求都集中在同 一个fd上,并且不使用预读),IO调度器总是只能看到一个请求,那么电梯算法将退化成先来先服务算法,可能会极大的增加碰头移动的开销。
    最后,glibc版本的异步IO支持非direct-io,可以利用内核提供的page cache来提高效率。而linux版本只支持direct-io,cache的工作就只能靠用户程序来实现了。