java NIO浅谈
NIO(Non-blocking I/O,在java领域,也称为New I/O),是一种同步非阻塞的I/O模型。也是I/O多路复用的基础,已经被越俩越多的应用到大型应用服务器,称为解决高并发和大量连接,I/O处理问题的有效方式。
传统BIO模型分析
bio经典编程模型
public static void main(String[] args) throws IOException {
// 线程池
ExecutorService executorService = ThreadUtil.newExecutor(100);
//新建一个socker服务器监听10086端口
ServerSocket serverSocket = new ServerSocket(10086);
// Thread.currentThread()获取当前线程==Thread isInterrupted是否中断
// 没有中断的话一直循环等待
while (!Thread.currentThread().isInterrupted()) {
// 新连接的创建 socket
Socket accept = serverSocket.accept();
//线程池 新建线程执行可以返回执行结果
executorService.submit(new ConnectIOnHandler(accept));
}
}
static class ConnectIOnHandler extends Thread {
private Socket socket;
public ConnectIOnHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()&&socket.isClosed()){
byte[] bytes = new byte[3];
try {
// 读出输入的东西
socket.getInputStream().read(bytes);
System.out.println(bytes);
// 响应回写的东西
socket.getOutputStream().write(bytes);
} catch (IOException e) {
e.printStackTrace();
}
}
super.run();
}
}
经典的每次连接新建线程监听 读写的模型,之所以使用多线程,主要原因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模型是无能为力的。
NIO是怎么工作的
常见I/O模型对比
所有系统I/O都分为两个阶段:等待就绪和操作。举例俩说,读函数,分为等待系统刻度和真正的读;同理,写函数分为等待网卡可以写和真正的写。
需要说明的是等待就是的阻塞式不适用CPU的,是在“空等”;而真正的读写操作的阻塞是使用CPU的,真正在“干活”,而这个过程非常,属于memory copy,带宽通常在1GB/S级别以上,可以理解为基本不耗时。
以socket.read()为例子:
传统BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,知道收到数据,返回读到的数据。
对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不堵塞
最新的AIO(Async I/O NIO2)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是一步的。
换句话说,BIO里用户最关心的“我要读”,NIO里用户最关心"我可以读了",在AIO模型里用户更需要关注的是“读完了”。
NIO 一个重要的特点是:socket主要的读,写,注册和接收函数,在等待就绪阶段都是非阻塞的,真的的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
如何结合实践模型使用NIO同步非阻塞特性
回忆BIO模型,之所以需要多线程,是因为在进行I/O操作的时候,一是没有办法知道到底能不能写,能不能读,只能“傻等”,技师通过各种估算,算出来操作系统没有能力进行读写,也没法在socket.read()和socket.white()函数中返回,这两个函数无法进行有效的中断。所以除了多开线程另起炉灶,没有好的方法利用CPU。
NIO的读写函数可以立刻返回,这就给了我们不开线程利用CPU的机会:如果一个连接不能读写(socket.read()返回0或者socket.white()返回0),就可以把这件事记下来,记录的方式通常是在Selector上注册标记为,然后切换到其他就绪的连接(channel)继续进行读写。
下面具体看下如何利用事件模型单线程处理所有I/O请求:
NIO的主要事件有几个:读就绪,写就绪,有新连接到来。
首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时间告诉时间选择器:我对这个事件感兴趣。对于写操作,就是泄不出去的时候对写时间感兴趣;对于读操作,就是完成连接和系统没有办法承载新读入的数据的时候:对于accept,一般是服务器刚启动的时候;而对于connect,一般是connect失败需要重连或者直接异步调用connect的时候。
其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll、2.6之后是epoll,windos是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记为,标示刻度,可写或者有连接到来。
selectshi9阻塞的,无论通过操作系统的通知(epoll)还是不停的轮询(slect,poll)这个函数式阻塞的,所以可以大胆的在一个while(true)里面调用这个函数而不用担心CPU空转
所以程序模型大概是
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//读,写或者连接
}
//IO线程主循环:
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//选择就绪的事件和对应的连接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
}
}
}
Map<Channel,ChannelHandler> handlerMa;//所有channel的对应事件处理器
}
简单的Reactor模式:注册所有感兴趣的时间处理器,单线程轮询选择就绪时间,执行时间处理器、
优化线程模型
NIO由原来的阻塞读写(占用线程)编程了单线程轮询事件,找到可以进行读写的网络描述符进行读写。除了时间的轮询是阻塞的(没有可干的事情必须阻塞),剩下的I/O操作都是纯CPU操作,没有必要开启多线程。
并且由于线程的节约,连接较大的时候因为线程切换带来的问题也随之解决,进而为处理海量连接提供了可能。
单线程处理I/O的效率确实非常高,没有线程切换,只是拼命的读,写,选择时间。但现在的服务器,一般都是多核处理器,如果能够利用多核心进行I/O,无疑对效率会有更大的提高。
分析一下所需要的线程,主要包括集中:
1.事件分发器,单线程选择就绪的时间。
2.I/O处理器,包括connect。read,write等,这种纯CPU操作,一般开启CPU核心线程就可以。
3.业务线程,在处理完I/O后,业务一般还会有自己的业务逻辑,有的还会有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要单独的线程
java的Selector对于Linux系统来说,有一个致命限制:同一个channel的select不能被并发的调用,因此,如果有多个I/O线程,必须保证:一个socket只能属于一个IOThread,而一个IOThread可以管理多个socket。
另外连接的处理和读写的处理通常可以选择分开,这样对于海量连接的注册和读写就可以分发,翠然read()和write()是比较高效无阻塞的函数,但毕竟会占用CPU,如果面对更高的并发则无能为力
NIO在客户端的处理
nio在服务端对于解放线程,优化I/O和处理海量连接方面,确实有自己的用武之地,在客户端上,使用场景
常见的客户端BIO+连接池模型,可以建立n个连接,然后当某一个连接被I/O占用的时候,可以使用其他连接来提高性能。
但多线程的模型面临和服务端相同的问题:如果指望增加连接数来提高性能,则连接数又受制于线程数、线程很贵、无法建立很多线程,则性能遇到瓶颈。
对于Redis来说,由于服务端是全局串行的,能够保证统一连接的所有请求与返回顺序一致。这样可以使用单线程+队列,把请求数据缓存。然后pipeline发送,返回future,然后channel可以时,直接在队列中吧future取回来,done()即可
class RedisClient Implements ChannelHandler{
private BlockingQueue CmdQueue;
private EventLoop eventLoop;
private Channel channel;
class Cmd{
String cmd;
Future result;
}
public Future get(String key){
Cmd cmd= new Cmd(key);
queue.offer(cmd);
eventLoop.submit(new Runnable(){
List list = new ArrayList();
queue.drainTo(list);
if(channel.isWritable()){
channel.writeAndFlush(list);
}
});
}
public void ChannelReadFinish(Channel channel,Buffer Buffer){
List result = handleBuffer();//处理数据
//从cmdQueue取出future,并设值,future.done();
}
public void ChannelWritable(Channel channel){
channel.flush();
}
}
这样做,能够充分利用pipeline来提高I/O能力,同时获取异步处理能力。
多连接,短连接的HttpClient
类似于竞对抓取的项目,往往需要建立无数的HTTP短连接,然后抓取,然后销毁,当需要单机抓取上千网站线程数又受限的时候,怎么保证性能呢?
用NIO,单线程进行连接,写,读操作,如果连接,读,写操作系统没有negligible处理,简单的注册一个时间,等待下次循环就好了。
如何存储不同的请求/响应呢?由于http是无状态没有版本的协议,又没有办法使用队列,好像办法不多。比较笨的办法是对于不同socket,直接存储socket的引用作为map的key。
常见的RPC框架,如Thrift,Dubbo
这种框架内部一般维护了请求的协议和请求号,可以维护一个以请求号为key,结果的result为future的map。结合NIO+长连接,获取非常不同的性能。
NIO高级主题
Proactor与Reactor
一般情况下,I/O复用机制需要事件分发器(event dispatcher)。事件分发器的作用,即将那些读写事件源分发给个读写事件的处理着,开发人员在开始的时候需要在分发器哪里注册感兴趣的时间,并提供相应的处理着(event handler),或者回调函数;时间分发器在适当的时候,会将请求的时间分发给这些handler或者回调函数。
设计到时间分发器的两种模式为Reactor(反应器)和Proactor(前摄器)。reactor模式是基于同步I/O的,而Proactor模式是和异步I/O相关的。在Reactor模式中,事件分发器等待某个事件或者可应用或个操作的装填发生(比如文件描述符可读写,或者socket可读写),时间分发器就把这个时间传给事先注册的时间处理函数或者回调函数,由后者来做实际的读写操作。
而在Proactor模式中,时间处理着(或者代由时间分发器发起)直接发起一个异步读写操作(详单与请求),而实际的工作是有操作系统来完成的,发起时,需要提供的参数包括用于存放读到数据的缓存区,读的数据大小或用于存放外发数据的缓存区,以及这个请求完后的回调函数等信息。时间分发器得知这个请求,他等待这个请求的完成,然后转发完成事件给响应的事件处理着或者回调,举例来说,在WIndows上时间处理着投递了一个异步IO操作(称为overlapped技术),时间分发器等IO Complete时间完成。这种异步模式的典型实现是基于操作系统底层异步API的,所以我们可称为“系统级别”的或者“真正意义上”的异步,因为具体的读写是由操作系统代劳的。
举例子,将有助于理解Reactor和Proactor两者之间的差异,已读操作为例
在Reactor中实现读
1.注册读就绪时间和响应的事件处理器
2.事件分发器等待时间。
3.事件到来,激动分发器,分发器调用事件对应的处理器。
4.时间处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。
在Proactor中实现读
1.处理器发起异步读操作(操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
2.事件分发器等待操作完成事件
3.在分发器等待过程中,操作系统利用并行的内核线程执行实际的读凑走,并将结果数据存入用户自定义缓冲区,最后通知时间分发器读操作完成。
4.时间分发器呼唤处理器。
5.时间处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分发器。
可以看出,两个模式的相同点,都是对某个I/O事件的事件通知(即告诉某个模块,这个I/O操作可以进行或已经完成)。在结构上,两者也有相同点:事件分发器负责提交IO操作(异步),查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示I/O操作已经完成;同步情况下(Reactor),回调handler时,表示I/O设备可以进行某个操作。
NIO存在问题
使用NIO!=高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。NIO并没有完全屏蔽凭条差异,它任然是基于各个操作系统的I/O系统实现的,差异仍然存在。使用NIO做网络编程构建时间驱动模型并不容易,陷阱重重。
推荐大家使用成熟的NIO框架,如Netty,MINA等。解决了很多NIO的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。
总结
最后总结一下到底NIO给我们带来了些什么:
事件驱动模型
避免多线程
单线程处理多任务
非阻塞I/O,I/O读写不再阻塞,而是返回0
基于block的传输,通常比基于流的传输更高效
更高级的IO函数,zero-copy
IO多路复用大大提高了Java网络应用的可伸缩性和实用性