文章摘要(AI生成)
NIO通过使用Selector和多路复用器的概念,有效地解决了BIO中线程开销过大的问题。在BIO中,每个客户端连接对应一个线程,导致服务端需要开启大量线程处理连接请求,造成巨大的资源消耗。而NIO中,引入了Selector来负责检查所有连接的就绪状态,通过轮询注册在其上的通道,获取就绪状态的通道进行处理。这样一来,一个Selector可以轮询多个通道,大大减少了线程的数量,提高了资源利用率。同时,NIO中使用了缓冲区来存储请求数据,可以在任务完成后发送通知,避免了连接超时的问题。总之,NIO通过Selector和缓冲区的方式,有效地解决了BIO中的线程开销问题,提高了系统的并发性能。
NIO概念
结合上篇文章BIO中所述的,BIO中“一个客户端(连接)对应一个线程,只有一个服务线程为每个连接分配处理线程”:
这种客户端与服务端1:1的处理方式,会导致服务端产生很大的线程开销,那么NIO是如何解决这个问题的呢?
包裹分拣
上面这个问题很像一个流水线问题,即上游有6条流水线在源源不断的给你产生包裹,按照阻塞式IO的思路,有多少个请求,你就应该开启多少个线程处理。现在有6条流水线,那你就应该安排6个工人来处理。
但是你的资金有限,雇不起6个工人来处理包裹,只能雇佣到2个人来做分拣工作:
这时候该怎么办呢?
一种思路时让两个人分分工,一个人处理3条流水线,这种处理方式就是伪异步IO的处理方式:
这种方式一看就有弊端:工人分拣缓慢时会导致其他流水线阻塞(上图3、6流水线就阻塞了),这样就导致流水线无法正常工作。那么对于请求处理时,我们通过线程池的方式来处理请求,读操作缓慢时同样会导致多个输入流阻塞,阻塞队列积满后后入列操作无法进行,最终导致大量连接超时。
NIO给我们的思路是,我们安排一个人来负责定时检查所有流水线上是否有包裹,有则取出来交给另一个人来专门进行分拣工作:
这样我们的流水线不会因为我们工人分拣能力而阻塞,如果我们缓冲区(放包裹的地方)够大,那么完全可以处理完这些请求,而在最终任务完成后发送通知。
概念小结
从包裹分拣的问题中我们可以看到,相比于BIO不计成本的创建线程,NIO在限定的资源内就能处理大量的请求。其中负责检查流水线包裹的是我们的Selector
(即多路复用器),它提供了选择已经就绪的任务的能力,通过不断轮询注册在其上的channel,获取就绪状态的channel。一个多路复用器可以轮询多个channel,没有最大连接句柄的限制。而我们分拣的工人则是对应各个通道。
NIO原理
了解了NIO是什么之后,我们来看下NIO在各个阶段都做了什么,与BIO相同,把大象装冰箱的步骤是一致的,但是由于增加了selector
的概念,具体的流程也就不同。
服务端创建
服务端流程创建示意如上:其主要是创建服务端通道,并将通道注册到selector上。
服务端创建即服务端通道创建,首先是创建selector,最终返回的结果是WindowsSelectorImpl
,及windows下的selector的多线程实现版本,它包含了:
1、一个selectionKey
集合
2、一段系统内存-系统缓冲区
3、一个唤醒管道,用来在select阻塞时进行唤醒
4、一个选择线程集合
唤醒管道的建立是在服务端建立一个本地回环连接(服务端的端口连接服务端所在机器的另一个端口),这样调用唤醒方法时,能够中断阻塞的select方法。
而selectionKey
则是由一个通道+通道对应的selector组成,除此之外selectionKey
还包括其在selector中的下标。下面流程中所有通道注册包括以下几步:
-
selector的通道列表中添加此通道
-
设置此通道在selector中的下标
-
为此通道分配缓冲区空间
-
通道状态设置为接收就绪
另外,在通道注册到selector时,如果selector的总通道数已满,则会对通道数进行扩容:
- selector的总通道数变为原来的2倍
- 轮询线程总数+1
建立连接
在服务端完成建立后,我们建立客户端时,会连接创建好的服务端,这时服务端首先接收到连接请求写入缓冲区。
当selector轮询缓冲区发现服务端通道发生数据变更时,则调用服务端通道进行接受连接。并在服务端为响应的客户端建立客户端通道,用于处理之后此客户端的请求。
这里每个通道都有一个文件描述符,即缓冲区指针。selector会调用系统的poll方法进行轮询,如果对应缓冲区发生变更,则更新对应通道的标记位。并将其放入到变更的通道列表中,这样我们的就能通过遍历变更的通道列表,对其调用accept方法进行请求受理。
请求发送和响应
连接建立完成后,服务端和客户端都以为这个连接建立好了对应的通道。我们在客户端发起请求后,服务端对应的客户端通道就会产生响应的数据变更。通过轮询我们就能发现对应通道,我们便能在服务端对所有变更的管道做统一的分发处理。
轮询&唤醒
轮询的操作是较为复杂的。其中还包括了唤醒操作。为什么需要唤醒呢?我们首先来看下轮询是怎么实现的:
从上图可知,轮询主要做了如下几步:
- 由于一次轮询得到的是当前时间点所有变更的管道,所以整个选择流程是阻塞的
- 如果有取消的管道,则需要对管道进行注销。
- 如果上次轮询通过唤醒中断了,则触发空轮询讲中断复位
- 分配轮询线程,进行并发轮询
- 获取各线程轮询结果,更新通道状态和变更通道列表
轮询的逻辑,主要是对缓冲区的检测,我们的通道主要是一些读写指针,如果某个通道的读写指针的位置和原纪录的指针位置不同,则说明该通道发生了读写操作,需要放到selectKeys中
不同缓冲区的轮询线程不同,所有通道的变化是并发监听的,但是当所有线程的轮询全部完成后,才会更新通道状态。
如果轮询线程发现当前轮询次数不变,则进行等待,等待其他线程
通道注销
注销包括以下几步:
- selector的通道列表中删除此通道
- 设置此通道的selector下标置为-1
- 删除此通道缓冲区空间
- 通道状态设置为关闭
- 关闭此通道
轮询线程分配
如果轮询线程总数大于轮询线程数,则增加轮询线程;
如果轮询线程总数小于轮询线程数,则减少轮询线程
在通道注册到selector时,如果selector的总通道数已满,则会对通道数进行扩容:
- selector的总通道数变为原来的2倍
- 轮询线程总数+1
唤醒
唤醒流程如下,服务端调用唤醒方法时,通过系统发起唤醒请求写入到缓冲区中,缓冲区变更后则被系统poll方法轮循到,此时,轮询中止。返回空的变更通道列表。
评论区