Edward_jie

for you, my Hall of Frame

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::
  91 随笔 :: 42 文章 :: 538 评论 :: 0 引用

同步/异步

  侧重点在于是否两线并行,在做一件事a的时候,可以先让这件事a自己进行,自己去做另外一件事b,a进行到一定程度的时候通知到自己,自己在根据事情a的通知去处理接下来的事情。

阻塞/非阻塞

  侧重点在于在做其中一件事a的时候,能否暂时暂停a去做另外一件事b,a事情会在那等待,自己在做事情b的时候经常回来看看事情a是否已经到了满足一定条件的时候,满足的话那么继续接下来的事情,不满足那么继续回去干事情b,事情a继续等待条件满足的时候。

IO关乎同步异步的概念,socket处理关乎阻塞非阻塞的概念。

BIO一个连接一个线程,不管有没有实际的事情在处理,socket在accept、read、write的时候都在等待,占用着线程

Java对BIO、NIO、AIO的支持:

  • Java BIO : 同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。

  • Java NIO : 同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

  • Java AIO(NIO.2) : 异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,

NIO是一个有效请求一个线程,即socket有可读取的数据时会使用线程,没有可用数据时,是不占用线程资源的

NIO的概念是非阻塞IO,其关注点在于“阻塞”二字,也就是我在处理一件事情的时候,能否暂时放下去处理另一件事,所以解决了在read、accept、write的时候线程不用一直等待而去处理其他的任务,非阻塞是指在处理socket缓冲区相关数据的时候是非阻塞的,而IO的时候还是同步的

而AIO概念是异步IO,关注点在于“异步”二字,是线程在到IO的时候,不用等待,可以去做其他的事情,IO继续IO相关的处理,两部并行,IO结束后通知线程回来继续接下来的任务处理

 

关于阻塞和非阻塞

不管是文件IO还是网络IO,会阻塞的根本原因在于应用程序的用户空间和操作系统的内核空间的数据互相拷贝

文件IO

读取:需要将操作系统内核空间将数据准备好拷贝给应用程序的用户空间
写入:需要将应用程序的用户空间将数据准备好拷贝给操作系统内核空间

网络IO

接收网络请求:网络--》网卡--》内核空间--》用户空间
发送网络请求:用户空间--》内核空间--》网卡--》网络

阻塞IO

用户线程必须等待用户空间和内核空间之间的互相拷贝完成才能执行下一步

非阻塞IO

用户线程把请求提交给用户空间后不需要一直等着用户空间和内核空间完成互相拷贝完,它可以继续执行,当用户空间和内核空间完成互相拷贝之后通知用户线程获取数据

BIO与NIO区别

BIO通常不知道什么时候能够读写,并且读写线程不能共用,只能傻等,最好的方式就是起线程另起炉灶,但在linux中线程本质是进程创建、销毁线程的代价很高昂,如果线程数量过高,会导致线程切换的时间高于线程执行时间,带来的现象就是系统的load偏高导致系统几乎不可用,容易造成锯齿状的系统负载,因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
NIO本质上是通过统一的Selector管理(通过注册需要的读写,从channel通道中轮询获取请求)达到单线程就能处理不同的读写操作,从而达到减少线程创建、销毁的数量,增加cpu的有效利用率(实际执行IO读写,而不是线程切换)

NIO与AIO的区别

从编程模式上来看AIO相对于NIO的区别在于,NIO需要使用者线程不停的使用Selector轮询所有的IO对象,来确定是否有数据准备好可以读了,而AIO则是在数据准备好之后,才会通知数据使用者(专门的Selector线程来轮询),这样使用者就不需要用Selector线程不停地轮询了。当然AIO的异步特性并不是Java实现的伪异步,而是使用了系统底层API的支持,在Unix系统下,采用了epoll,IO模型,而windows便是使用了IOCP模型

不同平台下采用轮询的方式

Linux下NIO、AIO采用Linux的epoll来轮询,windows下NIO、AIO采用IOCP的形式轮询

 

参考:https://segmentfault.com/a/1190000020402420?utm_source=tag-newest

 

NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。

那么NIO的本质是什么样的呢?它是怎样与事件模型结合来解放线程、提高系统吞吐的呢?

本文会先从传统的阻塞I/O和线程池模型面临的问题讲起,然后对比几种常见I/O模型,一步步分析NIO怎么利用事件模型处理I/O,解决线程池瓶颈处理海量连接,包括利用面向事件的方式编写服务端/客户端程序。最后延展到一些高级主题,如Reactor与Proactor模型的对比、Selector的唤醒、Buffer的选择等。

注:本文的代码都是伪代码,主要是为了示意,不可用于生产环境。

 

传统BIO模型分析

让我们先回忆一下传统的服务器端同步阻塞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是怎么工作的

很多刚接触NIO的人,第一眼看到的就是Java相对晦涩的API,比如:Channel,Selector,Socket什么的;然后就是一坨上百行的代码来演示NIO的服务端Demo……瞬间头大有没有?

我们不管这些,抛开现象看本质,先分析下NIO是怎么工作的。

1.常见I/O模型对比

所有的系统I/O都分为两个阶段:等待就绪和操作。举例来说,读函数,分为等待系统可读和真正的读;同理,写函数分为等待网卡可以写和真正的写。

需要说明的是等待就绪的阻塞是不使用CPU的,是在“空等”;而真正的读写操作的阻塞是使用CPU的,真正在"干活",而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,可以理解为基本不耗时。

下图是几种常见I/O模型的对比:

以socket.read()为例子:

  • 传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。
  • 对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。
  • 最新的AIO(Async I/O)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是异步的。
  • 换句话说,BIO里用户最关心“我要读”,NIO里用户最关心"我可以读了",在AIO模型里用户更需要关注的是“读完了”。
  • NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。

2.如何结合事件模型使用NIO同步非阻塞特性

下面具体看下如何利用事件模型单线程处理所有I/O请求:

NIO的主要事件有几个:

  • 读就绪
  • 写就绪
  • 有新连接到来

我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。对于写操作,就是写不出去的时候对写事件感兴趣;对于读操作,就是完成连接和系统没有办法承载新读入的数据的时;对于accept,一般是服务器刚启动的时候;而对于connect,一般是connect失败需要重连或者直接异步调用connect的时候。

其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。

注意,select是阻塞的,无论是通过操作系统的通知(epoll)还是不停的轮询(select,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> handlerMap;//所有channel的对应事件处理器 
}

这个程序很简短,也是最简单的Reactor模式:注册所有感兴趣的事件处理器,单线程轮询选择就绪事件,执行事件处理器。

3.优化线程模型

由上面的示例我们大概可以总结出NIO是怎么解决掉线程的瓶颈并处理海量连接的:

NIO由原来的阻塞读写(占用线程)变成了单线程轮询事件,找到可以进行读写的网络描述符进行读写。除了事件的轮询是阻塞的(没有可干的事情必须要阻塞),剩余的I/O操作都是纯CPU操作,没有必要开启多线程。

并且由于线程的节约,连接数大的时候因为线程切换带来的问题也随之解决,进而为处理海量连接提供了可能。

单线程处理I/O的效率确实非常高,没有线程切换,只是拼命的读、写、选择事件。但现在的服务器,一般都是多核处理器,如果能够利用多核心进行I/O,无疑对效率会有更大的提高。

仔细分析一下我们需要的线程,其实主要包括以下几种:

  • 事件分发器,单线程选择就绪的事件。
  • I/O处理器,包括connect、read、write等,这种纯CPU操作,一般开启CPU核心个线程就可以。
  • 业务线程,在处理完I/O后,业务一般还会有自己的业务逻辑,有的还会有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要单独的线程。

Java的Selector对于Linux系统来说,有一个致命限制:同一个channel的select不能被并发的调用。因此,如果有多个I/O线程,必须保证:一个socket只能属于一个IoThread,而一个IoThread可以管理多个socket。

另外连接的处理和读写的处理通常可以选择分开,这样对于海量连接的注册和读写就可以分发。虽然read()和write()是比较高效无阻塞的函数,但毕竟会占用CPU,如果面对更高的并发则无能为力。

 

NIO在客户端的魔力

通过上面的分析,可以看出NIO在服务端对于解放线程,优化I/O和处理海量连接方面,确实有自己的用武之地。

1.NIO又有什么使用场景呢?

常见的客户端BIO+连接池模型,可以建立n个连接,然后当某一个连接被I/O占用的时候,可以使用其他连接来提高性能。

但多线程的模型面临和服务端相同的问题:如果指望增加连接数来提高性能,则连接数又受制于线程数、线程很贵、无法建立很多线程,则性能遇到瓶颈。

每连接顺序请求的Redis

对于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能力,同时获取异步处理能力。

3.多连接短连接的HttpClient

类似于竞对抓取的项目,往往需要建立无数的HTTP短连接,然后抓取,然后销毁,当需要单机抓取上千网站线程数又受制的时候,怎么保证性能呢?

何不尝试NIO,单线程进行连接、写、读操作?如果连接、读、写操作系统没有能力处理,简单的注册一个事件,等待下次循环就好了。

如何存储不同的请求/响应呢?由于http是无状态没有版本的协议,又没有办法使用队列,好像办法不多。比较笨的办法是对于不同的socket,直接存储socket的引用作为map的key。

4.常见的RPC框架,如Thrift,Dubbo

这种框架内部一般维护了请求的协议和请求号,可以维护一个以请求号为key,结果的result为future的map,结合NIO+长连接,获取非常不错的性能。

 

NIO高级主题

1.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的,所以我们可称之为“系统级别”的或者“真正意义上”的异步,因为具体的读写是由操作系统代劳的。

2.Buffer的选择

对于NIO来说,缓存的使用可以使用DirectByteBuffer和HeapByteBuffer。如果使用了DirectByteBuffer,一般来说可以减少一次系统空间到用户空间的拷贝。但Buffer创建和销毁的成本更高,更不宜维护,通常会用内存池来提高性能。

如果数据量比较小的中小应用情况下,可以考虑使用heapBuffer;反之可以用directBuffer。

 

NIO存在的问题

使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。

NIO并没有完全屏蔽平台差异,它仍然是基于各个操作系统的I/O系统实现的,差异仍然存在。使用NIO做网络编程构建事件驱动模型并不容易,陷阱重重。

推荐大家使用成熟的 NIO框架:如Netty,MINA等 ,解决了很多NIO的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。

如果想免费学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java后端技术群:479499375,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

 

总结

最后总结一下到底NIO给我们带来了些什么:

  • 事件驱动模型
  • 避免多线程
  • 单线程处理多任务
  • 非阻塞I/O,I/O读写不再阻塞,而是返回0
  • 基于block的传输,通常比基于流的传输更高效
  • 更高级的IO函数,zero-copy
  • IO多路复用大大提高了Java网络应用的可伸缩性和实用性

参考:http://blog.itpub.net/31545684/viewspace-2375320/

posted on 2020-03-26 14:56  Edward_诺  阅读(...)  评论(...编辑  收藏