- 概念介绍:
阻塞:进程会被挂起,不能做任何事,不会立即返回
- 非阻塞:进程不会被挂起,能做任何事,会立即返回
- 阻塞和非阻塞:与进程等待消息时的状态有关
- 同步:发出请求后,进程会持续等待,直到满足为止。不一定阻塞
- 异步:发出请求后,进程不等待,会立即返回(无结果)。之后会通过状态、通知或者回调函数通知进程
- 同步和异步:与消息的通知机制有关
五种IO模型
1.阻塞IO模型
特点:进程会一直阻塞,直到数据拷贝完成例如:在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程
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; |
---|
实现
- 异步请求被提交到request_queue中。
- request_queue实际上是一个表结构,”行”是fd、”列”是具体的请求。也就是说,同一个fd的请求会被组织在一起
- 异步请求有优先级概念,属于同一个fd的请求会按优先级排序,并且最终被按优先级顺序处理
- 随着异步请求的提交,一些异步处理线程被动态创建。这些线程要做的事情就是从request_queue中取出请求,然后处理之
- 为避免异步处理线程之间的竞争,同一个fd所对应的请求只由一个线程来处理
- 异步处理线程同步地处理每一个请求,处理完成后在对应的aiocb中填充结果,然后触发可能的信号通知或回调函数,回调函数是需要创建新线程来调用的(在当前函数调用可能会造成阻塞)
- 异步处理线程在完成某个fd的所有请求后,进入闲置状态
- 异步处理线程在闲置状态时,如果request_queue中有新的fd加入,则重新投入工作,去处理这个新fd的请求,新fd和它上一次处理的fd可以不是同一个
- 异步处理线程处于闲置状态一段时间后,没有新的请求,则会自动退出。等到再有新的请求时,再去动态创建
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 { voiddata;/ 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 { voiddata; structiocb obj; unsignedlongres; unsigned*longres2; }; //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的工作就只能靠用户程序来实现了。