Java四种常见网络IO模型以及select、poll、e

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

详细介绍了Java 中常见的四种IO模型BIO、NIO、IO多路复用、AIO,以及select、poll、epoll系统函数。

1 网络IO操作的步骤

网络IO中一次请求和响应的流程IO可简单的分为以下几步,以服务器收发消息为例:

  1. Linux内核通过网卡读取客户端的请求数据,将数据读取到内核缓冲区(Page Cache)。数据从网卡到内核空间;
  2. 从内核缓冲区读取数据到Java进程缓冲区。数据从内核空间到用户空间;
  3. Java服务端进程在自己的用户空间中,处理客户端的请求。数据在用户空间中被处理;
  4. 处理完数据并构建好的响应之后,将数据从用户缓冲区写入内核缓冲区。
  5. Linux内核将内核缓冲区中的响应写入网卡,网卡通过底层的通讯协议,会将数据发送给目标客户端。

网络IO的过程中,总体可以分为两个阶段:

  1. 数据包到达网卡,并且被复制到内核空间缓存中,这段过程被称为准备阶段。在准备阶段,如果会阻塞调用线程,则被称为阻塞IO,否则称为非阻塞IO。
  2. 数据从内核空间复制到应用程序空间中,这样应用程序才能真正使用这些数据。在该阶段,如果会阻塞调用线程,则被称为同步IO,否则称为异步IO。

基于上面的步骤,常见的网络IO模型有四种:

  1. 同步阻塞IO(Blocking IO):即传统的BIO模型;
  2. 同步非阻塞IO(Non Blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。这里的NIO并非java中的NIO(New IO)库。
  3. IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java 中NIO的Selector和Linux中的epoll都是这种模型。
  4. 异步IO(Aynchronous IO):即经典的Proactor设计模式,异步非阻塞IO,也被称为AIO。

1.1 同步和异步

同步I/O:每个请求必须逐个地被处理,一个请求的处理会导致整个流程的暂时等待,这些事件无法并发地执行。用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行。

异步I/O:多个请求可以并发地执行,一个请求或者任务的执行不会导致整个流程的暂时等待。用户线程发起I/O请求后仍然继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

1.2 阻塞和非阻塞

阻塞和非阻塞是进程在访问数据的时候,请求操作是否准备就绪的一种处理方式。

阻塞:某个请求发出后,由于该请求操作需要的条件不满足,请求操作一直阻塞,不会返回,直到条件满足。

非阻塞:请求发出后,若该请求需要的条件不满足,则立即返回一个标志信息告知条件不满足,而不会一直等待。一般需要通过循环判断请求条件是否满足来获取请求结果。

同步和异步着重点在于多个任务执行过程中,后发起的任务是否必须等先发起的任务完成之后再进行。不管先发起的任务请求是阻塞等待完成,还是立即返回通过循环等待请求成功。

而阻塞和非阻塞重点在于请求的方法是否在条件不满足时被阻塞,是否立即返回。

2 同步阻塞IO

同步阻塞 IO (blocking IO)模型也被称为BIO,是最简单和最常见的模型。在linux中,默认情况下所有的socket都是blocking IO。

该模型中,用户空间的应用程序通过执行read调用(底层是recvfrom系统调用)来从socket中读取数据,在应用程序发起 read 调用后,会一直阻塞,直到数据包到达网卡上并复制到内核空间中,随后从内核空间拷贝到用户空间之后才会返回。

在这里插入图片描述

很多时候,在程序执行read调用的时候,数据包还没有到达,或者并没有被复制到内核空间中,此时read调用(底层是recvfrom系统调用)将会阻塞调用线程,等到数据被复制到内核空间中之后,在复制到应用程序空间的过程中,仍然会阻塞调用线程。

同步阻塞 IO 模型的特点就是IO操作的两个阶段都会阻塞调用线程。

BIO模型编程简单,在阻塞等待数据期间,用户线程挂起,此时基本不会占用 CPU 资源,且能够及时返回数据,无延迟;

但是由于同步阻塞的特性,服务器通常要为每一个客户端连接都分配一个独立的线程进行处理,即多线程方案。

在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

3 同步非阻塞IO

在linux系统下,可以通过设置socket使其变为non-blocking,即同步非阻塞IO(Non Blocking IO)模型。在Java中,通过执行channel.configureBlocking(false)方法来使其变为non-blocking。

该模型下的read调用(底层是recvfrom系统调用),有两种可能:

  1. 在内核缓冲区没有数据的情况下,recvfrom系统调用会立即返回,返回给进程一个调用失败的信息(EAGAIN 或 EWOULDBLOCK)。
  2. 在内核缓冲区有数据的情况下,recvfrom系统调用会将数据从内核缓冲复制到用户进程缓冲,这个数据拷贝的过程仍然是阻塞的。复制完成后,系统调用返回成功,应用进程获得了数据。

在这里插入图片描述

当因为数据没有准备好而返回给进程之后,进程(程序)可以干别的事情,然后再发起recvfrom系统调用,重复上面的过程,这个过程通常被称之为轮询(polling),该过程可以有效的利用CPU。

每次发起的 IO 系统调用后,如果处于IO准备阶段(数据还没准备好)则立即返回,通过轮询的操作,避免了调用线程一直阻塞。

但是,简单的NIO需要不断的重复发起IO系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,并导致上下文切换,系统资源利用率较低。

各种web服务器和框架底层一般很少直接使用这种模型,而是在其他更高级的IO模型中使用非阻塞IO这一特性。java的实际开发中,也不会涉及这种IO模型,Java的NIO也不是这里的NIO,而是另外的一种IO多路复用模型( IO multiplexing )。

4 IO多路复用

IO多路复用模型(IO multiplexing),就是用于解决同步非阻塞NIO模型中轮询耗费大量CPU的问题。

IO多路复用模型通过一个监听线程发起另一种形式的系统调用,由一个线程监听多个文件描述符(fd,linux系统把所有网络请求以一个fd来标识),一旦某个fd的操作就绪(一般是内核缓冲区可读/可写),该系统调用就会返回,随后监听线程可以通知程序对准备好了的fd进行对应的IO系统调用,比如通过recvfrom读取数据。有些地方也称这种IO方式为event driven IO,即事件驱动IO,因为不同的IO行为就绪之后,会返回不同的事件加以区分,比如读就绪事件,写就绪事件等。

目前支持IO多路复用有 select、poll、epoll等等系统调用函数,我们可以将多个socket连接注册到同一个select操作上,就能实现同时对多个socket连接的监听,当其中任何一个socket准备就绪,就能返回进行可读(可写),然后进程再进行recvfrom系统调用,将数据由内核拷贝到用户进程,当然这个拷贝的过程是阻塞的。

在这里插入图片描述

select、poll、epoll等等系统调用函数调用之后,同样会阻塞调用线程,但是和阻塞I/O所不同的,一次select、poll、epoll函数的调用可以同时阻塞并监听多个socket连接的IO操作,能达到在同一个线程内同时监听多个IO请求的目的,而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的,这就是IO多路复用的好处之一,系统不必创建大量的线程,从而大大减小了系统的开销。

IO多路复用模型中,对于注册的socket,一般也都设置成为non-blocking模型,但是对于用户程序是透明的,同样,原始non-blocking模型需要的轮询操作,也是由select、poll、epoll等系统调用函数在内核空间中帮我们完成了,减少无效的系统调用和上下文切换,同时减少了对 CPU 资源的消耗,这就是另一个好处。

Java4新增的NIO包中引入的选择器Selector,使用的就是IO多路复用模型,通过它,只需要一个线程便可以管理多个客户端连接。在底层在linux系统上,则是使用的是epoll系统调用。Selector配合通道Channel、缓存Buffer等类,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。

基于IO多路复用模型,进一步封装,产生了更加易于理解的Reactor模式,Netty、Redis、Nginx、Tomcat等非常多的软件和框架的底层都是使用的Reactor模式,也叫反应器模式(对事件产生反应)。Java NIO的Selector其实就是一个简单的Reactor模式的实现。

4.1 select/poll系统函数

select 调用和 poll 调用并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

首先会将绑定的socket都放在一个文件描述符集合中,在使用的时候,select调用首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后阻塞,由内核检测事件,当有网络事件产生时线程从阻塞中返回,随后遍历进程关注 Socket 集合,找到对应的Socket,并设置其状态为可读/可写,然后把整个Socket集合从内核态拷贝到用户态,用户态还要继续遍历整个Socket集合找到可读/可写的 Socket,然后对其处理。

select 调用使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,默认最大只能监听 1024个socket。poll 调用不再用BitsMap来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

对于select和poll的方式,需要从头到尾进行 2 次遍历文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

当客户端连接越多,集合越大,Socket 集合的遍历和拷贝会带来很大的开销,效率极差,于是有了下面的epoll模型。

4.2 epoll系统函数

完成epoll操作一共有三个步骤,即三个函数互相配合:

//建立一个epoll对象(在epoll文件系统中给这个句柄分配资源);
int epoll_create(int size);  
//向epoll对象中添加 连接的套接字;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
// 等待时间的产生,收集发生事件的连接,类似于select()调用。
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);  
复制代码

先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。

当执行 epoll_create 时 ,系统会在内核cache创建一个红黑树和就绪链表。

当执行epoll_ctl放入socket时 ,epoll会检测上面的红黑树是否存在这个socket,存在的话就立即返回,不存在就添加。然后给内核中断处理程序注册一个回调函数,告诉内核,如果这个socket句柄的中断到了,就把它放到准备就绪list链表里。如果网卡有数据到达,向cpu发出中断信号,cpu响应中断,中断程序就会执行前面的回调函数。红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn)。

epoll_wait就只检查就绪链表,如果链表不为空,就返回就绪链表中就绪的socket,否则就等待。只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

4.3 触发模式

epoll有两种工作模式:LT(level-triggered,水平触发)模式和ET(edge-triggered,边缘触发)模式。

水平触发(level-trggered):处于某个状态时一直触发。

  1. 只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直从epoll_wait中苏醒并发出可读信号进行通知。
  2. 只要文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直从epoll_wait中苏醒发出可写信号进行通知。

边缘触发(edge-triggered):在状态转换的边缘触发一次。

  1. 当文件描述符关联的读内核缓冲区由空转化为非空的时候,则从epoll_wait中苏醒发出可读信号进行通知。

  2. 当文件描述符关联的写内核缓冲区由满转化为不满的时候,则从epoll_wait中苏醒发出可写信号进行通知。

简单的说,ET模式在可读和可写时仅仅通知一次,而LT模式则会在条件满足可读和可写时一直通知。比如,某个socket的内核缓冲区中从没有数据变成了有2k数据,此时ET模式和LT模式都会进行通知,随后应用程序可以读取其中的数据,假设只读取了1k,缓冲区中还剩1k,此时缓冲区还是可读的,如果再次检查,那么ET模式则不会通知,而LT模式则会再次通知。

ET模式的性能比LT模式更好,因为如果系统中有大量你不需要读写的就绪文件描述符,使用LT模式之后每次epoll_wait它们都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!而如果使用ET模式,则在不会进行第二次通知,系统不会充斥大量你不关心的就绪文件描述符。

所以,使用ET模式时需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,否则可能出现读取数据不完整的问题。

同理,LT模式可以处理阻塞和非阻塞套接字,而ET模式只支持非阻塞套接字,因为如果是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行了。

默认情况下,select、poll都只支持LT模式,epoll采用 LT模式工作,可以设置为ET模式。

5 异步非阻塞 IO

本质上,select/epoll系统调用,属于同步IO,也是阻塞IO。都需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。IO多路复用,仍旧可以被归纳为归为同步阻塞模型。

IO多路复用需要先调用select询问数据状态的请求,然后再通过应用程序发起真正的读取数据请求,而异步非阻塞 IO(asynchronous IO,AIO),则是更进一步,读取数据的操作也不需要在应用程序中调用了。

AIO的基本流程是:用户线程通过系统调用,告知内核启动某个IO操作,用户线程随即返回。内核在整个IO操作(包括数据准备、数据复制)完成后,会通知用户程序,用户执行后续的业务操作。

AIO是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,而内核会完成数据准备和复制的两阶段操作,当内核处理完成,操作系统会通知相应的应用线程进行后续的操作。

AIO模式下,在内核的等待数据和复制数据的两个阶段,用户线程都不是阻塞的,实现了真正的异步非阻塞。用户线程只需要注册对应的事件回调函数即可收到对应的通知,所以说,异步IO有的时候,也叫做信号驱动 IO 。

在这里插入图片描述

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

JAVA7的时候,基于异步Channel的IO,在java.nio.channels包下增加了多个以Asynchronous开头的channel接口和类用于AIO通信,Java7称它们为NIO.2。

基于异步IO模型,进一步封装,产生了更加易于理解的Proactor模式。

参考资料:

  1. 这次答应我,一举拿下 I/O 多路复用!
  2. 5种IO模型 详解 包含select epoll原理

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!