本篇文章将介绍HTTP异步请求机制。
1 引言
Spring MVC has an extensive integration with Servlet 3.0 asynchronous request processing.DeferredResult and Callable return values in controller methods and provide basic support for a single asynchronous return value.
上面这段描述来自Spring官方文档Web on Servlet Stack。大概意思是说:Spring MVC可以支持对Servlet 3.0+的异步请求(asynchronous request)的处理,DeferredResult和Callable支持从controller方法返回异步数据。
那什么是异步请求呢?为何Servlet规范会在3.0版本中引入异步请求?原先的同步处理机制是有什么缺陷吗?
下面我们来尝试回答这个问题。
2 异步请求的处理
我们先通过一个样例直观感受下在Spring Web MVC框架下,一个可以处理异步请求的controller具体该是什么样子的。
@RestController
public class MyController {
@RequestMapping("/async")
public DeferredResult<String> sayHello() {
DeferredResult<String> deferredResult = new DeferredResult<>();
Thread worker = new Thread(new AsyncRunner(deferredResult));
worker.start();
return deferredResult;
}
static class AsyncRunner implements Runnable {
private DeferredResult<String> deferredResult = null;
public AsyncRunner(DeferredResult<String> deferredResult) {
this.deferredResult = deferredResult;
}
@Override
public void run() {
this.deferredResult.setResult("This is a message created by another thread.");
}
}
}
在上面所示代码中,我们创建一个名为MyController
的类。它有一个映射到/async
地址上的名为sayHello()
的方法。该方法返回一个DeferredResult<String>
类型的对象。
在方法内部,我们创建了需要返回的对象,并将该对象传递给了一个AsyncRunner
类型的线程。通过worker.start()
方法启动该线程后,程序就直接将deferredResult
返回了。
这段程序的逻辑是典型的异步多线程处理模式。主线程完成相关参数的创建和实例化之后,创建子线程并将相关数据交给子线程处理。主线程在启动完子线程后,就不再关注其处理状态,直接返回了。
启动工程后,通过浏览器访问http://localhost:8080/async会得到如下图结果:
异步请求访问结果
从浏览器端看,同步调用和异步调用似乎没有任何区别。其实,哪怕我们通过抓包工具查看网络请求的情况,我们也看不出任何同步请求和异步请求之间的差异。
那具体的差异在哪里呢?
3 关于异步请求
异步请求和我们平时理解的异步调用不同。异步请求并不是说浏览器提交请求给服务器,服务器在收到请求后返回浏览器一个已接收的响应,待服务器完成处理后再将处理结果推送给浏览器。这个理解是完全错误的。
异步请求的处理过程依然是一次请求,一次响应,并不存在服务器返回浏览器已接收的状态。从底层连接来看,浏览器在发送给服务器HTTP请求后,TCP连接会一直处于阻塞状态,一直等到服务器端将最终结果返回给浏览器,TCP连接才会断开。
简单来说,从浏览器端来看,无论是同步请求,还是异步请求,交互流程是完全一样的,没有任何差别!
那差异在哪里呢?差异主要在服务器端。
3.1 Tomcat底层机制
我们先来看下Servlet底层是如何接收并处理HTTP请求的:
protected class Acceptor extends AbstractEndpoint.Acceptor {
@Override
public void run() {
// Loop until we receive a shutdown command
while (running) {
...
try {
...
// Configure the socket
if (running && !paused && setSocketOptions(socket)) {
// Hand this socket off to an appropriate processor
if (!processSocket(socket)) {
...
}
}
...
} catch (IOException x) {
...
}
}
state = AcceptorState.ENDED;
}
}
Acceptor
是JIoEndpoint
的内部类,它通过run()
方法监听来自socket的网络请求,并调用processSocket(socket)
方法进行处理。
public class JIoEndpoint extends AbstractEndpoint {
protected boolean processSocket(Socket socket) {
// Process the request from this socket
try {
...
getExecutor().execute(new org.apache.tomcat.util.net.JIoEndpoint.SocketProcessor(wrapper));
} catch (RejectedExecutionException x) {
...
} catch (Throwable t) {
...
}
return true;
}
}
JIoEndpoint
的processSocket(Socket socket)
调用getExecutor()
得到一个ThreadPoolExecutor
类型的executor对象。
public class JIoEndpoint extends AbstractEndpoint {
@Override
public void startInternal() throws Exception {
if (!running) {
...
if (getExecutor() == null) {
createExecutor();
}
}
}
public void createExecutor() {
...
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), 60, TimeUnit.SECONDS,taskqueue, tf);
...
}
}
JIoEndpoint
在初始化时,会调用startInternal()
方法,该方法最终会调用createExecutor()
方法创建一个类型为ThreadPoolExecutor
的executor对象。默认情况下,getMinSpareThreads()
返回10, getMaxThreads()
返回200。
也就是说,Tomcat默认会有10个常驻线程用于处理HTTP请求。当并发数超过10时,Tomcat开始创建新的线程处理额外的并发请求。当并发数超过200时,则线程池满,Tomcat无法再接收更多的请求了。
当客户端在一段时间内大量调用服务端长耗时的服务时(比如影像上传),很容易出现大量的连接无法释放的情况。这种场景下,Tomcat底层用于处理socket连接的线程池会很快达到200的并发上限。而一旦达到这个上限,则意味着服务器开始拒绝所有的请求。哪怕新提交的请求只需要在很短时间内就能完成,它也无法得到被处理的机会。
3.2 异步请求的机制
Servlet 3.0定义的异步请求,实际上要解决的便是Servlet容器处理socket连接的线程池的容量问题。
异步请求场景下,Tomcat本身在将request标记为异步后,就会释放当前处理线程,但依然会保持socket会话的连接状态。当服务器中实际处理业务逻辑的线程完成处理后,则通过response向socket中写入数据,这样便能通过socket消息唤醒Web容器,Web容器此时再重新分配线程来执行后续处理。通过这样的机制,便能降低对处理socket连接的线程池中线程的消耗量,也就增大了Web应用的整体吞吐量和TPS了。
4 总结
所以,Servlet 3.0中定义的异步请求并不等同于我们常说的异步调用。它的交互仍然是一次性的,浏览器端的请求在接收到服务器端的最终结果前会一直处于阻塞状态。引入异步请求机制的目的,是为了解决服务端不同响应时效的服务间存在的不公平竞争的问题。它能保证响应快的服务不受响应慢的服务影响,即使大量响应慢的服务被调用,响应快的服务依然有机会得到处理。