高性能网络IO模型

网络IO的本质

任何IO事件处理可以分为两个过程:等待就绪(缺数据或DMA Copy)、数据拷贝(CPU Copy),与之相对的是阻塞与非阻塞、同步与异步是两组不同的概念。

  • 是否阻塞体现在socket 属性 O_NONBLOCK
  • 同步/异步体现在 IO读写api的区别上 

另外需要注意下面几点: 

  • IO 事件 , 对内核buffer而言,包括 缓冲区满、缓冲区空、缓冲区非空、缓冲区非满四种; 对tcp udp等协议而言,包括,已连接、关闭、半关闭、可读、可写、异常等等
  • 内核的buffer是为了减少磁盘IO的次数,而用户进程的buffer是为了减少系统调用的次数
  • DMA Copy由DMA(Driect Memory Access)芯片独立进行,不占用CPU资源
  • 一次系统调用至少包含两次内核与用户进程上下文切换

同步阻塞IO(BIO)

同步阻塞IO(BIO的处理过程)

同步阻塞IO(BIO)模型中,一个处理单元(进程或者线程)在一个IO事件的生命周期内只处理这一个事件。

通常的写法就是单个线程在一个连接的生命周期内全程服务这个连接。这种写法有下列问题:

  • 并发连接数受进程/线程数限制
  • 大量进程/线程wait闲置
  • 频繁的上下文切换

高性能IO模型

有下列模型/机制可供使用

  • IO多路复用
  • 非阻塞IO(NIO)
  • 信号(事件)IO
  • 异步非阻塞IO(AIO)

 上述模型并不完全独立,是相辅相成的机制

IO多路复用

考虑一个常见的场景:

一个线程T 从标准输入读取内容,通过一个套接字输送给服务端(IM场景)

这时这个线程T 同时持有两个描述符(socket描述符、标准输入描述符)

如果没有IO多路复用

当线程T 正在读标准输入时,服务端因异常关闭主动断开了连接,此时线程T将感知不到

情况就是下面我们常见的代码

1 while(read(stdin, buffer, len,size) > 0) {
2       write(svr_fd, buffer ,len);
3 }

IO多路复用是指:

  • 使用调用select、pselect、poll、epoll_wait等函数,替代accept、read、write等函数阻塞在IO处理的第一步:等待就绪上
  • 一次IO系统调用可以阻塞在多个描述符(套接字)上

调用IO 复用的api(select、pselect、poll、epoll等)时,其阻塞在多个文件描述符(套接字)上,这与普通的阻塞式IO函数如:read、write、close等不同,这些函数都是阻塞在一个文件描述符上。以select为例,select等待多个文件描述符(套接字)上发生IO事件,可以设置等待超时,select只返回描述符就绪的个数(一般可认为是IO事件的个数),用户需要遍扫描整个描述符集处理IO时间。伪代码如下:

1 while(true){
2     select(描述符集,超时值)
3     for(fd in 描述符集合){
4         if ( fd has IO事件){
5             处理IO事件
6         }
7     }
8 }

真实的select要比此复杂,其可指定自己关心的描述符集,分读、写、出错三种描述符集。

Select的缺点很明显,当描述符集很大时,遍历一遍集合的耗时将会很大,因此会有一个FD_SETSIZE宏限制。后续的epoll则优化的此问题,只返回发生的IO事件及其关联的描述符。

下图是多路复用处理过程

非阻塞IO(NIO)

非阻塞是指开启描述符/socket的O_NONBLOCK标志位。

对此类socket发出read、write等系统调用,如IO事件未就绪,则系统调用直接结束,并且errno将被设置为EAGAIN( EWOULDBLOCK ),意指待会再试, 可结合轮询构成一种可用的模型,但很少见。伪代码如下:

1 while(true) {
2     ret=recv(描述符)
3     if(ret != 错误 && ret != 结束){
4         处理IO事件
5     }
6 }

 

 

但NIO+轮询并不是一种好的选择,频繁的轮询白白耗费CPU资源,还造成大量的上下文切换。故而后面提到的 select 、poll、epoll等等待就绪事件的方法实际都是阻塞的

信号(事件)驱动IO 

信号驱动式IO是在NIO的基础上,事先向内核注册信号处理程序(设置回调),内核在IO就绪之后,将直接向进程发送SIGIO信号(执行回调),用户进程可以避免轮询

 

纵观各种读写的IO操作,都是首先等待内核准备好数据或准备好存放数据的内核空间,然后执行内核空间与用户进程空间之间的数据拷贝。其中,信号驱动式IO模型就是在内存做好准备之后,向用户进程发送SIGIO信号,通知用户进程执行剩下的数据拷贝的操作。

实际上,SIGIO 一般只用在UDP协议,而TCP基本无效。

原因是,UDP协议中能触发SIGIO信号的IO事件只有两种:

  • 有数据报可读
  • 套接字发生异步错误

而 TCP中能触发SIGIO的IO事件太多,且信号处理程序不能直接获取到就绪的事件类型和事件源FD

并且,信号IO不适合注册多个套接字(IO多路复用)

异步非阻塞IO(AIO)

首先AIO是异步的, 且是非阻塞的. 相较于前几种IO模型的最大的区别,在于其在IO处理过程中的第二步:此模型将第二步(处理已就绪数据)一并交给内核处理。在所有事情做完后告知用户进程(信号或者回调函数)

以读为例,过程如图:

 

 

Linux 实现的AIO在网络IO中一般也不使用,原因有:

  • 不好实现IO多路复用(通过信号不能区分)
  • IO处理过程中出现异常用户进程不好干预
  • 内核进行CPU Copy同样需要占用CPU资源,高并发场景下性能提升有限

可以看到异步IO实在内核已完成IO操作之后,才发起通知,时机不同于信号(事件)驱动式IO。Linux中异步IO系统调用皆以aio_*开头。操作完成之后的通知方式可以是信号,也可以是用户进程空间中的回调函数,皆可通过aiocb结构体设置。目前linux 虽然已有aio函数,但是即使是epoll并不是基于aio, 这与windows iocp和FreeBSD的kqueue纯异步的方案是不同的,普遍的测试结果,epoll性能比iocp还是有微小的差距。

高性能网络IO实现

常见的高性能IO函数select , epoll等处理流程如图:

第二步也可以使用MMAP来减少读写次数,但java中mmap只能映射本地文件(FileChannel),不支持映射socket

java程序还需要考虑jvm堆与native堆之间的数据拷贝,更为复杂(DirectByteBuffer 在常说的native堆,FileChannel.map方法创建的MappedByteBuffer是虚拟地址映射的内核buffer)

关于sendfile, 在linux 2.4版本之前(https://www.ibm.com/developerworks/cn/java/j-zerocopy/)

Copy 1、sendfile引发 DMA 引擎将文件内容拷贝到一个读取缓冲区。
Copy 2、然后由内核将数据拷贝到与输出套接字相关联的内核缓冲区。
Copy 3、数据的第三次复制发生在 DMA 引擎将数据从内核套接字缓冲区传到协议引擎时

在linux2.4及以后的版本,内核为此做了改进

Copy 1、sendfile引发 DMA 引擎将文件内容拷贝到内核缓冲区。
Copy 2、数据未被拷贝到套接字缓冲区。取而代之的是,只有包含关于数据的位置和长度的信息的描述符被追加到了套接字缓冲区。DMA 引擎直接把数据从内核缓冲区传输到协议引擎,从而消除了剩下的最后一次 CPU 拷贝。

另需要注意:

  • socket是否设置为阻塞并不影响 selector函数是否阻塞.  
  • socket是否一定要设置为非阻塞呢? 这需要考虑是水平触发还是边缘触发,还需要考虑用户程序是多次还是一次read\write
  • 尽量将socket设置为非阻塞,以防止read、write在第二步阻塞掉线程. 

Epoll的优点

  • fd集合直接存在内核cache,借mmap避免在用户进程与内核间频繁拷贝. 而select 、pselect、poll等API,都存在大量内核与用户进程间的FD拷贝,并且需要用户遍历查找就绪FD
  • 使用红黑树结构化fd集合以保证高效插入、查找、删除
  • epoll_wait 仅仅只是扫描已就绪fd链表(rdlist)

 

posted @ 2018-05-11 18:11  lvyahui  阅读(984)  评论(0编辑  收藏  举报