问题导读:
1.Selector有哪些功能?
2.Selector怎样保存内部状态信息?
3.Selector为什么比直接使用NIO好?
org.apache.kafka.common.client.Selector实现了Selectable接口,用于提供符合Kafka网络通讯特点的异步的、非阻塞的、面向多个连接的网络I/O.
这些网络IO包括了连接的创建、断开,请求的发送和接收,以及一些网络相关的metrics统计等功能。
所以,它实际上应该至少具体以下功能
使用
首先得谈一下Selector这东西是准备怎么让人用的。这个注释里说了一部分:
首先,Selector的API都是非阻塞或者带有阻塞超时时间的,这个特点直接源于Java NIO的Selector和SocketChannel的特性。这种异步非阻塞的IO带来的问题就是,必须时不时地调用某个方法,来检测IO完成的进度情况,对于NIO的selector,这个方法就是select,对于Kafka的Selector,这个方法就是poll.
为此,注释里举了一个典型的例子,这是一个发送数据的例子:
[Java]
纯文本查看
复制代码
但是Kafka Selector的poll不仅检测IO的进度,它还执行IO操作,比如当发现有channel可读了,它就从中读数据出来。那么,是否可以说Kafka的Selector执行的是异步IO呢?下面来谈下这个问题。
异步IO vs 同步非阻塞IO
异步IO是说实际的IO动作是由操作系统调用另外的线程或者其它的计算资源来做的。那么,想要确定Selector执行的是否是异步IO,得先看下它所构建的Channel是哪一种,毕竟不是所有的channel都支持异步IO。
Selector创建channel的动作是在#connect(String, InetSocketAddress, int, int)方法中。
[Java]
纯文本查看
复制代码
它是建了一个SocketChannel.而SocketChannel并不能进行异步IO,当它被设为no-blocking模式时,进行的是非阻塞的IO。在Java7中,引入了
AsynchronizedSocketChannel,它进行的才是真正的异步IO。
参见
内部状态
由于Selector的各个方法是非阻塞的,因此需要保存每个操作当前的完成进度。比如,正在写,写完成,读完成,连接建立成功,等。这样在调用者调用了poll方法以后,调用者可以检查各个操作完成的情况。
Selector内部的确有一些集合来保存这些信息:
[Java]
纯文本查看
复制代码
但是这里的集合有些并不是按照channel来组织的。比如:completedSend, completedReceives, disconnected, connected和failedSends。因为这些集合是在一个poll之后,Selector的使用者应该处理的,它们是按照类型组织。在poll执行的最开始,它会调用clear方法,清空这些集合,因为它们是上次poll的结果。所以,在一次poll之后查看这些结果的话,看到的就是这次poll的结果。
[Java]
纯文本查看
复制代码
这里之所以把failedSends加到disconnected之中,是因为failedSends里保存的失败的send,并不是上次poll留下来的,而是上次poll之后,此次poll之前,调用send方法时添加到failedSends集合中的。当有failedSends时,selector就会关闭这个channel,因此在clear过程中,需要把failedSends里保存的节点加到disconnected之中。
需要注意的是,这些集合里并没有包括正在发送以及正在接收的请求。原因是KafkaChannel对象本身持有正在处理的请求和响应。
[Java]
纯文本查看
复制代码
这里需要注意是是它的setSend和read方法
[Java]
纯文本查看
复制代码
当一个send正在发送的过程中,send != null, 此时调用setSend会抛出IllegalStateException。那么,Selector在可以在一个poll之前可以往一个channel发送多个请求吗?
canSendMore
这个需要需要追溯到哪些方法会调用KafkaChannel#setSend。结果只有NetworkClient的send(ClientRequest, long)方法会最终调到它。
而NetworkClient的send方法是这样的
[Java]
纯文本查看
复制代码
这里connectionStates.isConnected用来检测节点是否已经连接上。selector.isChannelReady()用来检测channel是否准备完成。由于Kafka security的一些要求,当socket channel连接建立完成后,可能还需要跟server交换一些认证数据,才能认为channel准备完毕。那么,重点就在于inFlightRequest.canSendMore这个方法了。因为如果它不检测一个channel是否有正在发送的send,就可能会在调用NetworkClient#send时,再试图给这个channel添加一个send,就会引发异常。
InFlightRequest保存了所有已发送,但还没收到响应的请求。
InFlightRequests的canSendMore是这样的:
[Java]
纯文本查看
复制代码
重点在于queue.peekFirst().request().completed, 即如果发给这个节点的最早的请求还没有发送完成,是不能再往这个节点发送请求的。
但是,从canSendMore方法中也可以看出,只要没有超过maxInFlightRequestsPerConnection,一个node可以有多个in-flight request的。这点,实际上影响到了另一个集合的数据结构的选择——stagedReceives
stagedReceives
[Java]
纯文本查看
复制代码
stagedRecieves用来保存已经接收完成,但是还没有暴露给用户(即没有放在completedReceive列表中)的NetworkReceive(即响应).
这里有两个问题:
第二个问题的答案就是NetworkClient的canSendMore方法并没有限制一个node只有在所有已发送请求都收到响应的情况下才能发送新请求。因此,一个node可以有多个in-flight request,也可以有多个已发送的请求。因此,Selector也就可能会收到来自于同一个node的多个响应。因此,selector在每次poll的时候,读取请求的操作是这样的:
[Java]
纯文本查看
复制代码
也就是说,只要有可以完整读出的响应,都会把这些响应放到stagedReceives列表中。这个while循环使得在一次poll中,可能会添加多个NetworkReceive到stagedReceives里。
但是,每次poll,只会把最早的一个NetworkReceive放在completedReceives里。
[Java]
纯文本查看
复制代码
这个行为比较奇怪。可能的解释是这会简化NetworkClient的实现,造成一种"对每个channel,poll一次只发送一个请求,只接收一个响应“的假像,使得NetworkClient的用户更容易处理请求和响应之间的对应关系。既然poll是一个非阻塞操作,用户就可以在未收到某个请求的响应时,多次调用poll,这个也没什么问题。因为poll一次并不保证就能收到刚才发出的请求对应的响应。
至于第一个问题,则是由于性能的考虑。
addToStagedReceives方法用于把一个NetworkReceive加到某个channel的stagedReceivs队列中。
[Java]
纯文本查看
复制代码
如果这个channel没有stagedReceives队列,会给它建一个,此时new的是ArrayDeque对象。这个ArrayDeque是JDK中性能最高的FIFO队列的实现,优于ArrayList和linkedList.
immediatelyConnectedKeys
[Java]
纯文本查看
复制代码
虽然在connect方法中,SocketChannel被设为non-blocking, 然后调用socketChannel.connect(address),虽然是非阻塞模式,但是connect方法仍然有可能会直接返回ture,代表连接成功。connect方法的doc是这么说的:
比如,如果是一个本地的连接,就可能在非阻模式下也会立即返回连接成功。也是挺神奇的,想一想,如果认为”执行指令“是一种阻塞的话,绝对意义上的非阻塞方法是不存在的,不存在执行时间为零的方法。也就是说,如果进行一个本地连接,OS加上JVM是可以在有限的指令数量和时间段内确定连接成功,这也可以被认为是在非阻塞状态下进行的。
lruConnection
在前边的connect方法中,socket被配置了keepAlive,可以检测出来连接断开的情况。但是,还有一种情况需要考虑,就是一个连接太久没有用来执行读写操作,为了降低服务器端的压力,需要释放这些的连接。所以Selector有LRU机制,来淘汰这样的连接。
在Java里,实现LRU机制最简单的就是使用LinkedHashMap, Selector也的确是这么做的。
[Java]
纯文本查看
复制代码
lruConnection的key是node的id, value是上次访问的时间。它的“顺序”被设为access顺序。Selector会用map的put操作来access这个map,当NIO的selector poll出来一批SelectionKey之后,这些key对应的node被重新put进map,以刷新它们的最近访问顺序,同时也把具体的“最近使用时间”作为entry的value放在这个map中。
这发生在会被每次poll调用的pollSelectionKeys方法中
[Java]
纯文本查看
复制代码
之所以要在value中保存最近使用时间,是因为这个时间会被用于计算空闲时间,当空闲时间超过了connectionMaxIdleMs时,就会关闭这个连接。
在poll的最后,会执行maybeCloseOldestConnection方法,来检测并关闭需要关闭的连接。
[Java]
纯文本查看
复制代码
这里有几点要注意:
1. 并不是每次poll都需要执行实际的检测。假如在某一时刻,我们得知了此时的least recently used node的access时间,那么以后最先过期的肯定是这个node,因此下一次检测的时间应至少是这个 access time of LRU node + maxIdleTime. 所以在代码中,使用这段代码来重置nextIdelCloseCheckTime
[Java]
纯文本查看
复制代码
2. maybeCloseOldestConnection每调用一次,最多只关闭一个连接。但是,在关闭连接时,它并没有根据移除node后的新的LRU node来重置 nextIdelCloseCheckTime。所以下一次调用maybeCloseOldestConnection时,if的判断条件肯定为true,因此会继续检测并关闭连接。
这种做法有些不妥,因为这样做的话一个poll并不能关闭所有应该关闭的空闲连接,不能指望用户接下来会主动地多poll几次。
总结
Kafka使用这个抽象出来的Selector的确比直接使用NIO在编程上要好一些,主要是代码会不那么臃肿,因为Selector配合KafkaChannel、Send, NetworkReceive, 处理了NIO网络编程的一些细节。Selector的这些代码写的也的确不错。 不过,poll这个操作被搞得有些教条,被赋予了太多的责任,看起来是为了迎合Kafka的新consumer的特点搞出来的东西。这个东西让人想起了回合制的游戏,设置好下一回合想干啥,点确定,然后就喝茶等了。
|
Kafka中的Selector网络接口详解
最新推荐文章于 2024-04-26 19:31:05 发布