发布于 2018-07-02
异步IO编程在javascript中得到了广泛的应用,之前也写过一篇博文进行梳理。
js的异步IO即是异步的,也是非阻塞的。非阻塞的IO需要底层操作系统的支持,比如在linux上的epoll系统调用。
从另外一个角度看待的话,底层操作系统对于非阻塞IO的系统调用是一种多路复用机制,js对其进行了比较厚的封装,转换成了异步IO。
但是,也可以进行一层稍微薄点的封装,保留这种多路复用的模型,比如java的NIO,是一种同步非阻塞的IO模型。
非阻塞IO的一大优势是,性能好,快啊!这在对IO性能要求高的场景得到了大量应用,比如SOA框架。
传统的同步阻塞IO
同步阻塞IO的特点
传统的同步IO方式,比如网络传输,比如文件IO,在调用者调用read()时,调用会被一层一层调用下去直到OS的系统调用,调用者的线程会被阻塞。
当读取完成时,该线程又会被唤醒,read()函数返回IO操作读取的数据。
我们很容易能发现这种方式的特点及优劣:
接口容易理解,编程难度低。对调用者而言,read()就像一个普通的函数调用一样,返回读取的数据。只不过可能这个操作有点慢,这个函数执行时间长了一些而已。
在费时的IO操作时,线程需要等待IO完成。这意味着,如果你需要多个IO操作同时进行,就只能通过开多个线程来解决。
在客户端编程时,第二点这个问题不大。客户端程序对IO的并发要求不高,反而因为同步阻塞IO的接口易于编程而能够减轻编程难度,代码更直观更可读,从而变相的提高可调试性和开发效率。
服务端编程的特点
然而,在服务器端编程的时候,这个劣势就很明显了,服务器端程序可能会面临大量并发IO的考验。
传统的同步IO方式,比如说socket编程,服务器端的一个简单的处理逻辑是这样的:
使用一个线程监听端口,如有客户端的TCP连接连入,就交由处理线程处理。
每来一个TCP连接,就需要开一个线程来处理和该客户端的逻辑。
在实际场景中会有很多优化技术,比如使用线程池。然而线程池仅仅是将TCP连接放入一个队列里交由线程池中空闲的线程处理。
实质上,即使使用线程池,也改变不了正在被处理的每一个请求都需要占用一个单独的线程这一事实。
这样,会造成一些问题:
每一个请求需要一个线程来处理,但是服务器的线程数量是有上限的,这就限制了服务器的并发量。
线程本身的调度也占用一定的操作系统资源,在线程比较多的情况下,这个占用叠加起来就非常客观。
多路复用IO
概念及模型
java提供的NIO就是一种多路复用IO方式。
它能够将多个IO操作用一个线程去管理,一个线程即可管理多个IO操作。
NIO的操作逻辑是这样的,首先将需要监控的IO操作注册到某个地方,并由一个线程管理。
当这些IO操作完成,会以事件的形式产生。该线程能够获取到完成的事件列表,并且对其进行处理。
java的NIO中有三个重要的概念:
Channel通道。表示一种IO原始源。如ServerSocketChannel表示监听客户端发起的TCP连接。
通过Channel能够发起某种IO操作,但是却立即返回不阻塞。
Buffer 缓冲区。Channel读取或写入的数据必须通过Buffer。网络读写常用的是ByteBuffer。
Selector 选择器。NIO中最核心的东西,将Channel注册到Selector中,使得Selector能够监控到该IO操作。
可以理解成Selecotr不断轮询被注册的Channel,一旦Channel中有注册的事件发生,便能处理发生的事件。
这里只是做个总结,看下下面的示例代码就明白了。
Selector和Channel
private void exec(int port) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int n = selector.select(); // Block
Iterator it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel channel = server.accept();
if (channel != null) {
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ);
onAccept(channel);
}
}
if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
onRead(socketChannel);
}
it.remove();
}
}
}
来一步一步的分析这些代码。
首先,第3行到第6行是对通道ServerSocketChannel的操作。
对于这个ServerSocketChannel,首先是设定了它的监听地址,这个与传统的阻塞IO一致,给定一些初始的数据。传统的阻塞IO之后会调用socket.accept()来获取客户端连接的TCP连接,这是一个阻塞的方法。
但是NIO在这里把ServerSocketChannel注册到了Selector上,并且监控OP_ACCEPT事件。这个时候socket可以认为已经在监听了,但是没有阻塞线程。
之后,如果有TCP连接连接上,OP_ACCEPT事件就会产生,通过selector即可处理该事件。
因此,NIO的操作逻辑其实是事件驱动的。
后面的循环则是Selector处理的主逻辑。
第9行,这是一个阻塞的方法。它会等待被注册的这些IO操作处理完成。一旦有一部分IO操作完成,它就会返回。
通过selector.selectedKeys()即可获得完成的IO操作的事件。后面的代码也就是在处理这些事件。
这部分完成的IO事件处理完毕后,就会循环的去处理下一批完成的IO事件,如此往复。
这里,我们可以清晰的看到,通过NIO的多路复用模型,我们通过一个线程,就能管理多个IO操作。
循环内部处理的逻辑,key.isAcceptable()可以认为是判断该事件是否是OP_ACCEPT事件。是的话表示已经有客户端TCP连接连接上了,第15行获取该TCP连接的socket对象。由于是NIO编程,这是获取到的是SocketChannel对象。
之后将该对象的OP_READ注册到Selector上,发起IO读操作,并且让Selector监听读完成的事件。
后面的key.isReadable()也是同样的道理,这里只有上面的代码注册了OP_READ事件,因此这里一定是上面的读操作完成了产生的事件。
Buffer
上面的代码里,当有新的TCP连接连入时,调用回调函数onAccept;当对方传输数据给自己时,数据读取完成后,调用回调函数onRead。
下面是这两个回调函数的实现,它的功能很简单:
当有TCP连接第一次连入时,发送hello\n给对方。
当接收到对方传来的数据时,原封不动的送回去。大概算是一个echo服务器。
private void onRead(SocketChannel socketChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
int count;
while ((count = socketChannel.read(buffer)) > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
socketChannel.write(buffer);
}
buffer.clear();
}
if (count < 0) {
socketChannel.close();
}
}
private void onAccept(SocketChannel channel) throws IOException {
System.out.println(channel.socket().getInetAddress() + "/" + channel.socket().getPort());
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
buffer.put("hello\n".getBytes());
buffer.flip();
channel.write(buffer);
}
从上面的代码可以看出:
onRead中的读操作是非阻塞的。在之前数据的网络传输已经完成了,这里只是处理传输完成的数据而已。
至于这里的写操作是不是阻塞的。。。我觉得不是阻塞的,这一点我还不确定 ,时间有限,之后会经过代码验证,查更多资料去确认这一点。
所有的读写操作的数据都需要经过Buffer。那为什么要增加Buffer这一抽象概念?直接使用bytes[]不挺好吗?
我猜测和NIO底层原理有关系,可能OS将数据传输到了操作系统原生的内存里,java使用的话复制到jvm内存中。我也不确定。。。 将来查更多资料去完善这一疑惑吧。
DEMO效果
上面通过一个小DEMO,也就是一个简单的ECHO服务器演示了NIO编程。下面来测试下结果:
frapples:~ ✔> nc -nvv 127.0.0.1 4040
Connection to 127.0.0.1 4040 port [tcp/*] succeeded!
hello
jfldjfl
jfldjfl
jfldjflieu
jfldjflieu
jfldhgldjfljdl
jfldhgldjfljdl
效果不错!不过这还没完。
尝试开启多个终端,同时连接服务器,你会惊讶的发现,服务器能够完美的同时和多个客户端连接而不会出现“卡死”的情况。
回顾刚才的小DEMO我们可以发现,刚才的DEMO是 单线程 的,但是通过多路复用模型,却能同时处理多个IO操作。
底层原理
硬件机制
之前在博文《异步IO和同步IO》中也提到了一些异步IO的操作系统机制。
非阻塞IO需要操作系统机制的支持,在linux系统上,对应的是select/poll系统调用或epoll系统调用。
操作系统的作用之一是对硬件设备的管理,我们发现,负责运算的部件CPU和负责网络传输的部件网卡,它们是互相独立的,因此,它们实际上可以同时执行任务。那么,底层硬件的支持使得完全可以做到以下步骤:
CPU发送给网卡某些网络IO操作请求,网卡接收到CPU接收到的请求。
网卡处理接收到的网络IO操作任务,于此同时,CPU也能执行其它的计算工作。
当网卡的网络IO操作完成后,通过硬件中断机制给CPU发中断。
CPU执行中断处理程序,执行IO操作完成后的逻辑。
这里有个小小的问题,在读取数据的时候,上面的步骤网卡读取数据时显然是不通过CPU的。以我个人有限的硬件知识推测,非阻塞IO的机制可能需要用到DMA。
仍然是个人推测,以后有时间去查阅相关资料去解决这个疑惑。
我们可以看到,硬件的运作方式天然就是异步的,也因此,操作系统也非常容易基于此进行抽象和封装,向上提供非阻塞的IO系统调用。
OS系统调用
linux操作系统的系统调用提供了多路复用的非阻塞IO的系统调用,这也是java NIO机制实现需要用到的。
在linux2.6之前,采用select/poll系统调用实现,而在linux2.6之后,采用epoll实现,使用红黑树优化过,也因此性能更高。
最后
本篇博文梳理的java的NIO机制,这是一种多路复用模型,能够使用一个线程去管理多个IO操作,避免传统同步IO的线程开销,大大提升性能。
从我个人的观点,评判一种模型是否易用,一方面来看该模型是否与实际的问题特点相契合;另外一方面,看该模型需要开发者花多少成本在模型本身上而非业务逻辑上。
从这个标准出发,我们也不难发现,本身异步IO的回调方式就够让开发者头疼的了,然而和异步IO相比,NIO比异步IO还要麻烦。
你需要花大量精力去时间去处理,去理解NIO本身的逻辑。因此,NIO的缺点是较高的开发成本和较晦涩的代码,不优雅。
NIO在SOA框架,RPC框架等服务器领域有着较大的应用,除了java标准库的NIO之外,这些实际生产的框架多使用第三方的NIO框架Netty。
原因之一是,java标准库的NIO有一个bug,可能造成CPU 100%的占用。