本文转载自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. 面向对象编程(UDP协议)

    UDP协议 UDP 是User Datagram Protocol的简称, 中文名是用户数据报协议,是OSI(Open System Interconnection,开放式系统互联) 参考模型中一种无 ...

  2. MySQL 中的自增主键

    MySQL 的主键可以是自增的,那么如果在断电重启后新增的值还会延续断电前的自增值吗?自增值默认为1,那么可不可以改变呢?下面就说一下 MySQL 的自增值. 特点 保存策略 1.如果存储引擎是 My ...

  3. Java程序操作HBase

    package com.zy.test; import java.io.IOException; import org.apache.hadoop.conf.Configuration; import ...

  4. 带撤销并查集 & 可持久化并查集

    带撤销并查集支持从某个元素从原来的集合中撤出来,然后加入到一个另外一个集合中,或者删除该元素 用一个映射来表示元素和并查集中序号的关系,代码中用\(to[x]\) 表示x号元素在并查集中的 id 删除 ...

  5. Educational Codeforces Round 94 (Rated for Div. 2)【ABCD】

    比赛链接:https://codeforces.com/contest/1400 A. String Similarity 题意 给出一个长 $2n-1$ 的二进制串 $s$,构造一个长 $n$ 的字 ...

  6. Java-Swing的JFrame的一些插件使用详解

    JFrame介绍: 在 JFrame 对象中可以使用add方法添加 AWT 或者 Swing 组件. JFrame 有一个 Content Pane,窗口能显示的所有组件都是添加在这个 Content ...

  7. poj 2007 凸包构造和极角排序输出(模板题)

    Scrambled Polygon Time Limit: 1000MS   Memory Limit: 30000K Total Submissions: 10841   Accepted: 508 ...

  8. hdu5247 找连续数

    Problem Description 小度熊拿到了一个无序的数组,对于这个数组,小度熊想知道是否能找到一个k 的区间,里面的 k 个数字排完序后是连续的. 现在小度熊增加题目难度,他不想知道是否有这 ...

  9. 牛客编程巅峰赛S1第3场 - 青铜&白银 C.牛牛晾衣服(二分)

    题意:有\(n\)件衣服,每件衣服都有\(a_{i}\)滴水,所有衣服每分钟都能自然烘干\(1\)滴水,或者用烘干机,每分钟可以烘干\(k\)滴水,问最快多少分钟可以使所有衣服都烘干. 题解:这题和之 ...

  10. 自动生成requirements.txt

    Python 自动生成当前项目的requirements.txt 通常我们开发一个python项目时都会用conda 或者 virtualenv 等虚拟环境管理工具来创建一个虚拟环境,在这个虚拟环境中 ...