本文讨论的是Linux环境下network I/O
对于典型的IO的访问,无论是文件的读取还是socket的read,数据都会从物理设备(硬盘或者网卡)拷贝到Linux的内核(kernel mode)的缓冲区,然后再从内核的缓冲区拷贝到应用程序(user mode)的地址空间。
一个read IO的操作过程经历2个阶段
1, 等待数据拷贝到内核。(对于socket,很多情况是有连接,但是没有数据传输,内核处于等待状态)
2,拷贝数据到进程所在地址空间。(当有数据到达内核缓冲区后,拷贝数据到user mode共进程使用)
以上2个阶段都不要等待就是异步IO模型,其中一个阶段需要等待就是同步IO模型。
因为有了这2个阶段,Linux系统下有多中访问网络模式的方案。
1, 阻塞IO(blocking IO)
2,非阻塞IO(non-blocking IO)
3,IO多路复用(IO multiplexing)
4,信号驱动IO(singal driven IO)
5,异步IO(asynchronour IO)
这里我们讲一下1,2,3这3个方案,他们都属于IO的同步模型,4在实际中并不常用,5是异步IO的模型。
阻塞IO
从图中可以看出,这是一个典型的最简单socket编程服务器端的处理流程。服务器调用的accept后,处于等待状态,等待第一个客户端的连接。当有一个client连接到这个进程所监听的端口后,就通过recvfrom函数去读取客户端传送过来的数据,如果此时客户端没有发送数据,recvfrom函数就持续等待(blocking),进入上面所说的第一阶段。当clinet发送数据到服务指定端口,内核将数据拷贝到内核缓冲区,并告诉系统内核已经准备好,并将数据拷贝到用户内存,至此进入第二阶段,然后kenrnel返回结果,用户进程读取client发过来的数据后recvfrom返回,结束block状态。所以阻塞IO的2个阶段都是block的。
通常在进程处理多个请求时,都是通过多线程,每个线程对应处理一个客户端连接。考虑到高并发的情况,应用会生成成千上万个线程,加大了对资源的消耗,用户层和内核频繁切换也导致CPU使用率上升。然而大多数连接都不是active的,都处于wait状态等待数据的ready。
非阻塞IO
从图中可以看到,当用户进程发出recvfrom的读操作时,如果没有数据到达kernel的缓冲区,它并不会等待,而是直接返回一个error。从user mode角度来讲,进程读一次数据并不需要一直等到数据的到来,而是马上返回结果,如果有数据就拷贝到user mode地址,没有的话返回一个错误。所以非阻塞IO的需要用户不断的主动询问kernel数据是否准备好。
IO多路复用
IO多路复用可以不用通过多线程去处理客户端的请求,一个线程就可以处理多个请求,redis就是通过此技术实现单线程模型。
IO多路复用就是我们常说的select,poll 和epoll三种实现方式。select和poll比较类似,主流操作系统都支持这种模式。epoll是Linux 2.6的内核引入,Windows不支持此模型。上图是select模型的展示。
当用户进程调用了select函数,把要监控的socket传入,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。所以,I/O 多路复用的特点是通过一种机制使一个进程能同时等待多个文件描述符(socket),而这些文件描述符其中的任意一个进入读就绪状态,select()函数就可以返回。重复以上操作,保证每个进入就绪的socket得到相应的处理。
IO多路复用和阻塞IO其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而阻塞IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个连接。如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程+阻塞IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
异步IO
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
异步IO,是用户空间与内核空间的调用方式反过来。内核是主动调用者,用户空间变成了被动接受者。用户空间的线程想内核注册各种IO事件的回调函数,由内核主动触发调用。异步IO在Linux2.6引入,目前不是很完善,起底层实现仍使用epoll,与IO多路复用相同,性能上没有明显占优。
模型比较
接下来我们重点讲解IO多路复用模型
IO多路复用就是通过select, poll, epll其中一种机制使一个进程可以监视多个文件描述符(这里可以理解为socket),一旦某个文件描述符准备就绪,能够通知user mode的应用程序进行相应的读写处理。
IO多路复用是同步IO,当读写事件就绪后需要自己负责读写工作,这个过程是阻塞的。异步IO的话不需要用户进程负责读写操作,而是由系统将数据从内核拷贝到用户空间。
IO多路复用select poll epoll三者比较
select:
支持目前所有的操作系统,单个线程能够监控的文件描述符是有限制的,Linux默认情况是1024个socket,可以通过重新编译内核提高最大值。
select的方法,有一个参数fd_set, 这个可以理解为一个存放文件描述符的集合即进程监控的socket的句柄,当每次调用select()时,都需要把fd_set集合从用户态拷贝到内核态, 如果集合很大,这个拷贝的开销是比较大的。然后内核会遍历传递进来fd_set集合,判断哪些文件描述符的IO状态存在改变,并去修改对应的socket的内容,通知进程文件描述符已经处于就绪状态。如果fd_set集合较大,每次select调用时所有文件描述符从user mode 拷贝到kernel mode,以及拷贝后内核遍历整个集合的开销是非常大的。
poll
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了连接数限制一个问题,并没有解决性能开销问题。
epoll
epoll是基于事件驱动的IO方式,是select和poll的增强版本。它将用户注册过的文件描述符的事件存放到内核的事件列表中,但不会像select一样每次把全部文件描述符进行拷贝,epoll只拷贝新添加的,所有每个文件描述符只需在用户空间和内核空间拷贝一次。在拷贝过程中,epoll采取了mmap共享内存的机制,比用户态拷贝到内核态效率更高。
Linux中提供三个epoll相关函数(Windows平台不支持):
1, epoll_create函数创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。
内核为epoll描述符创建了一个文件,开辟出一块内核高速cache区,这块区域用来存储我们要监管的所有的socket描述符,当然在这里面存储一定有一个数据结构,这就是红黑树,由于红黑树的接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。
2, epoll_ctl函数注册要监听的事件类型。四个参数解释如下:
epfd表示epoll句柄
op表示fd操作类型,有如下3种
EPOLL_CTL_ADD 注册新的fd到epfd中
EPOLL_CTL_MOD 修改已注册的fd的监听事件
EPOLL_CTL_DEL 从epfd中删除一个fd
fd是要监听的描述符
event表示要监听的事件
3, epoll_wait函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。
epfd是epoll句柄
events表示从内核得到的就绪事件集合
maxevents告诉内核events的大小
timeout表示等待的超时事件
函数返回一个就绪描述符链表。当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符,当调用epoll_wait的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据,如果没有则睡眠至超时,如果有数据则立即返回并将链表中的数据赋值到events数组中。这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低,但是epoll的话确实是非常适合这个时候使用。
对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到红黑树中之外,还会给内核中断处理程序注册一个回调函数,告诉内核,当这个描述符上有事件到达(或者说中断了)的时候就调用这个回调函数。这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入就绪链表rdlist中。
epoll没有对监控文件描述符数量的限制,还能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
水平触发(LT):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件
边缘触发(ET):当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。
mmap技术
上面我们谈到epoll是通过mmap实现数据一次拷贝,避免select和poll模式下设备把数据拷贝到内核,再从内核拷贝到用户态内存的2次拷贝,下面讲解一下什么是mmap技术。
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。虚拟内存的概念请google相关资料。
在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
传统的文件读取,内核从文件设备上读取内容到内核的分配的物理内存页,然后操作系统回把内核中的数据拷贝调用进程的虚拟进程所对应的用户空间的物理内存页,调用进程读取内存中内容。对于mmap而言,其减少了一次内核向用户内存的拷贝,他直接在用户进程的虚拟空间开辟一段虚拟空间直接映射到内核物理页,任何对虚拟空间内存的操作,都直接改变内核物理内存上的数据,从而改变文件内容。
mmap优点是对于读取大型文件有性能上优势,可用于实现高效的大规模数据传输。减少了数据的拷贝次数,提高了文件读取效率。
mmap缺点是文件如果很小,比如10字节,由于内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。虽然被映射的文件只有10字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此mmap函数执行后,实际映射到虚拟内存区域的是4096个字节,11~4096的字节部分用零填充。因此如果连续mmap小文件,会浪费内存空间。
mmap个参数设置和性能比较,请参见下面资料。
https://www.jianshu.com/p/eece39beee20
Linux Zero-Copy 资料