无堵塞IO介绍
既然NIO相比于原来的IO在读取速度上其实并没有太大区别(因为NIO出来后,IO的低层已经以NIO为基础重新实现了),那么NIO的优点是什么呢?
NIO是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,而且已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
传统的IO模型
让我们先回忆一下传统的服务器端同步阻塞I/O处理(也就是BIO,
Blocking I/O)的经典编程模型:
ExecutorService executor = Excutors.newFixedThreadPollExecutor(100);//线程池
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(8088);
while(!Thread.currentThread.isInturrupted()){//主线程死循环等待新连接到来
Socket socket = serverSocket.accept();
executor.submit(new ConnectIOnHandler(socket));//为新的连接创建新的线程
}
class ConnectIOnHandler extends Thread{
private Socket socket;
public ConnectIOnHandler(Socket socket){
this.socket = socket;
}
public void run(){
while(!Thread.currentThread.isInturrupted()&&!socket.isClosed()){死循环处理读写事件
String someThing = socket.read()....//读取数据
if(someThing!=null){
......//处理数据
socket.write()....//写数据
}
}
}
这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。
其实这也是所有使用多线程的本质:
1、利用多核。
2、当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。
现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很"贵"的资源,主要表现在:
1、线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
2、线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
3、线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
4、容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
所以,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理模型。
NIO模型(Reactor轮询模式)
回忆BIO模型,之所以需要多线程,是因为在进行I/O操作的时候,一是没有办法知道到底能不能写、能不能读,只能"傻等",即使通过各种估算,算出来操作系统没有能力进行读写,也没法在socket.read()和socket.write()函数中返回,这两个函数无法进行有效的中断。所以除了多开线程另起炉灶,没有好的办法利用CPU。
NIO的读写函数可以立刻返回,这就给了我们不开线程利用CPU的最好机会:
如果一个连接不能读写(socket.read()返回0或者socket.write()返回0),我们可以把这件事记下来,记录的方式通常是在
Selector上注册标记位,然后切换到其它就绪的连接(channel)继续进行读写。
下面具体看下如何利用事件模型单线程处理所有I/O请求:
NIO的主要事件有几个:读就绪、写就绪、有新连接到来。
用一个死循环选择就绪的事件,会执行系统调用,还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。
注意,select是阻塞的,无论是通过操作系统的通知还是不停的轮询,这个函数是阻塞的。所以你可以放心大胆地在一个while(true)里面调用这个函数而不用担心CPU空转。
服务端代码:
package nio3;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NIOServer {
private int flag = 0;
private int BLOCK = 2048;
private ByteBuffer sendbuffer = ByteBuffer.allocate(BLOCK);
private ByteBuffer receivebuffer = ByteBuffer.allocate(BLOCK);
private Selector selector;
public NIOServer(int port) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(port));
selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("Server Start----8888:");
}
private void listen() throws IOException {
//轮询 事件驱动模式
while (true) {
// select()阻塞,等待有事件发生唤醒
selector.select();
Set selectionKeys = selector.selectedKeys();
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
//处理事件后,要移除
iterator.remove();
handleKey(selectionKey);
}
}
}
/**
* 处理不同事件
*/
private void handleKey(SelectionKey selectionKey) throws IOException {
ServerSocketChannel server = null;
SocketChannel client = null;
String receiveText;
String sendText;
int count=0;
//连接事件
if (selectionKey.isAcceptable()) {
server = (ServerSocketChannel) selectionKey.channel();
client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
//读取模式
} else if (selectionKey.isReadable()) {
client = (SocketChannel) selectionKey.channel();
receivebuffer.clear();
count = client.read(receivebuffer);
if (count > 0) {
receiveText = new String( receivebuffer.array(),0,count);
System.out.println("读取到:"+receiveText);
//注册对写的事件感兴趣
client.register(selector, SelectionKey.OP_WRITE);
}
//写模式
} else if (selectionKey.isWritable()) {
sendbuffer.clear();
client = (SocketChannel) selectionKey.channel();
sendText="message from server--" + flag++;
sendbuffer.put(sendText.getBytes());
sendbuffer.flip();
client.write(sendbuffer);
System.out.println("向客户端发送了"+sendText);
// client.register(selector, SelectionKey.OP_READ);
client.close();
}
}
public static void main(String[] args) throws IOException {
NIOServer server = new NIOServer(9000);
server.listen();
}
}
客户端代码(用普通的io写的):
package nio;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
public class MultiPortEcho2 {
public static void main(String[] args) throws Exception{
Socket socket = new Socket("localhost", 9000);
DataOutputStream sendStream = new DataOutputStream( socket.getOutputStream());
DataInputStream receiveStream = new DataInputStream( socket.getInputStream());
System.out.println(receiveStream.available());
sendStream.write("aasdaaa".getBytes());
int line=-1;
byte[] bytes=new byte[1024];
while((line=receiveStream.read(bytes))!=-1){
System.out.println(new String(bytes,0,line));
}
socket.close();
}
}
以上代码是用了一个线程来处理不同的事件,当然我们可以在接受到读事件或者写时间的时候开启多线程来进行读写的过程(在轮询的时候已经确定都或者写模式,因此在新开的线程中只是简单的io读写操作,不会堵塞)。
优化线程模型
单线程处理I/O的效率确实非常高,没有线程切换,只是拼命的读、写、选择事件。但现在的服务器,一般都是多核处理器,如果能够利用多核心进行I/O,无疑对效率会有更大的提高。
仔细分析一下我们需要的线程,其实主要包括以下几种:
1、事件分发器,单线程选择就绪的事件。
2、I/O处理器,包括connect、read、write等,这种纯CPU操作,一般开启CPU核心个线程就可以。
3、业务线程,在处理完I/O后,业务一般还会有自己的业务逻辑,有的还会有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要单独的线程。
另外连接的处理和读写的处理通常可以选择分开,这样对于海量连接的注册和读写就可以分发。虽然read()和write()是比较高效无阻塞的函数,但毕竟会占用CPU,如果面对更高的并发则无能为力。
参考资料:Java NIO浅析 来自:美团点评技术团队(微信号:meituantech)