Tomcat源码分析—BIO请求处理过程

Tomcat源码分析—BIO请求处理过程

这篇文章是我在2011年时写的,现转到我自己的博客上

NIO和BIO在设计的架构上,很多东西都差不多,下面从一些类图开始介绍,NIO的部分完全可以参照这个图,两者都是非常类似的。
首先看一下处理请求的核心类Http11Processor,和这个类关联的一些主要类图如下:
NIO和BIO在设计的架构上,很多东西都差不多,下面从一些类图开始介绍,NIO的部分完全可以参照这个图,两者都是非常类似的。
首先看一下处理请求的核心类Http11Processor,和这个类关联的一些主要类图如下:

Http11Processor关联了两个内部缓冲区,一个用来写响应,一个是读响应,NIO的模式和这里的一样,此外它还关联了Reponse和Request,这两个类是对请求的封装,不是规范类,在CoyoteAdapter里会将这两个类装换为规范的实现类。

下面是和请求相关的类

MimeHeaders是用来保存请求头的,此外还有一些内部缓冲区相关的内以及它们的接口。

下面是和请求响应相关的类:

其中最重要的类是OutputBuffer,这是内部写数据需要用到的类

最后来看一张请求处理的关系图:

从这里就可以看到Http11Processor相关的内部缓冲区,以及容器所使用的缓冲区,Request和Rsponse部分都是对应的。
值得注意的是,BIO的这套逻辑,当然对于NIO也是类似的逻辑,就是上面这个图,全部都是可以重用的,这是很重要的一点,由于每个对象都是可以清空,可以回收的,所以最大限度的提升了性能。它们的关联起始点可以从Http11Processor开始,这个类关联了CoyoteAdapter,也关联了非规范的Request和Response,还关联了两个内部缓冲区,对于到容器来说,CoyoteAdapter还关联了规范的实现Request和Response,这两个类也关联了一些内部缓冲区。所以可以看做是一颗倒着的树,Http11Processor是树的起点,而这个类本身又是可以被回收的。

 

现在步入正题,来看接收请求的过程,下面是时序图:

这里还包含了一些初始化的过程,Acceptor是阻塞的接收,当接收到请求后,会将JioEndpoint#processSocket(),将请求赋给Worker,这里实际上是一个生产者消费者的模式,工作线程的run()方法模式就是阻塞的,只有当请求线程赋给它之后它才会工作。

        synchronized void assign(Socket socket) {
            while (available) {
                try {
                    wait();
                } catch (InterruptedException e) {
                }
            }
            this.socket = socket;
            available = true;
            notifyAll();
        }

        private synchronized Socket await() {
            while (!available) {
                try {
                    wait();
                } catch (InterruptedException e) {
                }
            }
            Socket socket = this.socket;
            available = false;
            notifyAll();
            return (socket);
        }
                                                              

这就是它的等待通知,生产者消费者机制,有人说,接收线程在赋值的时候似乎没有必要再等待了,因为每次都是工作线程需要等待拿数据,如果没有数据就一直在那里等,接收线程在调用socket的接收方法时不是已经阻塞了吗,后面就是一个赋值过程,接收线程为什么在assgin()后,需要等待呢?我的理解是防止请求来的过快,因为第一次将socket赋给了工作线程,如果工作线程此时没有接收到,接收线程又赋了一个新socket给工作线程,那么上次的请求socket就等于是完全丢失了,所以这里两个人都需要加一个等待通知机制。
工作线程可以在线程池中运行,也可以由WorkerStack保存,如果没有设置线程池,就使用WorkerStack,WorkerStack会根据当前线程是否满了或者堆栈是空的来判断是返回一个工作线程还是新创建一个,当工作线程执行完后,会将自己返还给WorkerStack。
工作线程将请求交给Http11ConnectionHandler,这是Http11Protcol的内部类,它内部保存了一个并发队列,里面包含了若干个Http11Processor,这些Http11Processor在处理完后都会回收,请求会由Http11ConnectionHandler转给Http11Processor#process(),开始解析请求。

 

下面是请求解析的核心部分:

Http11Processor首先是解析请求行,再是解析请求头,解析请求行的工作交给InternalInputBuffer,这个类会触发一次真正的读socket。然后开始解析请求行,看代码会发现解析一个请求行怎么用那么多代码?是的,因为tomcat考虑的东西很多,像每次读取的时候可能都读到了buffer的最后一个字节了,此时就需要再次读取socket,就是说每解析一小步都可能触发真正的socket读动作。另外它再解析的时候分成很多步骤,首先是过滤掉前面的多虑空格,第二部才是解析请求方法比如GET或者POST,再是过滤空格,第四步是解析URL和URL后面的请求参数,再过滤空格,第六步是解析HTTP协议。
之后就是解析请求头了,请求头因为又很多所以在一个循环里做,同样每次解析都有可能触发真正的socket读,解析工作跟进“ : ”进行分割,先解析请头名字,再是值,最后将它们增加到MimeHeaders中,这个类保存了一个请求头数组,里面就包含了所有请求头。
解析完请求头会预解析一些请求头,比如编码类型,user-agent等请求头,同时还会解析主机,最后调用CoyoteAdapter#service()方法,service()方法里也解析了一些请求头,另外还解析了sessionID,它首先从URL里面获取sessionID,不管找到没有会再次从cookie中获取sessionID,如果又获取到了会将URL里的sessionID给覆盖,最后将请求交给容器。
Http11Processor还有一个重要的工作就是keep-alive,每调用一次CoyoteAdapter,就会将keep-alive减一,最后为0的时候退出循环,或者是超时的时候也会退出循环。Http11Processor最后会回收清空内部缓存区,以及Request和Response(都是非规范的 ),以便下次请求继续使用。

读取工作结束了,下面来看看发送数据的过程,BIO和NIO的发送逻辑大体上都是类似的,所以可以相互参考,下面我们已自定义Servlet和DefaultServlet为例,分别介绍设置响应头,将响应头写入缓存,响应头和响应体的合并,真正写数据。之后再介绍两个特殊情况,servlet或者jsp调用flush()主动刷新的过程,以及发送一个超大数据是如何处理的?

 

先来看看设置响应头的时序图:

这个过程,NIO和BIO完全一样,最后都是将响应头设置到MimeHeaders上

再来看看响应头写入缓存的时序图:

写响应的出发点在OutputBuffer,这是容器内部的缓冲区,它会调用自身的doFlush(),这里可以分为三步,先是些入响应头,再写入响应体,最后一起写到客户端。写响应头的过程会有一个回调的事件机制触发,这是由Response调用的,它调用到了 Http11Processor,这个类先预先处理了一些响应头,比如是不是满足压缩的条件,如果是的话,就加入一个压缩的过滤器,压缩过滤器将内容写到byte[]中,最后将这个byte[]发送,这就达到了压缩的效果。响应头最后会被写入到InternalOutputBuffer内部的一个byte[]数组中。

 

下面来看看响应头和响应体的合并过程:

由于容器内部的缓存区都是由OutputBuffer管理的,所以写数据也是由它管理的,它内部保存了两个ByteChunk,这个类相当于byte[]的扩展吧,这两个ByteChunk,一个是内部使用,一个是真正写数据的时候交给写响应的连接器使用。OutputBuffer将内部缓存的数据拷贝到外部缓存,其实就是byte[]引用变了一下,没有真正拷贝,最后调用Response(非规范)写数据,Response则会将数据写入到InternalOutputBuffer中。InternalOutputBuffer负责将数据写入到自身内部的一个ByteChunk中,在写入之前会执行一些过滤器,比如前面说的压缩过滤器,如果配置了,此时就会执行,执行到过滤器的最后有一个OutputStreamOutputBuffer,这个类是InternalOutputBuffer的内部类,由它负责协调写数据岛缓冲区,它调用InternalOutputBuffer内部引用的ByteChunk#append(),
经过这一系列的调用后,会将InternalOutputBuffer内部的数组内容合并到ByteChunk中,也就是将响应头和响应体合并。

 

下面来看看最终写数据的过程:

其实前面我们说的那几部,少了上面时序图的CoyteAdapter,写响应是在所有的valve执行完后返还给CoyoteAdapter,由它结束请求,调用OutputBuffer,将请求的内容写入客户端。者会触发一个回调事件,最后由Http11Processor接收这个时间,它调用和自身关联的InternalOutputBuffer,InternalOutputBuffer负责将自身内部的ByteChunk中的内容通过Socket写入到客户端。
最后service()处理结束后会将Request(规范)和Response(规范)的内部资源清空
而Http11Processor#process()处理结束后会将InternalOutputBuffer和InternalInputBuffer的内部资源清空
Http11ConnectionHandler处理完后将Http11Processor对象回收,Http11Processor内部还包含了Request和Reponse

到此为止,一个正常的写数据到客户端的过程就结束了,下面看两个特殊情况, servlet或者JSP主动flsuh()的过程,以及超大数据是如何发送的。

 

首先是主动flush()的过程:

servlet调用CoyteWrite或者CoyteOutputStream的flush(),这会触发OutputBuffer#flush(),它又会调用自身的doFlush(),这个方法就是前面正常写数据时调用的doFlush(),只是这次传来的参数是true,它表示会主动的刷新数据到客户端,所以前面两部将响应头写入缓冲区和将响应头响应体合并跟前面时序图执行的过程是一样的,这里就省略了,实际OutputBuffer最后会触发一个回调事件,调用Http11Processor,是由这个回调事件引发了真正写数据的过程。最后Http11Processor调用InternalOutputBuffer将数据真正写到客户端。

 

最后来看一下超大的数据是如何发送的:

这里以DefaultServlet为例,发送一个超大数据,DefaultServlet内部有两个参数,输入缓冲区大小和发送缓冲区大小。默认两个都是2048,输入缓冲区是用来创建指定的byte[]的,当读取一个文件时,就是用这个指定的byte[]读取,也就是输入缓冲区大小,输入缓冲区是设置到OutputBuffer上的,OutputBuffer 再设置到它内部的ByteChunk上。这里要说明一下,OutputBuffer 是由于CoyteAdapter创建的,创建的OutputBuffer会是用一个默认的值再创建ByteChunk,这个值就是8192,ByteChunk有一个极限值,默认是-1表示没有上限,此时创建了ByteChunk后会将它的极限设置为8192,DefaultServlet设置了输出缓冲区大小必须大于8192才能真正设置上,所以默认的2048实际上不起作用。
DefaultServlet调用自己的copyRange(),将读取到的文件写入到OutputBuffer中,而OutputBuffer此时会主动将所有的内容都写入到InternalOutputBuffer内部的ByteChunk中,经过几次写之后,ByteChunk#append()会发现空间不够了,因为极限就是8192,所有会调用InternalOutputBuffer执行一次真实的写数据,将数据写到客户端,所以超大数据发送的核心就是每次写入OutputBuffer的数据最终都会写入到InternalOutputBuffer的ByteChunk中,而这个ByteChunk是有极限的,当超过了极限它就会调用InternalOutputBuffer执行一次真正的写操作。
这里有一点不明白的事,响应头是在什么地方设置的,我调试的时候从头到尾都是写缓冲区的过程,只有DefalutServlet里面有设置响应头的,但那是设置到MimeHeaders,响应头应该是设置到InternalOutputBuffer内部的数组中才对,但没有看到这个设置过程。

BIO的请求过程我觉得最核心的就是InternalInputBuffer和InternalOutputer以及容器的缓冲区,要知道容器使用了一个缓冲区OutputBuffer,它管理了一个对内的ByteChunk和一个对外的ByteChunk,而每次真正写数据的时候又使用了一个ByteChunk。

 

13 次阅读

发表评论

电子邮件地址不会被公开。