IO模型相信做开发的都不陌生,我在深入去了解Java NIO的时候就被IO模型的各种概念搞的痛不欲生。阻塞和非阻塞,同步和异步,没有真正理解它们的之前,难以搞清楚它们之间的本质区别。由于工作中常常接触到,最后痛定思痛。花了些时间去研究它。看了很多相关的资料,博客。很多文章都用生活中的例子来类比阻塞非阻塞,同步和异步。其中同步和异步是比较好理解的,生活中的例子也很贴切。但是阻塞和非阻塞在生活中就很难找到这种比较贴切的例子,稍不注意就会和同步异步混为一谈了。本文就谈一谈IO模型,一为记录知识,二为加深理解。
预备知识
在理解IO模型概念之前,首先要明白这几个概念:
线程的阻塞
线程阻塞通常是指一个线程在执行过程中暂停,以等待某个条件的触发。线程进入阻塞状态的原因包括:
- 等待阻塞:当线程执行了某个对象的wait()方法时,线程会被置入该对象的等待集中,直到执行了该对象的notify()方法wait()/notify()方法的执行要求线程首先获得该对象的锁。
- 同步阻塞:当多个线程试图进入某个同步区域(同步锁)时,没能进入该同步区域(同步锁)的线程会被置入锁定集(锁池)中,直到获得该同步区域的锁,进入就绪状态。
- 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
线程处于阻塞状态时,线程会被系统调度而暂时挂起,等待条件触发唤醒。也就是说线程因阻塞而被调度时是不占用CPU资源的,同时也会导致线程的上下文切换。
线程的上下文切换
这个词常出现在和并发有关的场合,不过当线程阻塞而被系统调度时也会出现上下文切换。那么所谓的上下文切换,到底是个什么操作,它在切换什么呢?对于单核CPU来说,CPU在一个时刻只能运行一个线程,为了控制线程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个线程的执行。这种行为被称为线程切换(线程是CPU能控制的最小执行单元)。因此为了能恢复一个线程之前运行的状态,在线程调度之前必须把线程的状态冻结,实际上就是把当前线程在CPU高速缓存,寄存器中的数据保存起来。任何线程都是在操作系统内核的支持下运行的,是与内核紧密相关的。从一个线程的运行转到另一个线程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息(PCB又名进程控制块,PCB中记录了操作系统所需要的、用于描述进程情况及控制进程运行所需要的全部信息)。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
总而言之线程上下文切换很耗资源。
文件描述符 fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。操作系统内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
用户态与内核态
这两个词在线程切换时也是经常碰到,对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的。但在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。好了我们现在需要再解释一下什么是内核态、用户态:
- 内核态:进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
- 用户态:进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
简单来说就是操作系统把内存寻址空间分为了两个空间,内核空间和用户空间,当代码运行在内核空间时那么线程就在内核态,当代码运行在用户空间时那么线程就在用户态。其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:”我要读取磁盘上的某某文件”。其实就是通过调用系统接口让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以开开心心的往下执行了。简单说就是应用程序想要读取磁盘,但应用程序没这个权利,那咋办呢?委托给内核,内核把磁盘的数据读取后再把数据返回给应用程序,就这么件简单的事情。
IO分类
IO分为两种类型分别为磁盘IO和网络IO,而IO读取数据的方式又分为两个维度,就是文章开头说的。“阻塞(Blocking)”、“非阻塞(Non-blocking)”、“同步”、“异步”。总结起来是四种模型 同步阻塞、同步非阻塞;异步阻塞、异步非阻塞 。每个 IO 模型都有自己的使用模式,它们对于特定的应用程序都有自己的优点。不管是哪种IO模型,从根本上IO操作都分为两个阶段。
磁盘IO | 网络IO | |
---|---|---|
阶段一 | 将数据从磁盘读取到内核空间 | 等待网络上的数据流到达,然后被复制到内核的某个缓冲区 |
阶段二 | 将数据从内核空间拷贝到用户空间 | 将数据从内核缓冲区复制到应用进程缓冲区 |
其中阶段一可概括为数据的准备阶段,阶段二可概括为数据的拷贝阶段,前文所说的阻塞和非阻塞是针对阶段一而言的,而同步和异步是针对阶段二而言的,因此理解了这两个阶段,基本上也就理解了IO模型。
Linux 下五种I/O模型
这里先把概念都抛出来吧,后面我会通过生活中的一个例子来结合概念去理解它。Linux下实现了五种IO模型,下面就分别来介绍一下这5种IO模型的异同。它们分别如下:
- 阻塞式I/O:所有套接字默认
- 非阻塞I/O
- I/O复用(select,poll,epoll)
- 信号驱动式(SIGIO):内核在描述符就绪时发送SIGIO通知进程
- 异步I/O(POSIX的aio_系列函数):不会阻塞。内核完成后整个操作,通知进程。
阻塞IO模型
最传统的一种IO模型,也是实现思路最简单的IO模型,即在读写数据过程中会发生阻塞现象。当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。它的流程图如下
非阻塞IO模型
当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果(ps:是不是和同步异步的概念很像,对于异步而言也是在发起请求后马上返回,通过回调告诉调用者最终结果)。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。因此,在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,非阻塞IO不会交出CPU,而会一直占用CPU的资源,相比阻塞IO模型,因为用户线程不会进入系统调度,所以在轮询期间用户线程还可以干点别的事情,在一定程度上提高了资源的利用率。但这也是会导致一个非常严重的问题,因为需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高。它的流程图如下
多路复用IO模型
多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。从非阻塞IO的实现原理可以看出,由于非阻塞需要一直轮询内核数据是否就绪,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间。结合前面两种模式。如果轮询不是进程的用户态,而是有人帮忙就好了。多路复用正好处理这样的问题。多路复用的特点是通过一种机制一个进程能同时等待IO文件描述符,内核监视这些文件描述符(套接字描述符),这就是select函数,多个进程的IO可以注册到同一个select上,当用户进程调用该select,select会监听所有注册好的IO,如果所有被监听的IO需要的数据都没有准备好时,select调用进程会阻塞。当任意一个IO所需的数据准备好之后,select调用就会返回,然后进程在通过recvfrom来进行数据拷贝。对于监视的方式,可分为 select, poll, epoll三种方式。这几个函数都是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用获取准备好的数据,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。它的流程图如下
信号驱动IO模型
当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后立马返回,用户线程会继续执行,等数据准备好后,内核为该进程产生一个SIGIO信号。就用recvfrom函数来拷贝数据到用户空间(这个过程是阻塞的)。这个一般用于UDP中,对TCP套接口几乎是没用的。原因是在TCPsocket中很多事件均会产生SIGIO信号,因为在同一个时候产生这种信号的原因太多我们不能区分到底是哪一种情况产生的SIGIO信号,所以在TCP中信号驱动是不太适合使用的,相反在UDP中却比较适合使用。信号驱动IO模型如下图
异步IO模型
异步操作的原理简单来说,就是我们给内核定下某个操作,让内核在完成整个操作的时候通知我们。前面说的四种IO模型都是同步IO模型,因为当数据准备好了之后,对于IO的第二阶段,都需要用户线程主动去从内核空间把数据拷贝到用户空间,但实际上这个步骤对于所有的IO操作都不可避免,那为什么不能让内核把数据拷贝到用户空间,万事俱备了之后再通知用户线程呢。异步IO就解决了这个问题,因此异步IO模型是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成。异步IO是需要操作系统的底层支持,在Java 7中,提供了Asynchronous IO,简称AIO。而我们之前一直用的Java NIO是同步非阻塞IO。这里对Java中出现的,BIO,NIO,AIO做个总结
类别 | IO模型实现 |
---|---|
BIO(Block IO) | 阻塞同步IO模型 |
NIO(New IO) | 非阻塞同步IO模型 |
AIO(Asynchronous IO) | 非阻塞异步IO模型 |
对于异步IO模型,其原理图如下
结合生活例子理解IO模型
通过生活中的例子可以更好的帮助我们理解IO模型。我以去书店买书为例,在买书的例子中有三个必不可少的对象,它们分别是小明,书店以及出版社。小明去书店买书,书店去出版社采购书籍。还有一个限制条件,小明不能直接去出版社买书。这里实际上就是模拟了IO的工作方式,小明代表的是应用程序,书店代表的是操作系统内核,出版社代表的是磁盘。因此买书的过程就大致是一个IO过程(为什么小明不能直接去出版社买书呢,因为应用程序也不能直接读磁盘)。好了下面开始五种IO模型的场景模拟吧。
- 阻塞IO: 小明准备去书店买本小说《三国演义》,于是打电话跟书店说:“服务员,我要买本三国演义”(发起IO请求)。可遗憾的是书店并没有存货,于是书店只能先去出版社把书采购回来(读取磁盘)。小明在这个过程中呢?小明只能干等着书店把书采购好(阻塞),在书店没有反馈之前他不能做任何事情。然后等待书店的通知,过了一段时间书店总算把三国演义采购回来了,但这时候书还只是在书店,因此小明还需要自己去书店把书拿回家(同步),然后小明就可以开心的看小说了。这里有两个需要注意的地方,1.小明在等书店反馈期间不能做任何事情,只能等;2.小明要买书,书店就会有一个服务员给小明提供服务。小红也要买书,书店就会有新的服务员给小红提供服务,也就是说只要有人请求要买书,书店都会给这个人提供一个单独的服务员,简单来说就是一对一的VIP服务。这样的工作方式在放生活中简直难以理解,可是早期的操作系统就是设计的这么直白。
- 非阻塞IO: 小明准备去书店买本小说《山海经》,于是打电话跟书店说:“服务员,我要买本山海经”(发起IO请求)。可遗憾的是书店并没有存货,于是书店只能先去出版社把书采购回来(读取磁盘)。有了之前的买书经验,这次小明变聪明了。他也不干等了,而是挂断了电话准备去做点别的事情。不过小明也不知道什么时候书店会把书采购回来,于是只能隔一段时间就打电话问书店,书采购好了没有(非阻塞轮询)。过了一段时间书店总算把山海经采购回来了,但这时候书还只是在书店,小明仍然需要自己去书店把书拿回家(同步),然后小明就可以开心的看小说了。相比于阻塞IO,好处就是小明不用一直干等了,但是小明必须时不时打电话给书店询问情况。这对于书店来说这并不是一个好方式,因为小明总是打电话给书店,导致书店的电话大部分时间占线而给其他客人造成了较差的用户体验(占用较高的CPU资源)
- 信号驱动IO: 小明准备去书店买本小说《东周列国志》,于是打电话给书店。:“服务员,我要买本东周列国志”(发起IO请求)。可遗憾的是书店并没有存货,于是书店只能先去出版社把书采购回来(读取磁盘)。小明有了前两次的买书经验,觉得自己轮询,总得时不时的打个电话,问下书到了没有,也太麻烦了。于是他干脆给书店留了自己的电话号码说:“这是我的号码,如果书到了,打电话告诉我,我来取书”(信号驱动)。这种方式完全的做到了非阻塞,但小明需要自己去书店取书,所以仍然是同步的方式。
- 多路复用IO: 小明准备去书店买本小说《山海经》,于是打电话跟书店说:“服务员,我要买本山海经”(发起IO请求),小明的朋友小红也打电话给书店说:服务员,我要买本《三国演义》(多个IO请求)。可遗憾的是书店并没有存货,于是书店只能先去出版社把书采购回来。书店这边呢,随着书店生意越来越好,以前的一对一VIP服务方式难以支撑多个IO请求了,100个人来买书难道还真招100个服务员来一对一服务吗?于是书店老板改变了做法,服务员接到买书请求后就挂断了电话(非阻塞),然后把这个买书请求放到一个清单中,然后服务员只需轮询这份清单,看书采购回来了没有。如果采购回来了就打电话通知买书的人,让他过来取书(同步)。这样只需要一个服务员就能处理100个人的买书请求,简直完美。对于这种方式,服务员查询清单这个过程仍然是阻塞的(当知道出版社有书时,服务员必须等待书采购并货运至书店,类比于磁盘数据读取到内核空间这个过程中,内核线程阻塞),所以IO多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上,但对于用户来说这种方式是非阻塞同步的。
- 异步IO: 小明准备去书店买本小说《镜花缘》。于是到书店的官网上提交了镜花缘书籍的请求(ps:书店也升级了),然后小明填写了自己的住址就下单了,然后就做自己的事情去了(非阻塞)。书店看了下书单请求,发现没有这本书,没关系先去出版社采购回来吧(读取磁盘),书采购回书店后,书店的老板根据小明留下的住址,将书通过邮寄的方式寄到了小明家里(把数据从内核态拷贝到用户态),小明等到送货上门的书后就可以愉快的看小说啦。这种方式就比较符合我们的生活习惯了,是完完全全的异步非阻塞。因为整个过程中小明从提交买书的请求,到把书拿到手,什么都不用做,只管躺着玩就行了(果然世界还是属于懒人的)。
本文所讨论的IO模型来自大名鼎鼎的《unix网络编程:卷1套接字联网API》。文中的IO模型的图片也取自该书。对于阻塞非阻塞,同步异步的理解这里再总结一下。同步异步好理解,它是针对请求而言的,客户端请求服务端如果立即返回,结果通过回调的方法返回,那么这种方式就是异步的;如果客户端请求服务端,没有立即返回而是等待服务端的结果再返回,那么这种方式就是同步的。阻塞非阻塞是针对客户端和服务端中间多一个第三方而言的,例如应用程序要访问磁盘,应用程序没权限,只能委托给内核(第三方)。在委托之后,客户端要不要等待结果返回,如果要等待就是阻塞的,不等待就是非阻塞的。(因为阻塞就意味着会有系统调度)。下图是五种IO模型的比较图。
关于epoll,poll,select
多路复用是一种机制,可以用来监听多种描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是同步的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。这三个方法是内核中的方法,通过C语言实现。下面分别介绍一下这个三个方法的原理
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。这3类文件描述符是在用户空间创建然后拷贝到内核中。它们分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。调用后select函数会阻塞,采用轮询方式,遍历所有fd,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。 将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。在用户态中通过将传入的struct pollfd结构体数组拷贝到内核中进行监听。 pollfd结构体如下
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,会将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。同样用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll操作过程需要三个接口,分别如下:
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。epoll_create方法的参数size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。上面三个方法中有两个都有epoll_event参数,它是一个结构体,封装了监听的事件,struct epoll_event结构如下
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
epoll_ctl方法: 函数是对指定描述符fd执行op操作。
方法名 | 解释 |
---|---|
epfd | 是epoll_create()方法的返回值。 |
op | 表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件 |
event | 是告诉内核需要监听什么事 |
int epoll_wait方法:等待epfd上的io事件,最多返回maxevents个事件。
方法名 | 解释 |
---|---|
events | 表示从内核得到事件的集合 |
timeout | 超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞) |
epoll的两种工作模式
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
模式 | 解释 |
---|---|
LT模式 | 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件 。下次调用epoll_wait时,会再次响应应用程序并通知此事件。 |
ET模式 | 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件 。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。 |
在 select/poll中,进程只有在调用方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制
。这正是epoll的魅力所在。)
总结
下面总结一下各个方法处理I/O的细节,下面1到4就是IO处理过程:
用户态将文件描述符传入内核的方式
select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。内核态检测文件描述符是否可读可写的方式
select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。如何找到就绪的文件描述符并传递给用户态
select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。继续重新监听时如何重复以上步骤
select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
epoll:无需重新构建红黑树,直接沿用已存在的即可。三种方式的效率
select:函数select每次调用都会线性扫描全部的FD集合,这样效率就会呈现线性下降,把FD_SETSIZE改大的后果就是,会因为前面的过慢导致后面的超时
poll:效率问题和select一样,区别就是将FD集合改成了线性链表。FD的上限不受限制
epoll:Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。