本文转载自Java NIO wakeup实现原理

导语

最近在阅读netty源码时,很好奇Java NIO中Selectorwakeup()方法是如何唤醒selector的,于是决定深扒一下wakeup机制的实现原理,相信对学习NIO是大有裨益的。

wakeup语义

众所周知,selector.select()是阻塞的,通常情况下,只有注册在selector上的channel有事件就绪时,select()才会从阻塞中被唤醒,处理就绪事件。那么,当selector上的channel无就绪事件时,如果想要唤醒阻塞在select()操作上的线程去处理一些别的工作,该如何实现呢?事实上Selector提供了这样的API:

public abstract Selector wakeup();

wakeup()实现的功能:

  • 如果一个线程在调用select()或select(long)方法时被阻塞,调用wakeup()会使线程立即从阻塞中唤醒;如果调用wakeup()期间没有select操作,下次调用select相关操作会立即返回,不会执行poll(),包括调用selectNow()。
  • 在Select期间,多次调用wakeup()与调用一次效果是一样的。

注意:如果调用wakeup()期间没有select操作,后续若先调用一次selectNow(),再次调用select()则会导致阻塞。

wakeup实现机制

以上描述了wakeup()的功能,那么JAVA NIO中是如何实现这个机制的呢?下面以windows环境为例,结合源码来探究这个问题。

通常我们会使用Selector.open()方法创建一个选择器对象,SelectorProvider负责根据不同操作系统来返回不同的实现类,windows平台就返回WindowsSelectorProvider,然后再调用其openSelector()

public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}

从WindowsSelectorProvider的openSelector()可知,其作用是创建一个WindowsSelectorImpl对象:

public AbstractSelector openSelector() throws IOException {
return new WindowsSelectorImpl(this);
}

WindowsSelectorImpl就是Selector接口的最终实现类,我们来看看其构造方法都做了什么:

WindowsSelectorImpl(SelectorProvider sp) throws IOException {
super(sp);
pollWrapper = new PollArrayWrapper(INIT_CAP);
wakeupPipe = Pipe.open();
wakeupSourceFd = ((SelChImpl)wakeupPipe.source()).getFDVal(); SinkChannelImpl sink = (SinkChannelImpl)wakeupPipe.sink();
(sink.sc).socket().setTcpNoDelay(true);//禁用Nagle算法
wakeupSinkFd = ((SelChImpl)sink).getFDVal(); pollWrapper.addWakeupSocket(wakeupSourceFd, 0);
}

从源码可知,实例化WindowsSelectorImpl时,会调用Pipe.open()创建一个管道实例wakeupPipe,并从wakeupPipe中获取wakeupSourceFd和wakeupSinkFd两个文件描述符,wakeupSourceFd为read端FD,wakeupSinkFd为write端FD,然后将wakeupSourceFd加入pollWrapper中。

我们知道,pollWrapper的作用是保存当前selector对象上注册的FD,当调用Selector的select()方法时,会将pollWrapper的内存地址传递给内核,由内核负责轮训pollWrapper中的FD,一旦有事件就绪,将事件就绪的FD传递回用户空间,阻塞在select()的线程就会被唤醒。将wakeupSourceFd加入pollWrapper中,表示selector也需要关注wakeupSourceFd上发生的事件,而谁会处理该事件呢?我们先了解下Pipe吧。

从广义上说,管道就是一个用来在两个实体之间单向传输数据的导管。在Unix系统中,管道被用来连接一个进程的输出和另一个进程的输入。Java使用Pipe类实现了一个管道范例,只不过它创建的管道是进程内(JVM进程内部)而非进程间使用的。

Pipe实现的管道由一个可写的SinkChannel和一个可读的SourceChannel组成,这两个Channel的远端是连接起来的,使得一旦将一些字节写入到SinkChannel,就可以在SourceChannel按写入顺序读取这些字节。下面我们看看SinkChannel和SourceChannel类继承结构图:

从图中我们知道几点:

  • SinkChannel和SourceChannel都扩展了AbstractSelectableChannel,因此都支持被注册到一个Selector上;
  • SourceChannel只实现了ReadableByteChannel,因此只支持读操作;同时实现了ScatteringByteChannel,具有将通道中的数据分散到多个缓冲区的能力(矢量I/O);
  • SinkChannel只实现了WritableByteChannel,因此只支持写操作;同时实现了GatheringByteChannel,具有将多个缓冲区的数据聚集到该通道的能力(矢量I/O);
  • SinkChannel和SourceChannel的实现类都实现了SelChImpl,因此都能获取通道相关联的文件描述符FD;
  • SourceChannel和SinkChannel内部通过聚合SocketChannel来完成读和写相关的操作。

下面我们继续分析Pipe.open()的实现;

Pipe.open()最终会创建一个PipeImpl实例:

PipeImpl(final SelectorProvider sp) throws IOException {
try {
AccessController.doPrivileged(new Initializer(sp));
} catch (PrivilegedActionException x) {
throw (IOException)x.getCause();
}
}

PipeImpl构造方法中会创建一个内部类Initializer实例,并调用它的run方法:

public Void run() throws IOException {
LoopbackConnector connector = new LoopbackConnector();
connector.run();
......
}

Initializer的run方法则会创建内部LoopbackConnector的实例,并调用它的run方法,其主要作用是建立一条本地环回连接,看实现:

public void run() {
ServerSocketChannel ssc = null;
SocketChannel sc1 = null;
SocketChannel sc2 = null; try {
// 环回地址
InetAddress lb = InetAddress.getByName("127.0.0.1");
assert(lb.isLoopbackAddress());
InetSocketAddress sa = null;
for(;;) {
// 绑定ServerSocketChannel到环回地址上的一个端口
if (ssc == null || !ssc.isOpen()) {
ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(lb, 0));
sa = new InetSocketAddress(lb, ssc.socket().getLocalPort());
} //建立连接
sc1 = SocketChannel.open(sa);
ByteBuffer bb = ByteBuffer.allocate(8);
long secret = rnd.nextLong();
bb.putLong(secret).flip();
sc1.write(bb); // 获取连接并校验合法性
sc2 = ssc.accept();
bb.clear();
sc2.read(bb);
bb.rewind();
if (bb.getLong() == secret)
break;
sc2.close();
sc1.close();
} // 创建source通道和sink通道
source = new SourceChannelImpl(sp, sc1);
sink = new SinkChannelImpl(sp, sc2);
} catch (IOException e) {
try {
if (sc1 != null)
sc1.close();
if (sc2 != null)
sc2.close();
} catch (IOException e2) {}
ioe = e;
} finally {
try {
if (ssc != null)
ssc.close();
} catch (IOException e2) {}
}
}

run方法完成的功能:

  • 使用本地环回地址“127.0.0.1”创建一个InetAddress实例lb。“127.0.0.1”是一个保留地址,主要用于环回测试,也就是说,目的地址为环回地址的IP数据包永远都不会出现在任何网络中;
  • 创建一个ServerSocketChannelImpl实例ssc,为该通道绑定一个唯一的文件描述符FD;
  • 使用lb和0号端口创建一个InetSocketAddress实例,并将该实例绑定到服务端socket通道上。这里使用了系统预留的0号端口,主要是为了避免写死端口号,操作系统会从动态端口号范围内搜索接下来可以使用的端口号作为服务端socket通道的监听端口;
  • 使用lb和ssc上绑定的端口号创建一个InetSocketAddress实例sa,再用sa创建一个SocketChannel实例,并为该通道绑定一个唯一的文件描述符FD;
  • 客户端socket通道创建成功后会调用connect尝试建立socket连接,由于当前处于阻塞模式,因此connect会阻塞直到成功建立连接或发生IO错误;
  • 创建一个8字节的ByteBuffer对象,填充一个随机long值,然后将缓冲区的数据写入通道sc1;
  • 调用ssc的accept()方法,accept方法会创建一个新的SocketChannel实例,并绑定一个唯一的文件描述符FD,然后使用这个SocketChannel实例读取数据;
  • 比较发送的数据和接收的数据是否相等,若相等,使用sc1创建SourceChannelImpl实例作为管道的source端,使用sc2创建SinkChannelImpl实例作为管道的sink端;
  • 最后调用close()关闭ServerSocketChannel,这样ServerSocketChannel就不会接受新的连接,同时释放绑定在该通道上的FD。

到此,一个管道被成功建立,这个管道的两端为两个通道,SourceChannel作为read端,而SinkChannel为write端,两个通道之间通过TCP进行连接,这样使得在SinkChannel端写入的数据SourceChannel端可以立马读取。

Java中Pipe实现的管道仅用于在同一个Java虚拟机内部传输数据。实际应用中,使用管道在线程间传输数据也是一种不错的方案,它为我们提供了良好的封装性。

现在,回到WindowsSelectorImpl的构造方法中,我们知道,创建一个Selector实例时,还会创建一个管道Pipe实例,并将管道source端wakeupSourceFd加入pollWrapper中,作为第一个注册到Selector的FD,并设置感兴趣的事件为Net.POLLIN,表示对可读事件感兴趣。当Selector在轮训pollWrapper中的FD时,如果wakeupSourceFd发生read事件,那么Selector就会被唤醒,这就是wakeup()的实现原理。看wakeup()实现:

public Selector wakeup() {
synchronized (interruptLock) {
if (!interruptTriggered) {
setWakeupSocket();
interruptTriggered = true;
}
}
return this;
}

首先判断interruptTriggered,如果为True,立即返回;如果为False,调用setWakeupSocket(),并将interruptTriggered设置为true。下面看setWakeupSocket()的实现:

private void setWakeupSocket() {
this.setWakeupSocket0(this.wakeupSinkFd);
}
private native void setWakeupSocket0(int wakeupSinkFd);

传入管道sink端的wakeupSinkFd,然后调用底层的setWakeupSocket0方法,下面从openjdk8源文件WindowsSelectorImpl.c找到setWakeupSocket0的实现:

Java_sun_nio_ch_WindowsSelectorImpl_setWakeupSocket0(JNIEnv *env, jclass this,
jint scoutFd)
{
/* Write one byte into the pipe */
const char byte = 1;
send(scoutFd, &byte, 1, 0);
}

该函数的主要作用是向pipe的sink端写入了一个字节,这样pipe的source端文件描述符立即就会处于就绪状态,select()方法将立即从阻塞中返回,这样就完成了唤醒selector的功能。

wakeup()中使用interruptTriggered来判断是否执行唤醒操作。因此,在select期间,多次调用wakeup()产生的效果与调用一次是一样的,因为后面的调用将不会满足唤醒条件。如果调用wakeup()期间没有select操作,当调用wakeup()之后,interruptTriggered被设置为true,pipe的source端wakeupSourceFd 就会处于就绪状态。如果此时调用select相关操作时,会调用resetWakeupSocket 方法,resetWakeupSocket 首先会调用本地方法resetWakeupSocket0读取wakeup()中发送的数据,再将interruptTriggered设置为false,最后doSelect将会立即返回0,而不会调用poll操作。

为什么说将一些字节写入到SinkChannel后,SourceChannel就可以立即按写入顺序读取这些字节?

这是因为我们在WindowsSelectorImpl构造方法中将TCP参数TCP_NODELAY设置为了true。该参数的主要作用是禁用Nagle算法,当sink端写入1字节数据时,将立即发送,而不必等到将较小的包组合成较大的包再发送,这样source端就可以立马读取数据。

下面附上windows环境下Selector的实现原理图帮助理解阻塞与唤醒的原理(图片来源网络):

总结

本文主要介绍了windows环境下wakeup()的实现原理,它通过一个可写的SinkChannel和一个可读的SourceChannel组成的pipe来实现唤醒的功能,而Linux环境则使用其本身的Pipe来实现唤醒功能。无论windows还是linux,wakeup的思想是完全一致的,只不过windows没有Pipe这种信号通知的机制,所以通过TCP来实现了Pipe,建立了一对自己和自己的loopback的TCP连接来发送信号。请注意,每创建一个Selector对象,都会创建一个Pipe实例,这会导致消耗两个文件描述符FD和两个端口号,实际开发中需注意端口号和文件描述符的限制。

Java NIO wakeup实现原理的更多相关文章

  1. Java NIO使用及原理分析 (四)

    在上一篇文章中介绍了关于缓冲区的一些细节内容,现在终于可以进入NIO中最有意思的部分非阻塞I/O.通常在进行同步I/O操作时,如果读取数据,代码会阻塞直至有 可供读取的数据.同样,写入调用将会阻塞直至 ...

  2. Java NIO使用及原理分析 (四)(转)

    在上一篇文章中介绍了关于缓冲区的一些细节内容,现在终于可以进入NIO中最有意思的部分非阻塞I/O.通常在进行同步I/O操作时,如果读取数据,代码会阻塞直至有 可供读取的数据.同样,写入调用将会阻塞直至 ...

  3. Java NIO使用及原理分析(1-4)(转)

    转载的原文章也找不到!从以下博客中找到http://blog.csdn.net/wuxianglong/article/details/6604817 转载自:李会军•宁静致远 最近由于工作关系要做一 ...

  4. Java NIO使用及原理分析(二)

    在第一篇中,我们介绍了NIO中的两个核心对象:缓冲区和通道,在谈到缓冲区时,我们说缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如 ...

  5. Java NIO使用及原理分析(二)(转)

    在第一篇中,我们介绍了NIO中的两个核心对象:缓冲区和通道,在谈到缓冲区时,我们说缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如 ...

  6. Java NIO使用及原理分析 (一)(转)

    最近由于工作关系要做一些Java方面的开发,其中最重要的一块就是Java NIO(New I/O),尽管很早以前了解过一些,但并没有认真去看过它的实现原理,也没有机会在工作中使用,这次也好重新研究一下 ...

  7. Java NIO使用及原理分析(三)

    在上一篇文章中介绍了缓冲区内部对于状态变化的跟踪机制,而对于NIO中缓冲区来说,还有很多的内容值的学习,如缓冲区的分片与数据共享,只读缓冲区等.在本文中我们来看一下缓冲区一些更细节的内容. 缓冲区的分 ...

  8. Java NIO使用及原理分析(三)(转)

    在上一篇文章中介绍了缓冲区内部对于状态变化的跟踪机制,而对于NIO中缓冲区来说,还有很多的内容值的学习,如缓冲区的分片与数据共享,只读缓冲区等.在本文中我们来看一下缓冲区一些更细节的内容. 缓冲区的分 ...

  9. Java NIO使用及原理分析 (一)

    http://blog.csdn.net/wuxianglong/article/details/6604817

随机推荐

  1. JSP标签使用的代码记录——《%= %》(神奇的CSDN为啥标题不让打英文的尖括号)

    关于JSP的一些标签,在用到的时候有些生疏,就去找了找资源重新温习了一下. 附上两个JSP<%= %>标签的博客,同时也记录当前项目里用到的方法. jsp页面中<%@ %>.& ...

  2. MySQL特殊字符的转义处理

    出现问题以及问题分析 这条语句会把user_name不为空的所有记录查询出来 select * from user where user_name like concat('%','_','%') 分 ...

  3. Python单元测试框架pytest常用测试报告类型

    先前博客有介绍pytest测试框架的安装及使用,现在来聊聊pytest可以生成哪些测试报告 1.allure测试报告 关于allure报告参见先前的一篇博文:https://www.cnblogs.c ...

  4. for循环语句学习

    for循环又称为遍历循环,从名字就可以知道,它用于对象的遍历 语法格式: 会从可迭代对象对象中依次拿出值来赋值给变量,变量的值每次都会被修改 for 变量1[变量2...] in 可迭代对象: 代码块 ...

  5. 最短Hamilton路径(状压dp)

    最短Hamilton路径实际上就是状压dp,而且这是一道作为一个初学状压dp的我应该必做的题目 题目描述 给定一张 n(n≤20) 个点的带权无向图,点从 0~n-1 标号,求起点 0 到终点 n-1 ...

  6. Codeforces Global Round 9 E. Inversion SwapSort

    题目链接:https://codeforces.com/contest/1375/problem/E 题意 给出一个大小为 $n$ 的数组 $a$,对数组中的所有逆序对进行排序,要求按照排序后的顺序交 ...

  7. 【洛谷 p3371】模板-单源最短路径(图论)

    题目:给出一个有向图,请输出从某一点出发到所有点的最短路径长度. 解法:spfa算法. 1 #include<cstdio> 2 #include<cstdlib> 3 #in ...

  8. 【uva 11134】Fabled Rooks(算法效率--问题分解+贪心)

    题意:要求在一个N*N的棋盘上放N个车,使得它们所在的行和列均不同,而且分别处于第 i 个矩形中. 解法:问题分解+贪心. 由于行.列不相关,所以可以先把行和列均不同的问题分解为2个"在区间 ...

  9. ACdream1414 Geometry Problem

    Problem Description       Peter is studying in the third grade of elementary school. His teacher of ...

  10. 一篇文章图文并茂地带你轻松学完 JavaScript 设计模式(一)

    JavaScript 设计模式(一) 本文需要读者至少拥有基础的 ES6 知识,包括 Proxy, Reflect 以及 Generator 函数等. 至于这次为什么分了两篇文章,有损传统以及标题的正 ...