Netty 源码分析 —— NIO 基础(四)之 Selector(NIO 最最最重要的核心)


我准备战斗到最后,不是因为我勇敢,是我想见证一切。 --双雪涛《猎人》


Thinking

  1. 一个技术,为什么要用它,解决了那些问题?
  2. 如果不用会怎么样,有没有其它的解决方法?
  3. 对比其它的解决方案,为什么最终选择了这种,都有何利弊?
  4. 你觉得项目中还有那些地方可以用到,如果用了会带来那些问题?
  5. 这些问题你又如何去解决的呢?

本文基于Netty 4.1.45.Final-SNAPSHOT

1、概述

前言

​ 在JDK1.4中引入的new i/o其中最最重要关键就是Selector选择器。它在Java NIO 中用于轮询能够检测一到多个NIO通道(NIO Channel),并能够知晓通道是否伪诸如读写事件做好准备的组件(可读可写的状态)。这样,一个单独的线程就可以实现管理多个Channel,从而管理多个网络连接。也因此,Selector也被称为多路复用器

与传统I/O对比,Selector优势在哪里。解决了那些痛点。(其实很多技术大多数都是在追求优化性能的情况下,尽可能地提高程序运行速度。毕竟现在是个连吃快餐都觉得麻烦的时代)

  1. 使用单独的线程完成对多通道读写的监控,避免了BIO中1连接1请求的阻塞。
  2. 使用单独线程大大的减少了,线程上下文切换的资源消耗。
  3. 使用单独线程大大减轻了传统IO中大量线程的资源消耗。(较于现在CPU多核越来越优秀的情况下,多线程的开销会越来越小。所以这种情况都是视情况而定的。)

Selector是如何实现轮询的呢?

  • 首先,需要将Channel注册到Selector中,这样Selector才能知道到底有那些Channel属于它管理的。
  • 然后,Selector会不断的轮询注册在其上的Channel。如果某个Channel上面发生了读或者写事件,这个Channel就处于就绪状态。会被Selector轮询出来,然后通过SelectorKey获取到所有就绪的Channel集合,进行后面的I/O操作。
  • 如下图所示
    Selector轮询

2、优缺点对比

优点

​ 使用一个线程能够处理多个Channel,只需要更少的线程来处理Channel。事实上,可以使用一个线程处理所有的Channel。对于操作系统来说,线程之间的上下文切换的开销是非常大的,而且每个线程都是需要占用系统资源的(如:CPU、内存)。

​ 简单来说,就是使用更少的线程,优化性能,并且避免了不必要的性能开销。

缺点

​ 因为在一个线程中使用多个Channel,因此会造成每个Channel处理效率降低。

(在Netty的设计中,通过Reactor模型对此种情况进行了优化,在我们第一张探讨I/O模型时,提到过,使用多线程管理Channel,将主Reactor分为多个,用于分别处理请求,和接受请求。一般情况下伪CPU*2)

3、Selector

java.nio.channels.Selector抽象类。
类图

这里多提一嘴,因为Selector 的实现是根据操作系统来具体实现的,所以这里是WindowsSelectorImpl,如果在Lunix/mac下看到的不一样,也不用惊慌。

对Selector源码感兴趣推荐《深入浅出NIO之Selector实现原理》

4、创建Selector

​ 通过#open()方法,我们可以创建一个Selector对象。

Selector selector = Selector.open();

5、注册Channel到Selector中

​ 每一个需要Selector管理的Channel都需要注册到其上,

// 创建文件Channel
// FileChannel channel = new RandomAccessFile("E:/idea_workspace/springcloud2.0/netty/netty-mytest/src/main/resources/data/Selector-data.txt", "rw").getChannel();
        // 文件 是不可以注册到Selector 中的,因为FileChannel 是阻塞的,如果想要注册到Selector 中就必须非阻塞的。
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(9999));
        // 开启 异步,只有开启了 异步,让Channel 为非阻塞状态才能注册到Selector中。
        socketChannel.configureBlocking(false);//《1》
        //  让Selector 管理Channel
        socketChannel.register(selector, SelectionKey.OP_ACCEPT);// 《2》
  • 注意,如果一个 Channel 要注册到 Selector 中,那么该 Channel 必须是非阻塞,所以 <1> 处的 channel.configureBlocking(false); 代码块。也因此,FileChannel 是不能够注册到 Channel 中的,因为它是阻塞的。

    • 为什么FileChannel 不能设置为非阻塞的。(见文末详解)
  • #register(Selector selector, int interestSet) 方法的第二个参数,表示一个“interest 集合”,意思是通过 Selector 监听 Channel 时,对哪些( 可以是多个 )事件感兴趣。可以监听四种不同类型的事件:

    • Connect :连接完成事件( TCP 连接 ),仅适用于客户端,对应 SelectionKey.OP_CONNECT
    • Accept :接受新连接事件,仅适用于服务端,对应 SelectionKey.OP_ACCEPT
    • Read :读事件,适用于两端,对应 SelectionKey.OP_READ ,表示 Buffer 可读。
    • Write :写时间,适用于两端,对应 SelectionKey.OP_WRITE ,表示 Buffer 可写。

    Channel 触发了一个事件,意思是该事件已经就绪:

  • 一个 Client Channel Channel 成功连接到另一个服务器,称为“连接就绪”。

  • 一个 Server Socket Channel 准备好接收新进入的连接,称为“接收就绪”。

  • 一个有数据可读的 Channel ,可以说是“读就绪”。

  • 一个等待写数据的 Channel ,可以说是“写就绪”。


因为 Selector 可以对 Channel 的多个事件感兴趣,所以在我们想要注册 Channel 的多个事件到 Selector 中时,可以使用或运算 | 来组合多个事件。示例代码如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

实际使用时,我们会有改变 Selector 对 Channel 感兴趣的事件集合,可以通过再次调用 #register(Selector selector, int interestSet) 方法来进行变更。示例代码如下:

channel.register(selector, SelectionKey.OP_READ);
channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
  • 初始时,Selector 仅对 Channel 的 SelectionKey.OP_READ 事件感兴趣。
  • 修改后,Selector 仅对 Channel 的 SelectionKey.OP_READSelectionKey.OP_WRITE) 事件感兴趣。

6、 SelectionKey 类

上一小节, 当我们调用 Channel 的 #register(...) 方法,向 Selector 注册一个 Channel 后,会返回一个 SelectionKey 对象。那么 SelectionKey 是什么呢?SelectionKey 在 java.nio.channels 包下,被定义成一个抽象类,表示一个 Channel 和一个 Selector 的注册关系,包含如下内容:

  • interest set :感兴趣的事件集合。
  • ready set :就绪的事件集合。
  • Channel
  • Selector
  • attachment :可选的附加对象。

6.1、 interest set

通过调用 #interestOps() 方法,返回感兴趣的事件集合。示例代码如下:

int interestSet = selectionKey.interestOps();

// 判断对哪些事件感兴趣
boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT != 0;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT != 0;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ != 0;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE != 0;
  • 其中每个事件 Key 在 SelectionKey 中枚举,通过位( bit ) 表示。代码如下:

    //  SelectionKey.java
    
    public static final int OP_READ = 1 << 0;
    public static final int OP_WRITE = 1 << 2;
    public static final int OP_CONNECT = 1 << 3;
    public static final int OP_ACCEPT = 1 << 4;
    
    • 所以,在上述示例的后半段的代码,可以通过与运算 & 来判断是否对指定事件感兴趣。

6.2、 ready set

通过调用 #readyOps() 方法,返回就绪的事件集合。示例代码如下:

int readySet = selectionKey.readyOps();

// 判断哪些事件已就绪
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
  • 相比 interest set 来说,ready set 已经内置了判断事件的方法。代码如下:

    // SelectionKey.java
    public final boolean isReadable() {
        return (readyOps() & OP_READ) != 0;
    }
    public final boolean isWritable() {
        return (readyOps() & OP_WRITE) != 0;
    }
    public final boolean isConnectable() {
        return (readyOps() & OP_CONNECT) != 0;
    }
    public final boolean isAcceptable() {
        return (readyOps() & OP_ACCEPT) != 0;
    }
    

6.3、 attachment

通过调用 #attach(Object ob) 方法,可以向 SelectionKey 添加附加对象;通过调用 #attachment() 方法,可以获得 SelectionKey 获得附加对象。示例代码如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

又获得在注册时,直接添加附加对象。示例代码如下:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

7、 通过 Selector 选择 Channel

在 Selector 中,提供三种类型的选择( select )方法,返回当前有感兴趣事件准备就绪的 Channel 数量

// Selector.java

// 阻塞到至少有一个 Channel 在你注册的事件上就绪了。
public abstract int select() throws IOException;

// 在 `#select()` 方法的基础上,增加超时机制。
public abstract int select(long timeout) throws IOException;

// 和 `#select()` 方法不同,立即返回数量,而不阻塞。
public abstract int selectNow() throws IOException;
  • 有一点非常需要注意:select 方法返回的 int 值,表示有多少 Channel 已经就绪。亦即,自上次调用 select 方法后有多少 Channel 变成就绪状态。如果调用 select 方法,因为有一个 Channel 变成就绪状态则返回了 1 ;若再次调用 select 方法,如果另一个 Channel 就绪了,它会再次返回1。如果对第一个就绪的 Channel 没有做任何操作,现在就有两个就绪的 Channel ,但在每次 select 方法调用之间,只有一个 Channel 就绪了,所以才返回 1

8、 获取可操作的 Channel

一旦调用了 select 方法,并且返回值表明有一个或更多个 Channel 就绪了,然后可以通过调用Selector 的 #selectedKeys() 方法,访问“已选择键集( selected key set )”中的就绪 Channel 。示例代码所示:

Set selectedKeys = selector.selectedKeys();
  • 注意,当有新增就绪的 Channel ,需要先调用 select 方法,才会添加到“已选择键集( selected key set )”中。否则,我们直接调用 #selectedKeys() 方法,是无法获得它们对应的 SelectionKey 们。

9、唤醒 Selector 选择

某个线程调用 #select() 方法后,发生阻塞了,即使没有通道已经就绪,也有办法让其从 #select() 方法返回。

  • 只要让其它线程在第一个线程调用 select() 方法的那个 Selector 对象上,调用该 Selector 的 #wakeup() 方法,进行唤醒该 Selector 即可。
  • 那么,阻塞在 #select()方法上的线程,会立马返回。

Selector 的 #select(long timeout) 方法,若未超时的情况下,也可以满足上述方式。

注意,如果有其它线程调用了 #wakeup() 方法,但当前没有线程阻塞在 #select() 方法上,下个调用 #select() 方法的线程会立即被唤醒。

10、关闭 Selector

当我们不再使用 Selector 时,可以调用 Selector 的 #close() 方法,将它进行关闭。

  • Selector 相关的所有 SelectionKey 都会失效
  • Selector 相关的所有 Channel 并不会关闭

注意,此时若有线程阻塞在 #select() 方法上,也会被唤醒返回。

11、 简单 Selector 示例

如下是一个简单的 Selector 示例,创建一个 Selector ,并将一个 Channel注册到这个 Selector上( Channel 的初始化过程略去 ),然后持续轮询这个 Selector 的四种事件( 接受,连接,读,写 )是否就绪。代码如下:

相较于下面的代码,通常是不可能运用到开发中的,而Netty提供了更加优雅的解决方案。

// 创建 Selector
Selector selector = Selector.open();
// 注册 Channel 到 Selector 中
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while (true) {
      // 通过 Selector 选择 Channel 
	int readyChannels = selector.select();
	if (readyChannels == 0) {
	   continue;
	}
	// 获得可操作的 Channel
	Set selectedKeys = selector.selectedKeys();
	// 遍历 SelectionKey 数组
	Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
	while (keyIterator.hasNext()) {
		SelectionKey key = keyIterator.next();
		if (key.isAcceptable()) {
			// a connection was accepted by a ServerSocketChannel.
		} else if (key.isConnectable()) {
			// a connection was established with a remote server.
		} else if (key.isReadable()) {
			// a channel is ready for reading
		} else if (key.isWritable()) {
			// a channel is ready for writing
		}
		// 移除
		keyIterator.remove(); // <1>
	}
}
  • 注意

    , 在每次迭代时, 我们都调用(保证事件事件处理完之后,清除掉。不然会 死循环的。)

    keyIterator.remove()
    

    代码块,将这个 key 从迭代器中删除。

    • 因为 #select() 方法仅仅是简单地将就绪的 Channel 对应的 SelectionKey 放到 selected keys 集合中。
    • 因此,如果我们从 selected keys 集合中,获取到一个 key ,但是没有将它删除,那么下一次 #select 时, 这个 SelectionKey 还在 selectedKeys 中.

12、问题集合

12.1、在Channel设置为非阻塞时,为什么FileChannel不能设置为非阻塞的。

  • 源码角度

首先我们先看一下FileChannel的源码。

继承子Channel的两个不同分类的Channel

java.nio.channels.FileChannel

public abstract class FileChannel
    extends AbstractInterruptibleChannel
    implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
{
    /**
     * Initializes a new instance of this class.
     */
    protected FileChannel() { }

介绍:A channel for reading, writing, mapping, and manipulating a file.

用于读取,写入,映射和操作文件的通道。

并且没有#configureBlocking(boolean block)方法

java.nio.channels.SelectableChannel

public abstract class SelectableChannel
    extends AbstractInterruptibleChannel
    implements Channel
{

    /**
     * Initializes a new instance of this class.
     */
    protected SelectableChannel() { }

介绍:A channel that can be multiplexed via a {@link Selector}.

可以通过{@link Selector}进行多路复用的频道。

并且提供了#configureBlocking(boolean block)`方法

所以从源码的角度可以看出,所有继承自SelectableChannel抽象类的所有实现都是可以注册到Selector上的。(可以设置为非阻塞Channel),相反则不能。

  • 从设计角度

    在UNIX是不支持文件的非阻塞I/O的,因为将常规文件的描述符设置为非阻塞的是没有任何效果的。(个人理解:因为在常规I/O操作时,读写一定时阻塞的,如果设置为非阻塞,可读可写作为文件的标识符,但是当正在发生读写操作时,还是会阻塞,倘若I/O操作前有一个非常大且缓慢的读写操作,那么该等待的事件还是要等待。因为一个线程不可能同时读写两个文件)详情:

    Every now and then, I hear some programmer complain that a given piece of code uses blocking I/O. The claim is typically that blocking I/O damages the responsiveness of applications, especially if it has a user interface. Hence, solely non-blocking I/O should be used, along with polling (poll() or select()) or an event handling framework (glib, Qt, etc).

    I can sympathize with the goal of improving applications responsiveness.****But**** that is not an excuse for mixing up *blocking* with ****sleeping****. Blocking is just one of several ways to sleep. In other words, non-blocking operations can sleep. Indeed turning non-blocking mode on for a file descriptor will not prevent sleeping in all cases that it could occur, but only one (or two) of them (depending how you count).

    Blocking mode refers to one particular and well defined form of sleep: waiting until a file descriptor can be written to or read from. What that really means depends on the type of the underlying file.

    • For sockets, readability means there is some unread data in the input buffers. This is well-known and this is probably the most common use case for non-blocking I/O. Conversely, writeability implies the output buffers are not full as defined by the underlying protocol of the socket. This usually corresponds to congestion control, though the exact mechanisms and policies may vary.
    • For pipes, readability means some unread data remains in the pipe buffer, or one task is blocking in a write to the other end of the pipe. Reciprocally, writeability means the pipe buffer has available room, or one task is blocking in a read operation on the pipe.
    • FIFOs are really exactly like pipes, except that they have a name in the file system hierarchy.
    • Terminals and pseudo-terminals also work much like pipes, with regard to I/O, except for the fact that they support duplex operations like sockets.
    • For devices (other than terminals), polling is implementation-defined. You need to check the device driver documentation.
    • For directories, polling is not defined. In any case, writing to directories is not allowed, and reading is only defined through synchronous APIs.
    • Regular files are *always* readable and they are also *always* writeable. This is clearly stated in the relevant POSIX specifications. *I cannot stress this enough. Putting a regular file in non-blocking has ABSOLUTELY no effects* other than changing one bit in the file flags.

    Reading from a regular file might take a long time. For instance, if it is located on a busy disk, the I/O scheduler might take so much time that the user will notice the application is frozen.

    Nevertheless, non-blocking mode will not work. It simply will not work. Checking a file for readability or writeability always succeeds immediately. If the system needs time to perform the I/O operation, it will put the task in non-interruptible sleep from the read or write system call.

    In other words, if you do know that a file descriptor refers to a regular file, do not waste your time, or worse, other people’s time implementing non-blocking I/O.
    The only safe way to read data from or write data to a regular file while not blocking a task… is to not do it - in that particular task. Concretely, you need to create a separate thread (or process), whether you like it or not, even if you think threads suck (which usually really means you are an incompetent programmer who cannot use threads properly).

    An alternative, of course, involves reading small chunks of data at once, and handling other events in-between. Then again, even reading a single byte can take a long time, if said byte was not read ahead by the operating system.

    原文链接:http://www.remlab.net/op/nonblock.shtml

    基于上述描述,就像RPC框架一样,Java也得需要经可能的保证大范围的适配运行环境。所以FileChannel并没有实现或者继承自SelectableChannel

​ 但是JDK 7 之后就支持了异步文件I/O新java.nio.channels.AsynchronousFileChannel类,这是不阻塞I / O的不同机制.其中一个实现sun.nio.ch.WindowsAsynchronousFileChannelImpl受益于Windows上的非阻塞I / O API(参见Asynchronous I/O in Windows.

​ 在此期间,您可以使用多个线程来实现相同的效果.但是这已经在jdk.management.resource.internal.inst.SimpleAsynchronousFileChannelImplRMHooks中实现,它可以在所有操作系统中移植(//TODO 有兴趣可以先研究一下。)

本文仅供笔者本人学习,有错误的地方还望指出,一起进步!望海涵!

转载请注明出处!

为什么Java中的FileChannel不是非阻塞的?

欢迎关注我的公共号,无广告,不打扰。不定时更新Java后端知识,我们一起超神。
qrcode.jpg

——努力努力再努力xLg

加油!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值