【性能基石之IO~~~BIO、NIO、多路复用选择器、多路复用选择器的实现方案select、poll、epoll、Java基于NIO实现的Selector多路复用选择器】

一.知识回顾

【0.IO在开发中有着举足轻重的地位,所以我们非常有必要学习。IO性能基石专栏都整理好了,可根据需要进行学习!】
【1.性能基石之IO~~~Linux操作系统相关知识体系补充&虚拟文件系统&文件描述符&PageCache内核缓存页】
【2.性能基石之IO~~~Page Cache缓存页&直接IO、缓存IO、内存映射mmap&文件一致性问题&Dirty概念&解决方案&Buffer IO在堆内,堆外IO详细过程与mmap映射过程】

二.BIO—Blocking IO—阻塞IO

2.1 什么是BIO?

BIO为阻塞型IO模型,服务器在接收客户端连接(accept)以及服务器读取客户端发送数据(recv)时会发生阻塞

2.2 BIO中的阻塞是什么意思?

当服务端执行了accept函数调用后,当前线程就会进入“等待”状态释放cpu的占用,直到有客户端连接服务器为止;服务端调用recv读取客户端发来的数据包时,当前线程就会进入“等待”状态释放cpu的占用,直到有客户端发来消息为止;

2.3 BIO底层是怎么实现的?原理是什么?

socket主要有三部分组成发送缓冲区+接收缓冲区+等待列表,当服务器socket调用accept函数,就回将当前线程的引用挂在等待列表中,如果socket接收到了客户端连接则唤醒等待列表中的线程。
在这里插入图片描述

2.4 BIO的优势以及存在的问题?

优点:

  1. 每个连接创建一个线程,实现了一个服务器连接多个客户端。

缺点:

  1. 当来了一个客户端创建连接后,如果不给客户端新分配一个线程执行服务器逻辑,那么服务端将很难再和第二个客户端建立连接。但是每处理一个请求就要重新new一个线程,而且我们的CPU是不会停止的,操作系统需要不断的调用我们的进程,进行进程的切换,用户态到内核态的切换,这个过程会大量的浪费我们CPU的资源以及一些内存的资源,是不可取的。
  2. 很多线程其实是在等数据(阻塞),是一个无用的线程

三.NIO—New IO—非阻塞IO

3.1 什么是NIO?

标记了非阻塞后的socket在调用acept和recv函数时无论有无连接或数据都会返回不会阻塞。

3.2 为什么需要NIO?BIO从NIO演进的过程?

  1. 如果说服务器只有很少的人用,那么BIO其实就可以满足我们的用户,但问题在于互联网蓬勃发展,随着服务器访问人数的增加,这样的服务器模型将会成为瓶颈。
  2. 我们以一种C10K的思想去看待BIO服务器代码。如果我们客户端的连接数增加了10K倍,那么就意味着要创建10K个线程,单单创建线程就是一项不小的开销了,再加上线程之间要来回切换,单机服务器根本就扛不住这么大的连接数。
  3. 那既然瓶颈是出在线程上,我们就考虑能不能把服务器的模型变为单线程模型,思路其实和之前说的差不多,用集合保存每个连接的客户端,通过while循环来对每个连接进行操作。
  4. BIO操作的瓶颈在于accept客户端的时候会阻塞,以及进行读写操作的时候会阻塞,导致单线程执行效率低。为了突破这个瓶颈,操作系统发展出了NIO,这里的NIO指的是非阻塞IO。
  5. 也就是说在accept客户端连接的时候,不需要阻塞,如果没有客户端连接就返回-1(java-NULL),在读写操作的时候,也不阻塞,有数据就读,没数据就直接返回,这样就解决了单线程服务器的瓶颈问题。

3.3 NIO的优势之处以及存在的问题?

优点:

  1. NIO是同步非阻塞的,客户端的accept和客户端读取都是非阻塞的。

缺点:

  1. 客户端与服务器建立连接后,后续会进行一系列的读写操作。虽然这些读写操作是非阻塞的,但是每调一次读写操作在操作系统层面都要进行一次用户态和内核态的切换,这个也是一项巨大的开销(读写等系统调用都是在内核态完成的)。
  2. 虽然可以单线程完成支持多连接的socket服务端,但是如果有1万个连接但是只有一个发送消息,还是会调用函数recv读取一万遍,其中有9999遍是无效调用,要知道应用程序是不能直接调用内核函数的,应用程序调用内核函数时会触发软件中断,效果类似线程上下文切换,会暂停当前应用程序让出CPU切换至内核函数执行,执行完后再切换回应用程序,这是会有进程的现场保护和现场恢复过程会占用CUP的时间和资源。
  3. 解决方案:如果可以吧socket集合交给内核去管理,让内核帮我们去遍历socket集合,返回给我们有数据可读的客户端,然后我们只进行有效读取。

四.多路复用选择器

4.1 多路复用器是什么?主要解决什么问题?作用是什么?

  1. 在客户端与服务器建立连接后,后续会进行一系列的读写操作。虽然这些读写操作是非阻塞的,但是每调一次读写操作在操作系统层面都要进行一次用户态和内核态的切换,这个也是一项巨大的开销(读写等系统调用都是在内核态完成的)。
  2. 我们以读操作为例:大部分读操作都是在数据没有准备好的情况下进行读的,相当于执行了一次空操作。我们要想办法避免这种无效的读取操作,避免内核态和用户态之间的频繁切换。
  3. 客户端与服务器两端都是通过socket进行连接的,socket在linux操作系统中有对应的文件描述符(fd),我们的读写操作都是以该文件描述符为单位进行操作的。为了避免上述的无效读写,我们得想办法得知当前的文件描述符是否可读可写。如果逐个文件描述符去询问,那么效率就和直接进行读写操作差不多了,我们希望有一种方法能够一次性得知哪些文件描述符可读,哪些文件描述符可写,这就操作系统后来发展出的多路复用器。也就是说,多路复用器的核心功能就是告诉我们哪些文件描述符可读,哪些文件描述符可写。
  4. 多路复用即让一个进程去监听多个socket。

4.2 多路复用器的发展历程?

  1. 最初的多路复用器是select模型,它的模式是这样的:程序端每次把文件描述符集合交给select的系统调用,select遍历每个文件描述符后返回那些可以操作的文件描述符,然后程序对可以操作的文件描述符进行读写。
    它的弊端是,一次传输的文件描述符集合有限,只能给出1024个文件描述符,poll在此基础上进行了改进,没有了文件描述符数量的限制。
  2. 但是select和poll在性能上还可以优化,它们共同的弊端在于:它们需要在内核中对所有传入的文件描述符进行遍历,这也是一项比较耗时的操作每次要把文件描述符从用户态的内存搬运到内核态的内存,遍历完成后再搬回去,这个来回复制也是一项耗时的操纵。
  3. 后来操作系统加入了epoll这个多路复用器,彻底解决了这个问题,epoll多路复用器的模型是这样的:
    为了在发起系统调用的时候不遍历所有的文件描述符,epoll的优化在于:当数据到达网卡的时候,会触发中断,正常情况下cpu会把相应的数据复制到内存中,和相关的文件描述符进行绑定。epoll在这个基础之上做了延伸,epoll首先是在内核中维护了一个红黑树,以及一些链表结构,当数据到达网卡拷贝到内存时会把相应的文件描述符从红黑树中拷贝到链表中,这样链表存储的就是已经有数据到达的文件描述符,这样当程序调用epoll_wait的时候就能直接把能读的文件描述符返回给应用程序。除了epoll_wait之外,epoll还有两个系统调用,分别是epoll_create和epoll_ctl,分别用于初始化epoll和把文件描述符添加到红黑树中。

五.多路复用选择器的实现方案select、poll、epoll

5.1 select

  1. select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

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

  3. select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

5.2 poll

  1. poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

  2. 但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

5.3 epoll

参考汇总一:

epoll 通过两个方面,很好解决了 select/poll 的问题。

5.3.1 epoll_create会创建一个eventpoll对象包括:

就绪列表:用于存放准备就绪的socket引用句柄
监听事件列表:用于存放epoll需要监听的socket引用句柄
等待队列:用于存放调用了epoll_wait的进程
在这里插入图片描述

5.3.2 epoll_ctl

将需要监听的socket添加到epoll_event的监听事件列表中

5.3.3 epoll_wait
  1. 当进程A调用epoll_wait时会首先检测eventpoll的就绪列表中有误数据,如果有数据直接返回;如果没有数据则将进程变为阻塞状态,从运行队列中取出来,添加到eventpoll的等待队列中,并且将event_poll添加到监听事件列表中所有socket的等待队列中(此处注意两点1.相对于selector的遍历rset看是否有socket就绪,epoll无需遍历之间检测就绪队列是否有数据即可,在高并发时可以提升性能;2.相对于selector进程添加到各个socket的等待队列,epoll将进程A添加到event_poll的等待队列,方便后面的释放)
  2. 当client1对应客户端发来消息时,消息到达了网卡时触发网卡硬件中断,中断程序首先将消息copy到client1对应socket的读缓存区里,然后通过等待队列中的event_poll引用找到event_poll,为event_poll的就绪队列里添加自己的引用,并且移除等待队列中的event_poll同时移除event_poll等待队列中的进程A
  3. 进程A回到运行队列分配到CPU,获取到就绪列表中的socket引用,遍历进行消息读取(遍历的全都是活跃连接,都是有效遍历)
    在这里插入图片描述
参考汇总二:
  1. epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

  2. epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
    在这里插入图片描述

  3. epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。

5.4 select vs poll vs epoll

一张图总结一下select,poll,epoll的区别:

selectpollepoll
操作方式遍历遍历回调
底层实现数组链表红黑树
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数1024(x86)或2048(x64)无上限无上限
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态,再拷贝回用户态每次调用poll,都需要把fd集合从用户态拷贝到内核态,再拷贝回用户态调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。

六.Java基于NIO实现的Selector多路复用选择器

6.1 什么是 NIO?

NIO 是 JDK1.4 引入的同步非阻塞 IO。服务器实现模式为多个连接请求对应一个线程,客户端连接请求会注册到一个多路复用器 Selector ,Selector 轮询到连接有 IO 请求时才启动一个线程处理。适用连接数目多且连接时间短的场景。

6.2 什么是同步非阻塞?

同步是指线程还是要不断接收客户端连接并处理数据,非阻塞是指如果一个管道没有数据,不需要等待,可以轮询下一个管道。

6.3 核心组件有哪些?

6.3.1 Selector
  1. 多路复用器,轮询检查多个 Channel 的状态,判断注册事件是否发生,即判断 Channel 是否处于可读或可写状态。使用前需要将 Channel 注册到 Selector,注册后会得到一个 SelectionKey,通过 SelectionKey 获取 Channel 和 Selector 相关信息。

  2. Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。使用Selector来实现 一个单独的线程可以管理多个channel,从而管理多个网络连接。

  3. 一个 Selector 允许一个线程处理多个 Channel (的事件),这对于应用程序中打开了很多个连接(Channel),但连接中数据传输并不频繁时会很有帮助。例如一个聊天服务器。这有一个线程使用一个 Selector 同时处理3个 Channel 的示意图:
    在这里插入图片描述

  4. 使用 Selector 时需要将 Channel 注册到 Selector 上,然后调用 Selector 的select()方法。这个方法会一直阻塞直到注册的某个 Channel 的某个 event 就绪,一旦这个方法 return,线程就会处理这些事件。这里说的事件有新连接的到来,数据的接收等。

6.3.2 Channel
  1. 双向通道,替换了 BIO 中的 Stream 流,不能直接访问数据,要通过 Buffer 来读写数据,也可以和其他 Channel 交互。

  2. Channel 和 Buffer 有很多种类型,下面是 Java NIO 中 Channel 的主要实现类:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel
6.3.3 Buffer
  1. 缓冲区,本质是一块可读写数据的内存,用来简化数据读写。Buffer 三个重要属性:position 下次读写数据的位置,limit 本次读写的极限位置,capacity 最大容量。
  • flip 将写转为读,底层实现原理把 position 置 0,并把 limit 设为当前的 position 值。
  • clear 将读转为写模式(用于读完全部数据的情况,把 position 置 0,limit 设为 capacity)。
  • compact 将读转为写模式(用于存在未读数据的情况,让 position 指向未读数据的下一个)。
  • 通道方向和 Buffer 方向相反,读数据相当于向 Buffer 写,写数据相当于从 Buffer 读。

使用步骤:向 Buffer 写数据,调用 flip 方法转为读模式,从 Buffer 中读数据,调用 clear 或 compact 方法清空缓冲区。

  1. 下面是 Java NIO 中 Buffer 的主要实现类:
  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer
  1. 通常,NIO 中的所有 IO 都从 Channel 开始,Channel 有点类似于 Stream。数据可以从 Channel 读到一个 Buffer 中,也可以从一个 Buffer 写入到一个 Channel 中,如下图所示:
    在这里插入图片描述

参考博文1

参考博文2

好了,到这里【性能基石之IO~~~BIO、NIO、多路复用选择器、多路复用选择器的实现方案select、poll、epoll、Java基于NIO实现的Selector多路复用选择器】就学习到这里,更多内容不断学习,不断创作中。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

硕风和炜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值