AsyncContext介绍

Servlet 3.0的异步处理支持特性,使Servlet 线程不再需要一直阻塞,直到业务处理完毕才能再输出响应,最后才结束该 Servlet 线程。在接收到请求之后,Servlet 线程可以将耗时的操作委派给另一个线~程来完成,自己在不生成响应的情况下返回至 容器。针对业务处理较耗时的情况,这将大大减少 服务器资源的占用,并且提高并发处理速度 Servlet 3.0新增了异步处理,可以先释放容器分配给请求的线程与相关资源,减轻系统负担,原先释放了容器所分配线程的请求,其响应将被延后,可以在处理完成(例如长时间运算完成、所需资源已获得)时再对客户端进行响应。

Servlet 3.0 开始提供了AsyncContext用来支持异步处理请求,那么异步处理请求到底能够带来哪些好处?

        Web容器一般来说处理请求的方式是:为每个request分配一个thread。我们都知道thread的创建不是没有代价的,Web容器的thread pool都是有上限的。 那么一个很容易预见的问题就是,在高负载情况下,thread pool都被占着了,那么后续的request就只能等待,如果运气不好客户端会报等待超时的错误。 在AsyncContext出现之前,解决这个问题的唯一办法就是扩充Web容器的thread pool。

但是这样依然有一个问题,考虑以下场景:

        有一个web容器,线程池大小200。有一个web app,它有两个servlet,Servlet-A处理单个请求的时间是10s,Servlet-B处理单个请求的时间是1s。 现在遇到了高负载,有超过200个request到Servlet-A,如果这个时候请求Servlet-B就会等待,因为所有HTTP thread都已经被Servlet-A占用了。 这个时候工程师发现了问题,扩展了线程池大小到400,但是负载依然持续走高,现在有400个request到Servlet-A,Servlet-B依然无法响应。

        看到问题了没有,因为HTTP thread和Worker thread耦合在了一起(就是同一个thread),所以导致了当大量request到一个耗时操作时,就会将HTTP thread占满,导致整个Web容器就会无法响应。但是如果使用AsyncContext,我们就可以将耗时的操作交给另一个thread去做,这样HTTP thread就被释放出来了,可以去处理其他请求了。

Http协议是单向的,只能客户端自己拉不能服务器主动推,Servlet对异步请求的支持并没有修改Http,而是对Http的巧妙利用。异步请求的核心原理主要分为两大类,一类是轮询,另一类是长连接

Servlet对异步请求的支持其实采用的是长连接的方式,也就是说,异步请求中在原始的请求返回的时候并没有关闭连接,关闭的只是处理请求的那个线程,只有在异步请求全部处理完之后才会关闭连接。

下面是一个官方的例子:

@WebServlet(urlPatterns={"/asyncservlet"}, asyncSupported=true)
public class AsyncServlet extends HttpServlet {
   /* ... Same variables and init method as in SyncServlet ... */

   @Override
   public void doGet(HttpServletRequest request, 
                     HttpServletResponse response) {
      response.setContentType("text/html;charset=UTF-8");
      final AsyncContext acontext = request.startAsync();
      acontext.start(new Runnable() {
         public void run() {
            String param = acontext.getRequest().getParameter("param");
            String result = resource.process(param);
            HttpServletResponse response = acontext.getResponse();
            /* ... print to the response ... */
            acontext.complete();
            }
      });
   }
}

1.startAsync()会直接利用原有的请求与响应对象来创建AsyncContext 2.startAsync(ServletRequest request,ServletResponse response)可以传入自行创建的请求、响应封装对象;

可以通过AsyncContext的getRequest()、getResponse()方法取得请求、响应对象。

此次对客户端的响应将暂缓至调用AsyncContext的complete()或dispatch()方法为止,前者表示响应完成,后者表示将调派指定的URL进行响应。

陷阱

在这个官方例子里,每个HTTP thread都会开启另一个Worker thread来处理请求,然后把HTTP thread就归还给Web容器。但是看AsyncContext.start()方法的javadoc:

Causes the container to dispatch a thread, possibly from a managed thread pool, to run the specified Runnable.

实际上这里并没有规定Worker thread到底从哪里来,也许是HTTP thread pool之外的另一个thread pool?还是说就是HTTP thread pool?

The Limited Usefulness of AsyncContext.start()文章里写道:不同的Web容器对此有不同的实现,不过Tomcat实际上是利用HTTP thread pool来处理AsyncContext.start()的。

这也就是说,我们原本是想释放HTTP thread的,但实际上并没有,因为有HTTP thread依然被用作Worker thread,只不过这个thread和接收请求的HTTP thread不是同一个而已。

这个结论我们也可以通过AsyncServlet1和SyncServlet的Jmeter benchmark看出来,两者的throughput结果差不多。启动方法:启动Main,然后利用Jmeter启动benchmark.jmx(Tomcat默认配置下HTTP thread pool=200)。

使用ExecutorService

前面看到了Tomcat并没有单独维护Worker thread pool,那么我们就得自己想办法搞一个,见AsyncServlet2,它使用了一个带Thread pool的ExecutorService来处理AsyncContext。

其他方式

所以对于AsyncContext的使用并没有固定的方式,你可以根据实际需要去采用不同的方式来处理,为此你需要一点Java concurrent programming的知识。

对于性能的误解

AsyncContext的目的并不是为了提高性能,也并不直接提供性能提升,它提供了把HTTP thread和Worker thread解藕的机制,从而提高Web容器的响应能力。

不过AsyncContext在某些时候的确能够提高性能,但这个取决于你的代码是怎么写的。 比如:Web容器的HTTP thread pool数量200,某个Servlet使用一个300的Worker thread pool来处理AsyncContext。 相比Sync方式Worker thread pool=HTTP thread pool=200,在这种情况下我们有了300的Worker thread pool,所以肯定能够带来一些性能上的提升(毕竟干活的人多了)。

相反,如果当Worker thread的数量<=HTTP thread数量的时候,那么就不会得到性能提升,因为此时处理请求的瓶颈在Worker thread。

你可以修改AsyncServlet2的线程池大小,把它和SyncServlet比较benchmark结果来验证这一结论。

一定不要认为Worker thread pool必须比HTTP thread pool大,理由如下:

  1. 两者职责不同,一个是Web容器用来接收外来请求,一个是处理业务逻辑
  2. thread的创建是有代价的,如果HTTP thread pool已经很大了再搞一个更大的Worker thread pool反而会造成过多的Context switch和内存开销
  3. AsyncContext的目的是将HTTP thread释放出来,避免被操作长期占用进而导致Web容器无法响应

所以在更多时候,Worker thread pool不会很大,而且会根据不同业务构建不同的Worker thread pool。 比如:Web容器thread pool大小200,一个慢速Servlet的Worker thread pool大小10,这样一来,无论有多少请求到慢速操作,它都不会将HTTP thread占满导致其他请求无法处理。

Nacos利用此机制:

Nacos采用长轮训机制来实现数据变更的同步,原理如下!

public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
        Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {
    
    // 判断当前请求是否支持长轮训。()
    if (LongPollingService.isSupportLongPolling(request)) {
        longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
        return HttpServletResponse.SC_OK + "";
    }
    
    //如果是短轮询,走下面的请求,下面的请求就是把客户端传过来的数据和服务端的数据逐项进行比较,保存到changeGroups中。
    // Compatible with short polling logic.
    List<String> changedGroups = MD5Util.compareMd5(request, response, clientMd5Map);
    
    // Compatible with short polling result.
    String oldResult = MD5Util.compareMd5OldResult(changedGroups);
    String newResult = MD5Util.compareMd5ResultString(changedGroups);
    
    String version = request.getHeader(Constants.CLIENT_VERSION_HEADER);
    if (version == null) {
        version = "2.0.0";
    }
    int versionNum = Protocol.getVersionNumber(version);
    
    // Before 2.0.4 version, return value is put into header.
    if (versionNum < START_LONG_POLLING_VERSION_NUM) {
        response.addHeader(Constants.PROBE_MODIFY_RESPONSE, oldResult);
        response.addHeader(Constants.PROBE_MODIFY_RESPONSE_NEW, newResult);
    } else {
        request.setAttribute("content", newResult);
    }
    
    Loggers.AUTH.info("new content:" + newResult);
    
    // Disable cache.
    response.setHeader("Pragma", "no-cache");
    response.setDateHeader("Expires", 0);
    response.setHeader("Cache-Control", "no-cache,no-store");
    response.setStatus(HttpServletResponse.SC_OK);
    return HttpServletResponse.SC_OK + "";
}

addLongPollingClient#

把客户端的请求,保存到长轮训的执行引擎中。

public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
        int probeRequestSize) {
    //获取客户端长轮训的超时时间
    String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER); 
    //不允许断开的标记
    String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
    //应用名称
    String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
    //
    String tag = req.getHeader("Vipserver-Tag");
    //延期时间,默认为500ms
    int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);

    // Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout.
    // 提前500ms返回一个响应,避免客户端出现超时
    long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
    if (isFixedPolling()) {
        timeout = Math.max(10000, getFixedPollingInterval());
        // Do nothing but set fix polling timeout.
    } else {
        long start = System.currentTimeMillis();
        //通过md5判断客户端请求过来的key是否有和服务器端有不一致的,如果有,则保存到changedGroups中。
        List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
        if (changedGroups.size() > 0) { //如果发现有变更,则直接把请求返回给客户端
            generateResponse(req, rsp, changedGroups);
            LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant",
                    RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                    changedGroups.size());
            return;
        } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) { //如果noHangUpFlag为true,说明不需要挂起客户端,所以直接返回。
            LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
                    RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                    changedGroups.size());
            return;
        }
    }
    //获取请求端的ip
    String ip = RequestUtil.getRemoteIp(req);

    // Must be called by http thread, or send response.
    //把当前请求转化为一个异步请求(意味着此时tomcat线程被释放,也就是客户端的请求,需要通过asyncContext来手动触发返回,否则一直挂起)
    final AsyncContext asyncContext = req.startAsync();
    // AsyncContext.setTimeout() is incorrect, Control by oneself
    asyncContext.setTimeout(0L); //设置异步请求超时时间,
    //执行长轮训请求
    ConfigExecutor.executeLongPolling(
            new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}

ClientLongPolling#

接下来我们来分析一下,clientLongPolling到底做了什么操作。或者说我们可以先猜测一下应该会做什么事情

  • 这个任务要阻塞29.5s才能执行,因为立马执行没有任何意义,毕竟前面已经执行过一次了
  • 如果在29.5s+之内,数据发生变化,需要提前通知。需要有一种监控机制

基于这些猜想,我们可以看看它的实现过程

从代码粗粒度来看,它的实现似乎和我们的猜想一致,在run方法中,通过scheduler.schedule实现了一个定时任务,它的delay时间正好是前面计算的29.5s。在这个任务中,会通过MD5Util.compareMd5来进行计算

那另外一个,当数据发生变化以后,肯定不能等到29.5s之后才通知呀,那怎么办呢?我们发现有一个allSubs的东西,它似乎和发布订阅有关系。那是不是有可能当前的clientLongPolling订阅了数据变化的事件呢?

class ClientLongPolling implements Runnable {

    @Override
    public void run() {
        //构建一个异步任务,延后29.5s执行
        asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() {
            @Override
            public void run() { //如果达到29.5s,说明这个期间没有做任何配置修改,则自动触发执行
                try {
                    getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());

                    // Delete subsciber's relations.
                    allSubs.remove(ClientLongPolling.this); //移除订阅关系

                    if (isFixedPolling()) { //如果是固定间隔的长轮训
                        LogUtil.CLIENT_LOG
                                .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "fix",
                                        RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
                                        "polling", clientMd5Map.size(), probeRequestSize);
                        //比较变更的key
                        List<String> changedGroups = MD5Util
                                .compareMd5((HttpServletRequest) asyncContext.getRequest(),
                                        (HttpServletResponse) asyncContext.getResponse(), clientMd5Map);
                        if (changedGroups.size() > 0) {//如果大于0,表示有变更,直接响应
                            sendResponse(changedGroups);
                        } else {
                            sendResponse(null); //否则返回null
                        }
                    } else {
                        LogUtil.CLIENT_LOG
                                .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "timeout",
                                        RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
                                        "polling", clientMd5Map.size(), probeRequestSize);
                        sendResponse(null);
                    }
                } catch (Throwable t) {
                    LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause());
                }

            }

        }, timeoutTime, TimeUnit.MILLISECONDS);

        allSubs.add(this);  //把当前线程添加到订阅事件队列中
    }
}

llSubs#

allSubs是一个队列,队列里面放了ClientLongPolling这个对象。这个队列似乎和配置变更有某种关联关系。

那么这里必须要实现的是,当用户在nacos 控制台修改了配置之后,必须要从这个订阅关系中取出关注的客户端长连接,然后把变更的结果返回。于是我们去看LongPollingService的构造方法查找订阅关系

/**
 * 长轮询订阅关系
 */
final Queue<ClientLongPolling> allSubs;

allSubs.add(this);

LongPollingService#

在LongPollingService的构造方法中,使用了一个NotifyCenter订阅了一个事件,其中不难发现,如果这个事件的实例是LocalDataChangeEvent,也就是服务端数据发生变更的时间,就会执行一个DataChangeTask的线程。

public LongPollingService() {
    allSubs = new ConcurrentLinkedQueue<ClientLongPolling>();

    ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);

    // Register LocalDataChangeEvent to NotifyCenter.
    NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);

    //注册LocalDataChangeEvent订阅事件
    NotifyCenter.registerSubscriber(new Subscriber() {

        @Override
        public void onEvent(Event event) {
            if (isFixedPolling()) {
                // Ignore.
            } else {
                if (event instanceof LocalDataChangeEvent) { //如果触发了LocalDataChangeEvent,则执行下面的代码
                    LocalDataChangeEvent evt = (LocalDataChangeEvent) event;
                    ConfigExecutor.executeLongPolling(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps));
                }
            }
        }

        @Override
        public Class<? extends Event> subscribeType() {
            return LocalDataChangeEvent.class;
        }
    });

}

LongPollingService#

在LongPollingService的构造方法中,使用了一个NotifyCenter订阅了一个事件,其中不难发现,如果这个事件的实例是LocalDataChangeEvent,也就是服务端数据发生变更的时间,就会执行一个DataChangeTask的线程。

public LongPollingService() {
    allSubs = new ConcurrentLinkedQueue<ClientLongPolling>();

    ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);

    // Register LocalDataChangeEvent to NotifyCenter.
    NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);

    //注册LocalDataChangeEvent订阅事件
    NotifyCenter.registerSubscriber(new Subscriber() {

        @Override
        public void onEvent(Event event) {
            if (isFixedPolling()) {
                // Ignore.
            } else {
                if (event instanceof LocalDataChangeEvent) { //如果触发了LocalDataChangeEvent,则执行下面的代码
                    LocalDataChangeEvent evt = (LocalDataChangeEvent) event;
                    ConfigExecutor.executeLongPolling(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps));
                }
            }
        }

        @Override
        public Class<? extends Event> subscribeType() {
            return LocalDataChangeEvent.class;
        }
    });

}

DataChangeTask#

数据变更事件线程,代码如下

class DataChangeTask implements Runnable {

    @Override
    public void run() {
        try {
            ConfigCacheService.getContentBetaMd5(groupKey); //
            //遍历所有订阅事件表
            for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
                ClientLongPolling clientSub = iter.next(); //得到ClientLongPolling
                //判断当前的ClientLongPolling中,请求的key是否包含当前修改的groupKey
                if (clientSub.clientMd5Map.containsKey(groupKey)) {
                    // If published tag is not in the beta list, then it skipped.
                    if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) { //如果是beta方式且betaIps不包含当前客户端ip,直接返回
                        continue;
                    }

                    // If published tag is not in the tag list, then it skipped.
                    if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {//如果配置了tag标签且不包含当前客户端的tag,直接返回
                        continue;
                    }
					//
                    getRetainIps().put(clientSub.ip, System.currentTimeMillis());
                    iter.remove(); // Delete subscribers' relationships. 移除当前客户端的订阅关系
                    LogUtil.CLIENT_LOG
                            .info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance",
                                    RequestUtil
                                            .getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()),
                                    "polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey);
                    clientSub.sendResponse(Arrays.asList(groupKey)); //响应客户端请求。
                }
            }
        } catch (Throwable t) {
            LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));
        }
    }
}

参考:详解Nacos 配置中心客户端配置缓存动态更新的源码实现