Apache HttpAsyncClient 源码解析

起因

618 当天有客户反馈交易成功异步通知延迟很高,部分单子延后了半个小时。经排查发现,异步通知队列消息堆积严重大,底层原因是,通知是 BIO,当时部分客户响应速度高达 10 秒,直接导致通知消息消费线程堵塞,消息只进不出。

所以引入异步通知框架 apache http asynclient。这个框架底层使用 java 的 nio 实现(ps:NIO 其实并不是 None Block IO,而是 New IO,底层实现还是阻塞的)。

引入这个框架能解决什么问题呢?使用 Reactor 线程模型,解放用户线程堵塞占用。利用 事件+异步回调机制,不会出现一个线程在处理 HTTP IO 事件时整个被卡主的情况。

本文源码分析基于:

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpasyncclient</artifactId>
    <version>4.1.4</version>
</dependency>
复制代码

这个框架跟我们一直在用的 httputil 有什么区别呢
传统的 httputil 同步阻塞 的,意味着用户线程进来,只要对面不响应,该线程就永远干不了其他的事情。
除了将 同步改为异步 外,这个框架还引入了 http 连接池 的概念(可以联想一下数据库连接池),注意和线程池别搞混。 在我们实际场景中,只要是使用了 http 协议与第三方系统进行交互的都可以使用这个框架来提高吞吐和并发。

用法

目前项目中错误的用法

Http 连接池这个概念并不是 async 框架新引入的,在我们项目中一直用的同步 http 框架中就包含。大部分情况我们都用错了。 CloseableHttpClient httpclient 这个是个大对象,初始化很多的东西,其中还包含一个 http 连接池,不是每次请求都去 new 一个实例

// 错误示范
public static String post(String url, String xmlData) throws Exception {
    try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
        // do something...
    }
}
复制代码

一个正确的 Demo

public class Demo {

    // 单例 全局唯一
    private static CloseableHttpAsyncClient asyncClient = null;
    {
        // 1 个 pool-1-thread-1 线程 + n 个(CPU 核心线程数) I/O dispatcher 线程
        IOReactorConfig reactorConfig = IOReactorConfig.custom()
                // .setIoThreadCount(3)
                .setConnectTimeout(3 * 1000) // 默连接超时时间
                .setSoTimeout(5000).build(); // 默认读取超时时间

        asyncClient = HttpAsyncClientBuilder.create()
                .setDefaultIOReactorConfig(reactorConfig)
                .setMaxConnTotal(1000) // 连接池总大小 1000
                .setMaxConnPerRoute(20) // 单个 Route 并发最高 20
                .build();

        // 单独设置每个 Host 最大并发数
//        PoolingNHttpClientConnectionManager manager = new PoolingNHttpClientConnectionManager();
//        manager.setDefaultMaxPerRoute(100);
//        manager.setMaxTotal(500);
//        manager.setMaxPerRoute(new HttpRoute(new HttpHost("https://www.baidu.com", 443)), 200);

        asyncClient.start();

        // 工程停止时,调用 close 方法
    }

    public static void main(String[] args) throws Exception {
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectTimeout(1000) // 单次请求连接超时时间
                .setConnectionRequestTimeout(2000) // 请求 http 连接释放超时时间
                .setSocketTimeout(10 * 1000) // 单次请求读取超时时间
                .build();

        String url = "http://127.0.0.1:8087/open/internal?s=200&e=500";
        String data = "hello, server!";

        StringEntity stringEntity = new StringEntity(URLEncoder.encode(data, Consts.UTF_8.name()), Consts.UTF_8);
        stringEntity.setContentEncoding(Consts.UTF_8.name());

        HttpPost request = new HttpPost(url);
        request.setConfig(requestConfig);
        request.setEntity(stringEntity);

        for (int i =0; i<1; i++) {
            String no = System.currentTimeMillis() + "";
            log.info("单号:{} 请求", no);
            Future<HttpResponse> future = asyncClient.execute(request, new MyFutureCallback(no));

            future.get();
        }

        Thread.sleep(1000 * 1000);
    }
}
复制代码

几个重要的参数需要注意:

  • connectTimeout:建立 http 连接超时时间
  • soTimeout:http readTimeout 读取超时间
  • connectionRequestTimeout:从池中获取 http 连接超时时间
  • maxConnTotal:http 连接池总大小
  • maxConnPerRoute:单个 route 最大连接数(ps:route = host:port)

异步回调处理类如下所示。不用单独写释放语句,在回调 completed 方法之前框架已经自动归还连接。当出现异常时,比如连接超时、读取超时,框架会回调 fail 方法,常见的异常已枚举。

public class MyFutureCallback implements FutureCallback<HttpResponse> {
    private String orderNo;
    public MyFutureCallback(String orderNo) {
        this.orderNo = orderNo;
    }

    @Override
    public void completed(HttpResponse response) {
        try {
            String content = EntityUtils.toString(response.getEntity(), "utf-8");
            log.info("响应报文:{}", content);
        }
        catch (Exception e) {
            log.error("", e);
        }
    }

    @Override
    public void failed(Exception ex) {
        // java.net.SocketTimeoutException: 5,000 milliseconds timeout on connection http-outgoing-477 [ACTIVE]
        if (ex instanceof java.net.SocketTimeoutException) {
            log.warn("HTTP 响应读取超时");
        }
        // java.util.concurrent.TimeoutException: Connection lease request time out
        else if (ex instanceof java.util.concurrent.TimeoutException) {
            log.warn("请求 HTTP 连接池释放连接超时");
        }
        // java.net.ConnectException: Timeout connecting to [/172.18.12.78:22]
        else if (ex instanceof java.net.ConnectException) {
            log.warn("HTTP 连接超时");
        }
        else {
            log.error("其他 IO 异常,不进行重新通知", ex);
        }
    }

    @Override
    public void cancelled() {

    }
}
复制代码

异步编程模型中涉及到了几个线程,要了解工作原理必须从不同的线程角度入手,明白这几个线程分别干了什么事情。这里分成四个方面来讲:框架启动时、用户请求、Reactor 线程、Worker 线程

项目启动时

  1. HttpAsyncClientBuilder#build 调用 new InternalHttpAsyncClient 在其父类 CloseableHttpAsyncClientBase 构造函数中创建了 reactorThread 实例 image.png

  2. HttpAsyncClientBuilder#start 方法中,reactorThread 线程启动,至此 启动线程释放,继续下面的流程。由 reactorThread 线程执行框架内部启动流程。

  3. 调用 NHttpClientConnectionManager#execute 方法,内部调用 AbstractMultiworkerIOReactor#execute 方法

  4. AbstractMultiworkerIOReactor#execute 内部创建指定数量的 worker 线程,并 for 循环调用启动,所以 worker 线程是由 reactor 线程创建并启动的 image.png

  5. 启动 worker 线程后,该线程进入无限循环逻辑,阻塞在 Selector#select 方法上,直到有相应的事件到来或者被唤醒 image.png

  6. 再来看 worker 线程,内部类 Worker#run 方法调用 BaseIOReactor#execute,其内部也是无限循环,阻塞在 Selector#select 方法上 至此,框架内部的线程都已初始化并启动成功

分析可知:AbstractMultiworkerIOReactor 类就是我们常说的 Reactor,BaseIOReactor 类就是 Worker

用户请求时

整体交互图

image.png

当用户调用 CloseableHttpAsyncClient#execute 方法,其调用栈如下:

  1. CloseableHttpAsyncClient#execute
  2. InternalHttpAsyncClient#execute image.png
  3. DefaultClientExchangeHandlerImpl#start
  4. AbstractClientExchangeHandler#requestConnection,回调参数 NHttpClientConnection,这里命名为回调 2 image.png
  5. PoolingNHttpClientConnectionManager#requestConnection,回调参数 CPoolEntry,这里命名为回调 1 image.png
  6. AbstractNIOConnPool#lease image.png
  7. AbstractNIOConnPool#processPendingRequest

这里有两个回调需要注意。当成功获取到连接后,首先被回调的是 回调1,然后才是回调 2,层层依次回调。最后 processPendingRequest 方法就是从连接池中获取连接的逻辑。大致逻辑如下:

  1. 判断当前的请求是否 请求释放 http 连接超时(就是上文提到的:connectionRequestTimeout 参数)
  2. 迭代 route 对应的 pool(后面称之为 routePool) 的 available 链表,意图拿到一个空闲连接。
  3. 如果存在空闲连接直接返回,判断该连接是否已经被关闭或者是否过期,任意条件满足时,关闭这个连接并回收。如果两个检查条件都通过,则加入到 完成队列(completedRequests,后续调用 fireCallbacks() 方法迭代该队列,依次触发上文传入的回调方法 1 和 2。并在回调方法 2 中,通过调用 IOSessionImpl#setEvent 触发写事件,并唤醒 worker 线程
  4. 如果不存在空闲连接,说明需要新建连接。
    1. 根据 maxPerRoute 参数和当前已分配队列大小,收缩 routePool 连接池大小。接着进行两个判断。
    2. 判断一:再次判断 maxPerRoute 值和已分配队列大小,如果后者大于前者,加入 leasingRequests 队列(等待获取 http 连接队列)
    3. 判断二:判断 整个连接池容量和整个已分配连接数量的大小,如果超出最大容量,则加入 leasingRequests 队列
    4. 如果能通过这两个判断则说明 可以新建连接。调用 DefaultConnectingIOReactor#connect 将新建连接请求加入到 Reactor 类成员变量 requestQueue 队列中,并唤醒 Reactor 线程
  5. 至此,用户线程在框架内的流程结束

在第 3 步中,对连接的过期检查就是这个框架对连接的淘汰策略,并 没有单独的线程去主动淘汰过期连接。是在用户线程获取连接时,进行过期判断|关闭判断,若是连接不可用,则移除。

重要的几个队列

AbstractNIOConnPool#processPendingRequest 方法中我们还需要注意,用了 粗体 标注的是流程中涉及到的比较重要的队列,其中涉及到了线程之间的交互,一个一个来看。

先来看 AbstractNIOConnPool#completedRequests 队列,它是 AbstractNIOConnPool 的成员变量且是单例所以是 线程间全局共享。简单理解该队列就是 AbstractNIOConnPoolprocessPendingRequest()fireCallbacks() 方法之间 数据传递的媒介,前者从 http 连接池里面拿出可用连接,成功则放入队列,后者消费这个队列,拿着可用连接去触发写事件,属于同一个线程之间的通信

当用户线程发现 http 连接池满时,会加入到 AbstractNIOConnPool#leasingRequests 队列中。那什么时候会拿出来进行重试呢?答案是在某一个连接用完归还到连接池时,就会调用 processNextPendingRequest 方法将等待中的 请求拿出来重试,那具体的代码流程是怎样的,我们到后面讲 worker 线程处理读事件逻辑中进行回答(锚点1)。现在只需要知道这个队列,用户线程往该队列中放数据,worker 线程来消费。

最后一个在 DefaultConnectingIOReactor#requestQueue 队列。当用户线程判断为需要新建 http 连接时,会首先提交该请求到 reactor 的 requestQueue 中,然后在 processSessionRequests() 方法中进行消费。可以看到 三个队列刚好对应了用户线程和另外线程的之间的通信方式,那 reactor 线程和 worker 线程之间是否也有通信?也是有的,这就要说 reactor 线程的事件处理逻辑了。

Reactor 线程

Reactor 线程的职责是负责新建连接。当 AbstractMultiworkerIOReactor#execute 发现有事件到来时,调用 DefaultConnectingIOReactor#processEvents 进行处理,在 processSessionRequests() 方法中迭代 requestQueue 队列,取出并进行 异步 http 连接,如果 对端同步返回连接成功,就调用 addChannel 方法将 ChannelEntrty 加入到 worker 类提供的 newChannels 队列。(实现了 reactor 线程和 worker 线程之间的数据通信) image.png

如果没有同步返回,则把这个 SocketChannel 注册到 reactor 的 OP_CONNECT 事件上。当对端返回连接成功后,会在接下来的逻辑中处理,在 for 循环中调用 processEvent,从 SelectionKey#attachment 中注册塞入的信息,使用 Channel#finishConnect 方法判断连接是否已经建立成功,成功则返回 true,失败则抛出异常。如果此时 SessionRequest#isCompleted 还未完成(在 worker 线程里面更新为 true),则将 ChannelEntry 再次放入 worker 队列。

image.png

reactor 线程工作已经完成,控制权来到 worker 线程,再来看调用 addChannel 方法后的逻辑。Worker 线程在 processNewChannels 方法里面迭代取出元素,将 Channel 读事件 OP_READ 注册到 selector 上。并调用 SessionRequest#completed 方法标记完成,并进行回调。此时会调用在申请链接时,放入 SessionRequestImpl 中的 AbstractNIOConnPool.InternalSessionRequestCallback#completed 的回调方法。 image.png

AbstractNIOConnPool#requestCompleted 方法就是 http 链接建立后的回调操作。将新建的连接放入连接池后,再调用 processNextPendingRequest() 处理那些由于某些原因未获取连接请求,这里就可以回答 锚点1 的留下的问题。调用完后,同样需要调用 fireCallbacks() 来触发写事件并唤醒 worker。 image.png

至此,http 连接新建完成。总结一下,新建 http 连接涉及到了三个线程,由用户线程发起,reactor 线程进行异步连接,最后由 worker 线程善后

Worker 线程

在 AbstractIOReactor#processEvent 我们可以看到该线程既能处理 读事件又能处理写事件,所以对于 Channel 的 读写都是在 worker 线程中处理

处理写事件相对于读来讲简单许多,来看调用栈

1. AbstractIOReactor#processEvent key.isWritable()
2. AbstractIODispatch#outputReady
3. InternalIODispatch#onOutputReady
4. DefaultNHttpClientConnection#produceOutput
5. HttpAsyncRequestExecutor#outputReady
6. DefaultClientExchangeHandlerImpl#produceContent
7. MainClientExec#produceContent
8. BasicAsyncRequestProducer#produceContent
9. EntityAsyncContentProducer#produceContent 往 bufffer 中写入数据
复制代码

image.png

处理读事件相对复杂,其中涉及到 http 连接的释放

1. AbstractIOReactor#processEvent key.isReadable()
2. AbstractIODispatch#inputReady
3. InternalIODispatch#onInputReady
4. DefaultNHttpClientConnection#consumeInput
5. ManagedNHttpClientConnectionImpl#onResponseReceived
6. HttpAsyncRequestExecutor#responseReceived 省略中间调用 BasicAsyncResponseConsumer#responseReceived
7. HttpAsyncRequestExecutor#inputReady 省略中间调用 BasicAsyncResponseConsumer#consumeContent
8. HttpAsyncRequestExecutor#processResponse 省略中间调用 BasicAsyncResponseConsumer#responseCompleted
9. DefaultClientExchangeHandlerImpl#responseCompleted 
10. AbstractClientExchangeHandler#releaseConnection 归还 http 连接
11. resultFuture.completed 用户自定义回调处理方法
复制代码

image.png

可以看到类结构也是非常清晰,关键类有 HttpAsyncRequestExecutor、DefaultClientExchangeHandlerImpl、MainClientExec 与底层交互的类命名都是 BasicAsync 打头,写入为 Producer,读取为 Consumer

其中释放连接方法有两处调用,红字部分是第二次调用,到这里其实已经释放了。真正释放的地方在 MainClientExec#responseCompleted 方法中

总结

至此,分析结束。难就难在异步编程模型中各个线程发生切换的地方,以及之间的通信,只要看懂了几个线程之间的交互,那框架的实现也差不多了解了

猜你喜欢

转载自juejin.im/post/7127896247146561573