当前位置: 首页 > 工具软件 > Grizzly > 使用案例 >

grizzly精要

邓仲卿
2023-12-01
NIO的框架玩的不少,mina, jproactor, 不久前也耍了耍Grizzly. Grizzly的主开发是Jean-Francois Arcand, 挺厉害的. 从此人blog中可见一斑. 以下是其的blog链接: http://weblogs.java.net/blog/jfarcand/archive/2005/06/grizzly_an_http.html Grizzlly grizzly的core封装了nio调用, 基本上全是对通用操作,模型的wrapper, 比如pipeline对应线程池, ConnectorHandler对连接事件处理, 玩了些优化手段如多选择器, 缓存连接等. 通常我们说grizzly指的是加入了http模块之后的http-specified的grizzly.当前也主要用于构建http容器. jean本人也吹嘘grizzly性能如何如何, 无意间甩出了好些NO1的基准测试结果. 姑且信之一二. 但这个玩意终究没有绝对的, 特定的模型适合于特定的场景. 但把grizzly作为一个best practise来研究总不为过的,毕竟高手出招, 再烂也顶你一打菜鸟. 基本的东西可以自行去查阅资料, 我这里所有东西都只侧重于记录个人意识所认为的重点. Grizzly的几个重要东西: Pipeline, SelectorThread, Task. 1.Pipeline 在com.sun.enterprise.web.connector.grizzly下,有许多与Pipelien相关的类,如Pipeline, KeepAlivePipeline, ThreadPoolExecutorPipeline, LinkedListPipeline等. Pipeline在这里是对线程池和任务队列的一个封装. LinkedListPipeline的性能目前被测出是最好的, ThreadPoolExecutorPipeline也是比较猛的.不过这些玩意都是不同种类Pipeline的选择,可根据情况选. KeepAlivePipeline是一个特例,它并不是来执行特定任务的,而是用来维护HTTP协议中的持久连接的状态,例如维护最大的持久连接数,持久连接timeout等.另外,异步的socketChannel中却一个类似socket.setSoTimeout的函数,这个函数在保证服务器软件的可靠性和安全性(抗DOS攻击)上,具有重要的作用.Grizzly用KeepAlivePipeline来模拟socket.setSoTimeout的作用. 2.SelectorThread 这是Grizzly的入口类,位于com.sun.enterprise.web.connector.grizzly包中.在SelectorThread中,SocketChannel和Selector被创建并初始化.当网络有请求进来时,Selector会根据不同的请求类型和NIO的不同事件进行不同的处理.     当NIO事件为OP_READ时,表明是原有连接中有新的请求数据过来了.这类请求属于ReadTask,应该交给负责处理ReadTask的处理器来处理.ReadTask有自己的Pipeline来处理,显然不会占用主线程来处理Read请求.     当NIO事件为OP_ACCEPT时,表明有新的请求进来了,这类请求属于AcceptTask,交给负责处理AcceptTask的处理器来处理.在老版GlassFish中, AcceptTask也有自己的Pipeline来处理,这样就让AcceptTask在主线程以外的线程中执行.但是经过多次性能测试和比较,发现AcceptTask在主线程中执行的时候,性能最好.因此,新的grizzly中主线程直接处理OP_READ事件,在handleAccept()方法中. 当ReadTask执行完之后,表明整个请求的数据已经完全接收到,就可以进行请求处理了,请求处理属于ProcessTask,交给负责处理ProcessTask的处理器来处理.ProcessTask有自己的Pipeline来处理. 3.Task Grizzly默认包含下面几种Task. 1)AcceptTask: 用于响应新的连接请求.实际上,这个任务的类根本没有抽象出来,因为没必要. 2)ProcessTask: 用于处理并响应请求.这个任务通常是对请求数据进行解析,解析完成后再将请求传递给其他服务的容器进行真正的业务处理. 3)ReadTask: 用于SocketChannel最初的读取操作.由于Nio是非阻塞的,最初的读取往往不能获得全部的请求数据,这时候,ReadTask会将任务委托给StreamAlgorithm,根据不同实现,用不同的方法将剩下的请求数据获取. 在com.sun.enterprise.web.connector.grizzly.algorithms包中,grizzly默认实现了4种算法: ContentLengthAlgorithm SeekHeaderAlgorithm StateMachineAlgorithm NoParsingAlgorithm 前3个算法主要围绕HTTP请求中的Content-Length字段来进行解析.只要能读到这个字段的值,那么我们就可以预先判断整个请求的长度,从而确定什么时候完成请求读取,接着进行请求处理了.第4个算法是对请求数据根本不进行预处理,假设所有的数据都读进来了.如果最后发现请求数据读得不对,再交给请求处理任务(ProcessTask)来负责将剩下的内容读取进来. Grizzly的几个关键特点 这几点是非常非常重要的, 光这几点我觉得可以抵一本专门讲网络编程的书的价钱了. 1.如何处理慢速连接 现存的很多NIO框架很少有对OP_WRITE的处理.通常看到的代码都是在请求处理完成之后,直接通过如下代码将结果返回给客户端: while(bb.hasRemaining) {   int len = socketChannel.write(bb);   if(len < 0) {     throw new EOFException();   } } 这样在大多数情况下都没有什么问题.但是在客户端的网络环境很糟糕的情况下,服务器会遭到很沉重的打击. 因为如果客户端的网络或是中间交换机问题使得网络传输的效率很低,这时服务器已经准备好的返回结果无法通过 TCP/IP层传输到客户端.此时在执行上面代码时就会出现以下情况: 1) bb.hasRemaining()一直为true,因为服务器的返回结果已经准备好了. 2) socketChannel.write(bb)的结果一直为0,因为由于网络原因数据一直传不过去. 3)因为是异步非阻塞方式, socketChannel.write(bb)不会被阻塞. 所以在一段时间内, 这段代码会被无休止地快速运行,消耗大量的CPU资源. 这样的结果显然不好.因此,我们对OP_WRITE也应该加以处理.在NIO中最常用的方法如下: while(bb.hasRemaining()) {   int len = socketChannel.write(bb);   if (len<0) {     throw new EOFException();   }   if(len == 0){     selectionKey.interestOps(selectionKey.interestOps() | SelectionKey.OP_WRITE);     mainSelector.wakeup();     break;   } } 上面的程序在网络不好的时候,将此频道的OP_WRITE操作注册到Selector上,这样,当网络恢复,在频道可以继续将结果数据返回客户端的时候,Selector会通过SelectionKey来通知应用程序,再去执行写操作.这样就能节约大量的CPU资源,使得服务器能适应各种恶劣的网络环境. 不过, grizzly中对OP_WRITE的处理并不是这样的.我们可以看看它的源码.在Grizzly中,对请求结果的返回是在ProcessTask中处理的,经过SocketChannelOutputBuffer类,最终通过OutputWriter来完成返回结果的动作.在OutputWriter中处理OP_WRITE的代码如下: public static long flushChannel(SocketChannel socketChannel, ByteBuffer bb, long writeTimeout) throws IOException {     SelectionKey key = null;     Selector writeSelector = null;     int attempts = 0;     int bytesProduced = 0;     try {         while (bb.hasRemaining()) {             int len = socketChannel.write(bb);             ++attempts;             if(len<0){               throw new EOFException();             }                      bytesProduced += len;             if(len == 0) {                 if(writeSelector == null) {                     writeSelector = SelectorFactory.getSelector();                     if(writeSelector == null) {                         //continue using the main one                         continue;                     }                 }                 key = socketChannel.register(writeSelector, key.OP_WRITE);                 if(writeSelector.select(writeTimeout) == 0){                     if(attempts >2) {                         throw new IOException("Client disconnected");                 } else {                     --attempts;                 }             } else {                 attempts = 0;             }         }     } finally {         if(key!=null) {             key.cancel();             key = null;         }         if(writeSelector !=null){             //Cancel the key.             writeSelector.selecteNow();             SelectorFactory.returnSelector(writeSelector);         }     }     return bytesProduced; } 上面的程序与之前的区别在于: 当发现由于网络情况而导致的发送数据受阻(len==0)时, 之前的处理是将当前的频道注册到当前的Selector中; 而在grizzly中, 程序从SelectionFactory中获得了一个临时的Selector.在获得这个临时的 Selector之后,程序做了一个阻塞的操作: writeSelector.select(writeTimeout).这个阻塞操作会在一定时间内(writeTimeout)等待这个频道的发送状态.如果等待时间过长,便认为当前的客户端连异常中断了.     这种实现方式颇受争议.有很多开发者质疑grizzly的作者为什么不使用之前的模式.另外在实际处理中, Grizzly的处理方式事实上放弃了NIO中的非阻塞优势,使用writeSelector.select(writeTimeout)做了个阻塞操作.虽然CPU资源没有浪费,可以线程资源在阻塞时间内,被这个请求所占有,不能释放给其它请求来使用. Grizzly的作者对此回应如下: 1) 使用临时的Selector, 目的是减少线程间的切换.当前的Selector一般用来处理OP_ACCEPT和OP_READ操作.使用临时的Selector可减轻主Selector的负担;而在注册的时候则需要进行线程切换,会引起不必要的系统调用.这种方式避免了线程之间的频繁切换,有利于系统的性能提高. 2) 虽然writeSelector.select(writeTimeout)做了阻塞操作,但是这种情况只是少数极端的环境下才会发生.大多数客户端是不会频繁出现这种现象的,因此在同一时刻被阻塞的线程不会太多. 3) 利用这个阻塞操作来判断异常中断的客户连接. 4) 经过压力测试证明这种实现的性能很好. 如何避免内存泄露 在NIO框架中,值得注意的是java.nio.channel.SelectionKey.attach(), 由于NIO非阻塞的特点其使用比较频繁. 这是因为在非阻塞频道中,在socketChannel.read(byteBuffer)调用中,往往不能返回所有的请求数据,其他的部分数据可能要在下一次(或几次)读取中才能完全返回.因此在读取一些数据之后,需要将当前的频道重新注册到Selector上: selectionKey.interestOps(selectionKey.interestOps() | SelectionKey.OP_READ()); 这样还不够,因为前几次读取的部分数据也需要保留, 将所有数据综合起来才能构成一个完整的请求, 因此需要调用下面的函数将部分数据保存起来供以后使用: selectionKey.attach(..) 这个函数的设计目的也在于此,主要用于异步非阻塞的情况保存恢复与频道相关的数据.但是,这个函数非常容易造成内存泄露.这是因为在非阻塞情况下,你无法保证这个带有附件的SelectionKey什么时候再次返回到准备好的状态.在一些特殊的情况下(客户端断电或者网络问题)导致代表这些连接的SelectionKey永远也不会返回到准备好的状态, 而一直存放在Selector中,它们所带的附件也不会被gc掉.下面看grizzly是如何处理这种问题的. 事实上,grizzly的实现中很少看到selectionKey.attach(...)的代码.在入口程序SelectThread中的enableSelectionKeys() 方法中有attch方法的调用: public void enableSelectionKeys() {     SelectionKey selectionKey;     int size = keysToEnable.size();     long currentTime = System.currentTimeMillis();     for(int i=0;i<size;++i) {         selectionKey = keysToEnable.poll();         selectionKey.interestOps( selectionKey.interestOps() | SelectionKey.OP_READ);         if(selectionKey.attachment() == null) {             selectionKey.attach(currentTime);             keepAlivePipeline.trap(selectionKey);         }     } } 显而易见,这个函数的目的是要给每个selectionKey加上一个时间戳.这个时间戳是为KeepAlive系统而加的.怎样防止这个long类型对象的内存泄露呢?(:-)是否有点夸张) 在SelectThread的doSelect()方法中有一个expireIdleKeys()的调用.在每次doSelect()调用中,expireIdleKeys都会被执行一次,来查看selector中每个SelectionKey,将它们的时间戳与当前的时间对比,判断是否当前的SelectionKey很长时间没响应了,然后根据配置的timeout时间,强行将其释放和回收.   那么系统用来存放每一次请求读取的数据放在哪里了呢?一般来说这个存放频道数据的对象应该是ByteBuffer.在 DefaultReadTask中,可以看到ByteBuffer的使用情况. public void doTask() throws IOException {   if(byteBuffer == null) {     WorkerThread workerThread = (WorkerThread)Thread.currentThread();     byteBuffer = workerThread.getByteBuffer();     if(workerThread.getByteBuffer() == null) {         byteBuffer = algorithm.allocate(useDirectByteBuffer, useByteBufferView, selectorThread.getBufferSize());         workerThread.setByteBuffer(byteBuffer);     }   }   doTask(byteBuffer); } 上面的方法透露出两个重要信息: * 对ByteBuffer的分配, 并不是每个SelectionKey(或者说每个网络连接)都有自己的ByteBuffer,而是每个工作线程拥有一个ByteBuffer. * ByteBuffer的分配也不是新创建的ByteBuffer对象,而是通过ByteBufferView来对原有的ByteBuffer对象进行重新分割.原因是新建一个ByteBuffer对象的系统开销比较大,因此Grizzly在启动的时候初始创建了一个大的ByteBuffer对象.以后每个线程再需要ByteBuffer对象的时候,就通过ByteBufferView来在原有ByteBuffer之上创建一个视图,这样的性能要好很多. 如果说每个线程只使用一个ByteBuffer对象(确切的说是ByteBufferView对象),而在NIO中,每个线程是要服务于多个连接请求的,那么线程是怎样维护每个连接请求的数据独立性的呢?从DefaultReadTask中的 doTask(ByteBuffer byteBuffer)方法中,我们可以看到最初始的读取调用以及对读取数据的处理过程. protected void doTask(ByteBuffer byteBuffer) {   int count = 0;   Socket socket = null   SocketChannel socketChannel = null;   boolean keepAlive = false;   Exception exception = null;   key.attach(null);   try {     socketChannel = (SocketChannel) key.channel();     socket = socketChannel.socket();     algorithm.setSocketChannel(socketChannel);     int loop = 0;     int bufferSize = 0;     while (socketChannel.isOpen() && (bytesAvailable || ((count=socketChannel.read(byteBuffer))>-1))) {[1]       ...       byteBuffer = algorithm.preParse(byteBuffer);       inputStream.setByteBuffer(byteBuffer);[2]       inputStream.setSelectionKey(key);       //try to predict which HTTP method we are processing       if(algorithm.parse(byteBuffer)){[3]         keepAlive = executeProcessorTask();[4]         if(!keepAlive) {           break;         }       }     }   } ... } 从上可看出,此方法作了初始的读取动作socketChannel.read(byteBuffer).初始读取完后,其实并不知道是否所有的请求数据都已经读取进来了.接着这个请求就交给executeProcessorTask()去执行了.在executeProcessorTask()中使用了一个ByteBufferInputStream类,这个类是对ByteBuffer的一个封装,并在[2]中进行了设置和初始化.事实上, 在默认的解析算法中,客户端的请求在第一次读取动作中如果没有全部完成,那剩余部分的数据其实就交给ByteBufferInputStream来完成了. ByteBufferInputStream实际上在它的doTask方法中接手了数据读取工作, 并且采用的是阻塞读取方式:(哈哈, 有点奇怪吧).具体是这样的: 当读取数据的任务交给ByteBufferInputStream的时候, ByteBufferInputStream会再做一次最大的努力来读取可能有的数据.如果还是没有读取到什么数据的话,grizzly并没有将SelectionKey重新注册到主线程的Selector,而是从Selector池中获得一个临时的Selector,将SelectionKey重新注册到这个临时的Selector中. 接着这个临时的Selector做了一个阻塞的操作readSelector.select(readTimeout),这个动作一直会阻塞到当前频道有数据进来,或者阻塞时间超过Timeout的时间.     这种算法也颇受争议.有的人认为使用阻塞的模式性能不会比NIO中非阻塞的模式好,特别是在有很多网络速度很慢的客户端的情况下,这样会大量造成线程的占用而变得不具有很好的可扩展性.     grizzly的作者也承认,如果在大量慢速的客户端的情况的,使用非阻塞模式肯定要好些,但是他为自己的实现算法也给了下面一些理由: 1) 假设大多数客户端的速度良好是合理的.因此大多数的请求数据在一到两次都能全部读取. 2) 读连接异常的客户端可以在最早时间范围内进行判断和做出放弃的决定,保护系统的资源不被浪费. 3) 这样实现没有内存泄露的问题,而且内存消耗也要小些,能够获得更好的性能. 4) 因为每个连接所有的读取过程都在一个线程中完成,不用在主线程之间切换,可以减少操作系统的线程调度负担,并减少主线程的负担.
 类似资料: