Java NIO - IO多路复用

目录

IO模型

IO实现

同步阻塞IO

核心流程

交互流程

同步阻塞IO模型

同步非阻塞IO

核心流程

交互流程

同步非阻塞IO模型

总结

优点

缺点

SELECT核心流程

交互流程

IO多路复用模型

总结

优点

缺点

POLL

优点

缺点

EPOLL核心流程

交互流程

总结

优点

缺点

LT VS ET

总结


IO模型

在Java中,常见的IO模型有4种,

  • 同步阻塞IO(Blocking IO)
  • 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
  • IO多路复用(IO Multiplexing):也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。这里复用的是指复用一个或几个线程,用一个或一组线程处理多个IO操作,减少系统开销小,不必创建和维护过多的进程/线程;
  • 异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

IO实现

目前流程的多路复用IO实现主要包括四种: selectpollepollkqueue。下表是他们的一些重要特性的比较:

IO模型 相对性能 关键思路 操作系统 JAVA支持情况
select 较高 Reactor windows/Linux 支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels 2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型
poll 较高 Reactor Linux Linux下的JAVA NIO框架,Linux kernels 2.6内核版本之前使用poll进行支持。也是使用的Reactor模式
epoll Reactor/Proactor Linux Linux kernels 2.6内核版本及以后使用epoll进行支持;Linux kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO 支持,所以Linux下使用epoll模拟异步IO
kqueue Proactor Linux 目前JAVA的版本不支持

多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下多路复用IO技术发挥不出来它的优势。另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。

同步阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,应用程序会一直处于阻塞等待状态,直到内核把数据准备好了返回给应用程序

交互流程

1)服务端进行初始化:新建 socket、绑定地址、转为服务端 socket

2)服务端调用 accept,进入阻塞状态,等待客户端连接

3)客户端新建 socket,向服务端发起连接

4)服务端和客户端通过 TCP 三次握手建立连接

5)服务端继续执行 read 函数,进入阻塞状态,等待客户端发送数据

6)客户端向服务端发送数据

7)服务端读取数据,执行逻辑处理

同步阻塞IO模型

Java NIO - IO多路复用_第1张图片

1)应用进程发起 read 系统调用

2)应用进程阻塞等待数据就绪

3)数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核 socket 缓冲区时,此时处于就绪状态

4)将数据从内核拷贝到应用程序缓冲区,返回成功

多线程版本:文中使用的例子是单线程,如果是多线程则在每个 socket 建立连接后新建线程去负责处理该 socket 后续的流程,这样就不会由于单个 socket 阻塞住而影响到其他 socket。

总结

单线程:某个 socket 阻塞,会影响到其他 socket 处理。

多线程:当客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。

 

同步非阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,内核会直接返回错误,应用程序不断轮询内核,直到内核把数据准备好了返回给应用程序。

交互流程

1)服务端调用 accept,数据未就绪,内核返回-1

2)服务端调用 accept,数据未就绪,内核返回-1

3)服务端调用 accept,数据未就绪,内核返回-1

4)客户端新建 socket,向服务端发起连接

4)服务端调用 accept,服务端和客户端通过 TCP 三次握手建立连接

5)服务端执行后续逻辑处理

同步非阻塞IO模型

Java NIO - IO多路复用_第2张图片

总结

提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。

优点

单个 socket 阻塞,不会影响到其他 socket 

缺点

需要不断的遍历进行系统调用,有一定开销

SELECT核心流程

1)应用程序首先发起 select 系统调用,传入要监听的文件描述符集合

2)内核遍历应用程序传入的 fd 集合,如果遍历完一遍后发现没有就绪的 fd 则用户进程会进入阻塞状态,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回

3)应用程序遍历 fd 集合,找到就绪的 fd,进行相应的事件处理

/**
 * 获取就绪事件
 *
 * @param nfds      3个监听集合的文件描述符最大值+1
 * @param readfds   要监听的可读文件描述符集合
 * @param writefds  要监听的可写文件描述符集合
 * @param exceptfds 要监听的异常文件描述符集合
 * @param timeval   本次调用的超时时间
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

交互流程

1)用户空间发起 select 系统调用,将监听的 fd 集合从用户空间拷贝到内核空间

2)内核遍历 fd 集合,检查数据是否就绪

3)如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程

4)当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket

PS:客户端发送数据到数据进入服务端内核的流程类似下面 epoll 的流程

5)socket 检查是否有阻塞等待的进程,如果有则唤醒该进程

6)用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间

7)用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区

8)最后执行逻辑处理。

IO多路复用模型

Java NIO - IO多路复用_第3张图片

 

fd_set

fd_set 在 select 的整个调用过程中表达了两种不同的意思。

在入参时,fd_set 表示应用程序要监听哪些 fd;在回参时,fd_set表示哪些 fd 已经就绪了。

应用程序传入的 fd_set 其实是个位图,例如我们要监听 fd = 1、fd = 4,则传入 0000 0101,也就是 5。

这边使用的 long 类型数组来实现位图:1个 long 可以表示64位,则16个long可以表示1024位。

当内核处理完毕,将就绪的 fd 返回时,会将就绪的 fd 对应的位标记为1,然后覆盖掉入参的 fd_set,所以我们最终返回时的 fd_set 表示的是哪些 fd 是就绪的。

总结

    将 socket 是否就绪检查逻辑下沉到操作系统层面,避免大量系统调用。

    告诉你有事件就绪,但是没告诉你具体是哪个 FD。

优点

    不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题

缺点

    单进程监听的 FD 存在限制,默认1024

    每次调用需要将 FD 从用户态拷贝到内核态

    不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符

    入参的3个 fd_set 集合每次调用都需要重置

POLL

/**
 * 获取就绪事件
 *
 * @param pollfd  要监听的文件描述符集合
 * @param nfds    文件描述符数量
 * @param timeout 本次调用的超时时间
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int poll(struct pollfd *fds,
         unsigned int nfds,
         int timeout);
 
struct pollfd {
    int fd;         // 监听的文件描述符
    short events;   // 监听的事件
    short revents;  // 就绪的事件
}

 

poll 函数基本同 select,只是对 select 进行了一些小优化,一个是优化了1024个文件描述符上限,另一个是新定义了 pollfd 数据结构,使用两个不同的变量来表示监听的事件和就绪的事件,这样就不需要像 select 那样每次重置 fd_set 了。
总结

跟 select 基本类似,主要优化了监听1024的限制。

优点

    不需要每个 FD 都进行一次系统调用,导致频繁的用户态内核态切换

缺点

    每次需要将 FD 从用户态拷贝到内核态

    不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符

EPOLL核心流程

1)应用程序调用 epoll_create,内核会分配一块内存空间,创建一个 epoll,最后将 epoll 的 fd 返回,我们后续可以通过这个 fd 来操作 epoll 对象

2)应用程序不断调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,内核通过红黑树的结构来高效的维护我们传入的 fd 集合

3)应用程序调用 epoll_wait 来获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞,否则直接返回就绪的事件。

4)应用程序根据内核返回的就绪事件,进行相应的事件处理

/**
 * 创建一个epoll
 *
 * @param size epoll要监听的文件描述符数量
 * @return epoll的文件描述符
 */
int epoll_create(int size);
 
/**
 * 事件注册
 *
 * @param epfd        epoll的文件描述符,epoll_create创建时返回
 * @param op          操作类型:新增(1)、删除(2)、更新(3)
 * @param fd          本次要操作的文件描述符
 * @param epoll_event 需要监听的事件:读事件、写事件等
 * @return 如果调用成功返回0, 不成功返回-1
 */
int epoll_ctl(int epfd,
              int op,
              int fd,
              struct epoll_event *event);
 
/**
 * 获取就绪事件
 *
 * @param epfd      epoll的文件描述符,epoll_create创建时返回
 * @param events    用于回传就绪的事件
 * @param maxevents 每次能处理的最大事件数
 * @param timeout   等待I/O事件发生的超时时间,-1相当于阻塞,0相当于非阻塞
 * @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
 */
int epoll_wait(int epfd,
               struct epoll_event *events,
               int maxevents,
               int timeout);

交互流程

1)用户空间调用 epoll_create ,内核新建 epoll 对象,返回 epoll 的 fd,用于后续操作

2)用户空间反复调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,底层通过红黑树来高效的维护 fd 集合

3)用户空间调用 epoll_wait 获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞

4)客户端向服务端发送数据,数据通过网络传输到服务端的网卡

5)网卡通过 DMA 的方式将数据包写入到指定内存中(ring_buffer),处理完成后通过中断信号告诉 CPU 有新的数据包到达

6)CPU 收到中断信号后,进行响应中断,首先保存当前执行程序的上下文环境,然后调用中断处理程序(网卡驱动程序)进行处理:

    根据数据包的ip和port找到对应的socket,将数据放到socket的接收队列;

    执行 socket 对应的回调函数:将当前 socket 添加到 eventpoll 的就绪列表、唤醒 eventpool 等待队列里的用户进程(设置为RUNNING状态)

7)用户进程恢复运行后,检查 eventpoll 里的就绪列表不为空,则将就绪事件填充到入参中的 events 里,然后返回

8)用户进程收到返回的事件后,执行 events 里的事件处理,例如读事件则将数据从内核缓冲区拷贝到应用程序缓冲区

9)最后执行逻辑处理。

总结

epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。

优点

解决了 select 和 poll 的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。

缺点

    跨平台性不够好,只支持 linux,macOS 等操作系统不支持

    相较于 epoll,select 更轻量可移植性更强

    在监听连接数和事件较少的场景下,select 可能更优

LT VS ET

LT:Level-triggered,水平(条件)触发,默认。epoll_wait 检测到事件后,如果该事件没被处理完毕,后续每次 epoll_wait 调用都会返回该事件。

ET:Edge-triggered,边缘触发。epoll_wait 检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。

小结

epoll 和 select、poll 默认都是 LT 模式,LT 模式会更安全一点,而 ET 则是 epoll 为了性能开发的一种新模式,LT 模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。

总结

可以看到从最初的同步阻塞IO,到现在主流的 epoll,其实是一个不断演进的过程,就像我们的业务系统一样。

同步阻塞IO的方式实现比较简单,同时在当时可能已经能满足需求了,因此被最早提出来,然后随着不断的发展,在一些场景下,同步阻塞IO逐渐不能满足需求,于是操作系统底层开始优化,提出了非阻塞的模式。类似的,同步非阻塞IO也存在一定的问题,于是就有了后续的IO多路复用。

现在还有一种更牛逼的IO模型也在发展,叫做异步IO,这种模型下,你只需要一次非阻塞的系统调用,后续的事情全部由内核来帮你完成。不过异步IO当前在 linux 下还不够完善,所以当前 linux 的主流还是 epoll。


 激动的心,颤抖的手

 借用客官你们发财的小手,给个鼓励吧

Java NIO - IO多路复用_第4张图片

你可能感兴趣的:(JAVA,java)