一、网络服务和请求的特点与事件分发器的两种模式:
例如:Web服务、分布式事务
大多数都有相同的基础结构和步骤:
读请求:Read request
解码请求:Decode request
进程服务:Process service
编码回复:Encode reply
回复应答:Send reply
但是各种不同的请求不同在逻辑和每一步的开销
例如:
XML parsing, File transfer, Web page generation, computational services, …
典型的服务设计如下图所示:
参照上图,可以写出典型的ServerSocket的伪代码如下:
class Server implements Runnable {
public static final Integer PORT=123333;//mock的
public static final Integer MAX_INPUT=128;//mock的
public void run() {
try {
ServerSocket ss = new ServerSocket(PORT);
while (!Thread.interrupted())
new Thread(new Handler(ss.accept())).start();
// or, single-threaded, or a thread pool
} catch (IOException ex) { /* ... */ }
}
static class Handler implements Runnable {
final Socket socket;
Handler(Socket s) { socket = s; }
public void run() {
try {
byte[] input = new byte[MAX_INPUT];
socket.getInputStream().read(input);
byte[] output = process(input);
socket.getOutputStream().write(output);
} catch (IOException ex) { /* ... */ }
}
private byte[] process(byte[] cmd) { /* ... */ }
}
}
分治思想:
-
1.将进程切分成小的任务,每一小块非阻塞的去执行任务
-
2.当每个任务被启用时执行它 一个IO事件通常如下所示:
-
3.java nio中的基本原理:
- 非阻塞的读写
- 分配任务绑定IO事件
一般情况下,I/O 复用机制需要事件分发器(event dispatcher)。 事件分发器的作用,即将那些读写事件源分发给各读写事件的处理者,就像送快递的在楼下喊: 谁谁谁的快递到了, 快来拿吧!开发人员在开始的时候需要在分发器那里注册感兴趣的事件,并提供相应的处理者(event handler),或者是回调函数;事件分发器在适当的时候,会将请求的事件分发给这些handler或者回调函数。
涉及到事件分发器的两种模式称为:Reactor和Proactor。 Reactor模式是基于同步I/O的,而Proactor模式是和异步I/O相关的。在Reactor模式中,事件分发器等待某个事件或者可应用或个操作的状态发生(比如文件描述符可读写,或者是socket可读写),事件分发器就把这个事件传给事先注册的事件处理函数或者回调函数,由后者来做实际的读写操作。
单机版Reactor模式:
二、Java NIO
Java NIO非堵塞技术实际是采取Reactor模式,或者说是Observer模式为我们监察I/O端口,如果有内容进来,会自动通知我们,这样,我们就不必开启多个线程死等,从外界看,实现了流畅的I/O读写,不堵塞了。
Java NIO的组成:
-
Channels:与文件、socket连接来支持 非阻塞读 Channel 有点象流。 数据可以从Channel读到Buffer中
-
Buffers:缓冲区
-
Selectors(选择器):这个类似一个观察者,只要我们把需要探知的socketchannel告诉Selector,我们接着做别的事情,当有事件发生时,他会通知我们,传回一组SelectionKey,我们读取这些Key,就会获得我们刚刚注册过的socketchannel,然后,我们从这个Channel中读取数据,放心,包准能够读到,接着我们可以处理这些数据。
-
SelectionKeys:一个用于向Selector注册Channel的token。每次Selector注册一个Channel的时候都会创建一个 SelectionKey,一个键(SelectionKey)一直有效,直到通过调用它的cancel方法、关闭它的通道或关闭它的选择器来取消它。cancel方法取消键(SelectionKey)不会立即将其从选择器中移除;相反,它被添加到选择器(SelectionKey)的取消键集中,以便在下一个选择操作中删除。可以通过调用键的isValid方法来测试键的有效性。
选择键包含两个表示为整数值的操作集 。 操作集的每一位表示由密钥通道支持的可选择操作的类别。
- (interest set)兴趣集确定下一次调用选择器的选择方法之一后,准备测试哪些操作类别。 兴趣集在创建密钥时用给定的值初始化; 可以稍后通过
interestOps(int)
方法进行更改。 - (ready set)就续集标识了键的选择器已经检测到密钥通道已准备就绪的操作类别。 当创建密钥时,就绪集被初始化为零; 可能在选择操作期间可能会被选择器更新,但不能直接更新。
选择键的就绪集(ready set)表示其通道对某些操作类别做好准备是一个提示,但不能保证这样的类别中的操作可以由线程执行而不会导致线程阻塞。 在完成选择操作之后,准备好的集合很可能是准确的。 外部事件和相应通道上调用的I / O操作可能会导致不准确。
该类定义了所有已知的操作设置位,但是确切地说,给定通道支持哪些位取决于通道的类型。
SelectableChannel
的每个子类定义了一个validOps()
方法,它返回一组仅识别通道支持的操作的集合。 尝试设置或测试密钥通道不支持的操作集位将导致适当的运行时异常。通常需要将某些特定于应用程序的数据与选择密钥相关联,例如表示较高级别协议的状态的对象,并处理就绪通知以实现该协议。 因此选择键支持单个任意对象的一个键的连接 。 可以通过
attach
方法附加一个对象,然后通过attachment
方法检索 。多个并发线程使用选择键是安全的。 通常,读取和写入兴趣集的操作将与选择器的某些操作同步。
正是这种同步的执行方式取决于实现:在简单的实现中,如果选择操作已经进行,则读取或写入兴趣集可能会无限期地阻止;
在高性能的实施中,阅读或写入兴趣集可能会暂时阻止,如果有的话。 在任何情况下,选择操作将始终使用在操作开始时当前的兴趣值。
- (interest set)兴趣集确定下一次调用选择器的选择方法之一后,准备测试哪些操作类别。 兴趣集在创建密钥时用给定的值初始化; 可以稍后通过
Channel和Buffer有好几种类型。下面是JAVA NIO中的一些主要Channel的实现:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
正如你所看到的,这些通道涵盖了UDP 和 TCP 网络IO,以及文件IO。
服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多路复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一,大多数IO相关组件如Netty、Redis在使用的IO模式,消息队列kafka中接收消息也是基于Reactor模式。
我的理解:Reactor 模型中有 2 个关键组成:
- Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人。
- Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。
取决于 Reactor 的数量和 Hanndler 线程数量的不同,Reactor 模型有 3 个变种:
- 单 Reactor 单线程。
- 单 Reactor 多线程。
- 主从 Reactor 多线程。
关于如何处理请求?
很容易想到两个方案
1.顺序处理请求:如果写成伪代码,大概是这个样子:
while (true) {
Request request = accept(connection);
handle(request);
}
这个方法实现简单,但是有个致命的缺陷,那就是吞吐量太差。由于只能顺序处理每个请求,因此,每个请求都必须等待前一个请求处理完毕才能得到处理。这种方式只适用于请求发送非常不频繁的系统。
2**.每个请求使用单独线程处理**。也就是说,我们为每个入站请求都创建一个新的线程来异步处理。我们一起来看看这个方案的伪代码。
while (true) {
Request = request = accept(connection);
Thread thread = new Thread(() -> {
handle(request);});
thread.start();
}
这个方法反其道而行之,完全采用异步的方式。系统会为每个入站请求都创建单独的线程来处理。
优点:它是完全异步的,每个请求的处理都不会阻塞下一个请求
缺点:为每个请求都创建线程的做法开销极大,在某些场景下甚至会压垮整个服务。
还是那句话,这个方法只适用于请求发送频率很低的业务场景。
那么先来看看单线程的Reactor是如何处理请求的
三、单 Reactor 单线程
方案说明:
- 1.select是 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求。
- 2.Reactor对象通过select监控客户端请求事件,收到事件后通过 Dispatch 进行分发。
- 3.如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理。
- 4.如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应。
- 5.Handler 会完成 Read→业务处理→Send 的完整业务流程。
服务器端用一个线程通过多路复用搞定所有的 IO 操作(包括连接,读、写等),编码简单,清晰明了,但是如果客户端连接数量较多,将无法支撑,下面的NIO就属于这种模型。
3.1Reactor模型的朴素原型
Java的NIO模式的Selector网络通讯,其实就是一个简单的Reactor模型。可以说是Reactor模型的朴素原型
下面用Java NIO来简单实现一个Reactor模型(非常简单,因为Java NIO就是基于Reactor模型实现的)
public class NIOServer {
public static void main(String[] args) throws IOException {
//1、获取Selector选择器
Selector selector = Selector.open();
// 2、获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 3.设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 4、绑定连接
serverSocketChannel.bind(new InetSocketAddress(8888));
// 5、将通道注册到选择器上,并注册的操作为:“接收”操作
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 6、采用轮询的方式,查询获取“准备就绪”的注册过的操作
while (true){
if(selector.select() == 0){
continue;
}
// 7、获取当前选择器中所有注册的选择键(“已经准备就绪的操作”)
Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
while(selectedKeys.hasNext()){
// 8、获取“准备就绪”的时间
SelectionKey selectedKey = selectedKeys.next();
// 9、判断key是具体的什么事件
if (selectedKey.isAcceptable()) {
// 10、若接受的事件是“接收就绪” 操作,就获取客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
// 11、切换为非阻塞模式
socketChannel.configureBlocking(false);
// 12、将该通道注册到selector选择器上
socketChannel.register(selector, SelectionKey.OP_READ);
}else if(selectedKey.isReadable()){
// 13、获取该选择器上的“读就绪”状态的通道
SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
//14.读取数据
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int length=0;
while((length=socketChannel.read(byteBuffer))!=-1){
//flip():Buffer有两种模式,写模式和读模式。在写模式下调用flip()之后,Buffer从写模式变成读模式。
//那么limit就设置成了position当前的值(即当前写了多少数据),postion会被置为0,以表示读操作从缓存的头开始读,mark置为-1。
//也就是说调用flip()之后,读/写指针position指到缓冲区头部,并且设置了最多只能读出之前写入的数据长度(而不是整个缓存的容量大小)。
byteBuffer.flip();
System.out.println(new String(byteBuffer.array(), 0, length));
byteBuffer.clear();
}
socketChannel.close();
}
// 15、移除选择键
selectedKeys.remove();
}
}
}
}
在上面这个简单的模型中抽象出来两个组件——Reactor和Handler两个组件:
1)Reactor:负责响应IO事件,当检测到一个新的事件,将其发送给相应的Handler去处理;新的事件包含连接建立就绪、读就绪、写就绪等。
2)Handler:将自身(handler)与事件绑定,负责事件的处理,完成channel的读入,完成处理业务逻辑后,负责将结果写出channel。
就能表示一个简单的Reactor模型了
3.2什么是单线程Reactor呢?
下面的图,来自于“Scalable IO in Java”。Reactor和Hander 处于一条线程执行。
这是最简单的单Reactor单线程模型。Reactor线程是个多面手,负责多路分离套接字,Accept新连接,并分派请求到Handler处理器中
顺便说一下,可以将上图的accepter,看做是一种特殊的handler。
3.3单线程Reactor的参考代码
“Scalable IO in Java”,实现了一个单线程Reactor的参考代码,Reactor的代码如下:
Reactor线程
class Reactor implements Runnable {
final Selector selector;
final ServerSocketChannel serverSocket;
Reactor(int port) throws IOException {
//1、获取Selector选择器
selector = Selector.open();
//2、获取通道
serverSocket = ServerSocketChannel.open();
//3、绑定连接
serverSocket.socket().bind(
new InetSocketAddress(port));
//4、设置为非阻塞
serverSocket.configureBlocking(false);
// 5、将通道注册到选择器上,并注册的操作为:“接收”操作
SelectionKey sk =
serverSocket.register(selector,
SelectionKey.OP_ACCEPT);
//6、新建一个Acceptor分发器 关联到serverSocket
sk.attach(new Acceptor());
}
public void run() { // normally in a new Thread
try {
while (!Thread.interrupted()) {
//select函数 阻塞式的 至少有一个I/O事件就绪,才会返回
selector.select();
//得到selectedKeys
Set selected = selector.selectedKeys();
Iterator it = selected.iterator();
//遍历selectedKeys 用Acceptor分配
while (it.hasNext()){
dispatch((SelectionKey)(it.next()));
}
//都执行完了,清空selectedKeys
selected.clear();
}
} catch (IOException ex) { /* ... */ }
}
void dispatch(SelectionKey k) {
//通过选择见检索到当前附加的对象也就是Acceptor 去执行
Runnable r = (Runnable)(k.attachment());
if (r != null)
r.run();
}
class Acceptor implements Runnable { // inner
public void run() {
try {
SocketChannel c = serverSocket.accept();
//创建一个handler去执行(读或写)channel
if (c != null)
new Handler(selector, c);
}
catch(IOException ex) { /* ... */ }
}
}
}
Handler执行器
public class Handler implements Runnable {
private SocketChannel channel;
private SelectionKey selectionKey;
ByteBuffer input = ByteBuffer.allocate(1024);
ByteBuffer output = ByteBuffer.allocate(1024);
static final int READING = 0, SENDING = 1;
int state = READING;
public Handler(Selector selector, SocketChannel c) throws IOException {
this.channel = c;
c.configureBlocking(false);
// Optionally try first read now
this.selectionKey = channel.register(selector, 0);
//将Handler作为callback对象
this.selectionKey.attach(this);
//第二步,注册Read就绪事件
this.selectionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
private boolean inputIsComplete() {
/* ... */
return false;
}
private boolean outputIsComplete() {
/* ... */
return false;
}
private void process() {
/* ... */
return;
}
public void run() {
try {
if (state == READING) {
read();
} else if (state == SENDING) {
send();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void read() throws IOException {
channel.read(input);
if (inputIsComplete()) {
process();
state = SENDING;
// Normally also do first write now
//第三步,接收write就绪事件
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
}
private void send() throws IOException {
channel.write(output);
//write完就结束了, 关闭select key
if (outputIsComplete()) {
selectionKey.cancel();
}
}
}
3.4单线程模式的缺点
当其中某个 handler 阻塞时, 会导致其他所有的 client 的 handler 都得不到执行, 并且更严重的是, handler 的阻塞也会导致整个服务不能接收新的 client 请求(因为 acceptor 也被阻塞了)。 因为有这么多的缺陷, 因此单线程Reactor 模型用的比较少。这种单线程模型不能充分利用多核资源,所以实际使用的不多。
3.5方案优缺点分析:
- 优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。
- 缺点:性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。
- 缺点:可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。
- 使用场景:单线程模型仅仅适用于handler 中业务处理组件能快速完成的场景。以及客户端的数量有限,业务处理非常快速,比如 Redis在业务处理的时间复杂度 O(1) 的情况。
四、多线程的单Reactor(体现在Handler执行器由多线程来执行)
方案说明:
- 1、Reactor 对象通过select 监控客户端请求事件,收到事件后,通过dispatch进行分发。
- 2、如果建立连接请求,则Acceptor 通过accept 处理连接请求,然后创建一个Handler对象处理完成连接后的各种事件。
- 3、如果不是连接请求,则由reactor分发调用连接对应的handler 来处理。
- 4、handler 只负责响应事件,不做具体的业务处理, 通过read 读取数据后,会分发给后面的worker线程池的某个线程处理业务。
- 5、worker 线程池会分配独立线程完成真正的业务,并将结果返回给handler。
- 6、handler收到响应后,通过send 将结果返回给client。
方案优缺点分析:
- 优点:可以充分的利用多核cpu 的处理能力。
- 缺点:多线程数据共享和访问比较复杂, reactor 处理所有的事件的监听和响应,在单线程运行, 在高并发场景容易出现性能瓶颈。
4.1、基于线程池的改进
在多线程Reactor模式基础上,做如下改进:
- 1、将Handler处理器的执行放入线程池,多线程进行业务处理。
- 2、而对于Reactor而言,可以仍为单个线程。如果服务器为多核的CPU,为充分利用系统资源,可以将Reactor拆分为两个线程。
4.2、改进后的完整示意图
下面的图,来自于“Scalable IO in Java”,和上面的图的意思,差不多,只是更加详细。Reactor是一条独立的线程,Hander 处于线程池中执行。
4.3、多线程Reactor的参考代码
“Scalable IO in Java”,的多线程Reactor的参考代码,是基于单线程做一个线程池的改进,改进的Handler的代码如下:
public class MThreadHandler implements Runnable {
private SocketChannel channel;
private SelectionKey selectionKey;
ByteBuffer input = ByteBuffer.allocate(1024);
ByteBuffer output = ByteBuffer.allocate(1024);
static final int READING = 0, SENDING = 1;
int state = READING;
ExecutorService pool = Executors.newFixedThreadPool(2);
static final int PROCESSING = 3;
public MThreadHandler(Selector selector, SocketChannel c) throws IOException {
this.channel = c;
c.configureBlocking(false);
// Optionally try first read now
this.selectionKey = this.channel.register(selector, 0);
//将Handler作为callback对象
this.selectionKey.attach(this);
//第二步,注册Read就绪事件
this.selectionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
private boolean inputIsComplete() {
/* ... */
return false;
}
private boolean outputIsComplete() {
/* ... */
return false;
}
private void process() {
/* ... */
return;
}
public void run() {
try {
if (state == READING) {
read();
} else if (state == SENDING) {
send();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private synchronized void read() throws IOException {
// ...
channel.read(input);
if (inputIsComplete()) {
state = PROCESSING;
//使用线程pool异步执行
pool.execute(new Processer());
}
}
private void send() throws IOException {
channel.write(output);
//write完就结束了, 关闭select key
if (outputIsComplete()) {
selectionKey.cancel();
}
}
private synchronized void processAndHandOff() {
process();
state = SENDING;
// or rebind attachment
//process完,开始等待write就绪
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
private class Processer implements Runnable {
public void run() {
processAndHandOff();
}
}
}
区别就在于使用了线程池来异步执行
private synchronized void read() throws IOException {
// ...
channel.read(input);
if (inputIsComplete()) {
state = PROCESSING;
//使用线程pool异步执行
pool.execute(new Processer());
}
}
五、主从 Reactor 多线程
针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行。
方案说明:
- 1、Reactor主线程 MainReactor 对象通过select 监听连接事件, 收到事件后,通过Acceptor 处理连接事件。
- 2、当 Acceptor 处理连接事件后,MainReactor 将连接分配给SubReactor。
- 3、subreactor 将连接加入到连接队列进行监听,并创建handler进行各种事件处理。
- 4、当有新事件发生时, subreactor 就会调用对应的handler处理。
- 5、handler 通过read 读取数据,分发给后面的worker 线程处理。
- 6、worker 线程池分配独立的worker 线程进行业务处理,并返回结果。
- 7、handler 收到响应的结果后,再通过send 将结果返回给client。
- 8、Reactor 主线程可以对应多个Reactor 子线程, 即MainRecator 可以关联多个SubReactor。
加粗的为与多线程单Reactor不一样的地方
Scalable IO in Java 对 Multiple Reactors 的原理图解
对于多个CPU的机器,为充分利用系统资源,将Reactor拆分为两部分。代码如下:
public class MThreadReactor implements Runnable {
//subReactors集合, 一个selector代表一个subReactor
Selector[] selectors=new Selector[2];
int next = 0;
final ServerSocketChannel serverSocket;
private MThreadReactor(int port) throws IOException {
//Reactor初始化
selectors[0]=Selector.open();
selectors[1]= Selector.open();
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
//非阻塞
serverSocket.configureBlocking(false);
//分步处理,第一步,接收accept事件
SelectionKey selectionKey = serverSocket.register( selectors[0], SelectionKey.OP_ACCEPT);
//attach callback object, Acceptor
selectionKey.attach(new Acceptor());
}
public void run() {
try {
while (!Thread.interrupted()) {
for (int i = 0; i <2 ; i++) {
selectors[i].select();
Set<SelectionKey> selectionKeys = selectors[i].selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
//Reactor负责dispatch收到的事件
dispatch((SelectionKey) (iterator.next()));
}
selectionKeys.clear();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void dispatch(SelectionKey k) {
Runnable r = (Runnable) (k.attachment());
//调用之前注册的callback对象
if (r != null) {
r.run();
}
}
class Acceptor { // ...
public synchronized void run() throws IOException {
//主selector负责accept
SocketChannel connection = serverSocket.accept();
if (connection != null) {
//选个subReactor去负责接收到的connection
new Handler(selectors[next], connection);
}
if (++next == selectors.length) {
next = 0;
}
}
}
}
方案优缺点说明:
- 优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
- 优点:父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
- 缺点:编程复杂度较高。
这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持,kafka在接收客户端请求时也是类似。
六、Reactor编程的优点和缺点
优点:
- 1、响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
- 2、编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
- 3、可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
- 4、可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
缺点:
- 1、相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
- 2、Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
- 3、Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用改进版的Reactor模式如Proactor模式。
参考:
https://www.jianshu.com/p/2759a2374ed4
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf