经过FD耗尽实验谈谈使用HttpClient的正确姿式

一段问题代码实验

在进行网络编程时,正确关闭资源是一件很重要的事。在高并发场景下,未正常关闭的资源数逐渐积累会致使系统资源耗尽,影响系统总体服务能力,可是这件重要的事情每每又容易被忽视。咱们进行一个简单的实验,使用HttpClient-3.x编写一个demo请求指定的url,看看若是不正确关闭资源会发生什么事。java

public String doGetAsString(String url) {
        GetMethod getMethod = null;
        String is = null;
        InputStreamReader inputStreamReader = null;
        BufferedReader br = null;
        try {
            HttpClient httpclient = new HttpClient();//问题标记①
            getMethod = new GetMethod(url);
            httpclient.executeMethod(getMethod);

            if (HttpStatus.SC_OK == getMethod.getStatusCode()) {
                ......//对返回结果进行消费,代码省略
            }

            return is;

        } catch (Exception e) {
            if (getMethod != null) {
                getMethod.releaseConnection();  //问题标记②              
            }            
        } finally {
            inputStreamReader.close();
            br.close();
            ......//关闭流时的异常处理代码省略

        }
        return null;
    }

这段代码逻辑很简单, 先建立一个HttpClient对象,用url构建一个GetMethod对象,而后发起请求。可是用这段代码并发地以极高的QPS去访问外部的url,很快就会在日志中看到“打开文件太多,没法打开文件”的错误,后续的http请求都会失败。这时咱们用lsof -p ${javapid}命令去查看java进程打开的文件数,发现达到了655350这么多。
分析上面的代码片断,发现存在如下2个问题:linux

(1)初始化方式不对。标记①直接使用new HttpClient()的方式来建立HttpClient,没有显示指定HttpClient connection manager,则构造函数内部默认会使用SimpleHttpConnectionManager,而SimpleHttpConnectionManager的默认参数中alwaysClose的值为false,意味着即便调用了releaseConnection方法,链接也不会真的关闭。apache

(2)在未使用链接池复用链接的状况下,代码没有正确调用releaseConnection。catch块中的标记②是惟一调用了releaseConnection方法的代码,而这段代码仅在发生异常时才会走到,大部分状况下都走不到这里,因此即便咱们前面用正确的方式初始化了HttpClient,因为没有手动释放链接,也仍是会出现链接堆积的问题。编程

可能有同窗会有如下疑问:
一、明明是发起Http请求,为何会打开这么多文件呢?为何是655350这个上限呢?
二、正确的HttpClient使用姿式是什么样的呢?
这就涉及到linux系统中fd的概念。api

什么是fd

在linux系统中有“一切皆文件”的概念。打开和建立普通文件、Socket(套接字)、Pipeline(管道)等,在linux内核层面都须要新建一个文件描述符来进行状态跟踪和使用。咱们使用HttpClient发起请求,其底层须要首先经过系统内核建立一个Socket链接,相应地就须要打开一个fd。安全

为何咱们的应用最多只能建立655350个fd呢?这个值是如何控制的,可否调整呢?事实上,linux系统对打开文件数有多个层面的限制:服务器

1)限制单个Shell进程以及其派生子进程能打开的fd数量。用ulimit命令能查看到这个值。网络

2)限制每一个user能打开的文件总数。具体调整方法是修改/etc/security/limits.conf文件,好比下图中的红框部分就是限制了userA用户只能打开65535个文件,userB用户只能打开655350个文件。因为咱们的应用在服务器上是以userB身份运行的,天然就受到这里的限制,不容许打开多于655350个文件。多线程

# /etc/security/limits.conf
#
#<domain>      <type>  <item>     <value>
userA          -      nofile         65535
userB             -         nofile         655350

# End of file

3)系统层面容许打开的最大文件数限制,能够经过“cat /proc/sys/fs/file-max”查看。并发

前文demo代码中错误的HttpClient使用方式致使链接使用完成后没有成功断开,链接长时间保持CLOSE_WAIT状态,则fd须要继续指向这个套接字信息,没法被回收,进而出现了本文开头的故障。

再识HttpClient

咱们的代码中错误使用common-httpclient-3.x致使后续请求失败,那这里的common-httpclient-3.x究竟是什么东西呢?相信全部接触过网络编程的同窗对HttpClient都不会陌生,因为java.net中对于http访问只提供相对比较低级别的封装,使用起来很不方便,因此HttpClient做为Jakarta Commons的一个子项目出如今公众面前,为开发者提供了更友好的发起http链接的方式。然而目前进入Jakarta Commons HttpClient官网,会发现页面最顶部的“End of life”栏目,提示此项目已经中止维护了,它的功能已经被Apache HttpComponents的HttpClient和HttpCore所取代。

同为Apache基金会的项目,Apache HttpComponents提供了更多优秀特性,它总共由3个模块构成:HttpComponents Core、HttpComponents Client、HttpComponents AsyncClient,分别提供底层核心网络访问能力、同步链接接口、异步链接接口。在大多数状况下咱们使用的都是HttpComponents Client。为了与旧版的Commons HttpClient作区分,新版的HttpComponents Client版本号从4.x开始命名。

从源码上来看,Jakarta Commons HttpClient和Apache HttpComponents Client虽然有不少同名类,可是二者之间没有任何关系。以最常使用到的HttpClient类为例,在commons-httpclient中它是一个类,能够直接发起请求;而在4.x版的httpClient中,它是一个接口,须要使用它的实现类。

既然3.x与4.x的HttpClient是两个彻底独立的体系,那么咱们就分别讨论它们的正确用法。

HttpClient 3.x用法

回顾引起故障的那段代码,经过直接new HttpClient()的方式建立HttpClient对象,而后发起请求,问题出在了这个构造函数上。因为咱们使用的是无参构造函数,查看三方包源码,会发现内部会经过无参构造函数new一个SimpleHttpConnectionManager,它的成员变量alwaysClose在不特别指定的状况下默认为false。

alwaysClose这个值是如何影响到咱们关闭链接的动做呢?继续跟踪下去,发现HttpMethodBase(它的多个实现类分别对应HTTP中的几种方法,咱们最经常使用的是GetMethod和PostMethod)中的releaseConnection()方法首先会尝试关闭响应输入流(下图中的①所指代码),而后在finally中调用ensureConnectionRelease(),这个方法内部实际上是调用了HttpConnection类的releaseConnection()方法,以下图中的标记③所示,它又会调用到SimpleHttpConnectionManager的releaseConnection(conn)方法,来到了最关键的标记④和⑤。

标记④的代码说明,若是alwaysClose=true,则会调用httpConnection.close()方法,它的内部会把输入流、输出流都关闭,而后把socket链接关闭,如标记⑥和⑦所示。

而后,若是标记④处的alwaysClose=false,则会走到⑤的逻辑中,调用finishLastResponse()方法,如标记⑧所示,这段逻辑实际上只是把请求响应的输入流关闭了而已。咱们的问题代码就是走到了这段逻辑,致使没能把以前使用过的链接断开,然后续的请求又没有复用这个httpClient,每次都是new一个新的,致使大量链接处于CLOSE_WAIT状态占用系统文件句柄。

经过以上分析,咱们知道使用commons-httpclient-3.x以后若是想要正确关闭链接,就须要指定always=true且正确调用method.releaseConnection()方法。

上述提到的几个类,他们的依赖关系以下图(红色箭头标出的是咱们刚才讨论到的几个类):

其中SimpleHttpConnectionManager这个类的成员变量和方法列表以下图所示:

事实上,经过对commons-httpclient-3.x其余部分源码的分析,能够得知还有其余方法也能够正确关闭链接。

方法1:先调用method.releaseConnection(),而后获取到httpClient对象的SimpleHttpConnectionManager成员变量,主动调用它的shutdown()方法便可。对应的三方包源码以下图所示,其内部会调用httpConnection.close()方法。

方法2:先调用method.releaseConnection(),而后获取到httpClient对象的SimpleHttpConnectionManager成员变量,主动调用closeIdleConnections(0)便可,对应的三方包源码以下。

方法3:因为咱们使用的是HTTP/1.1协议,默认会使用长链接,因此会出现上面的链接不释放的问题。若是客户端与服务端双方协商好不使用长链接,不就能够解决问题了吗。commons-httpclient-3.x也确实提供了这个支持,从下面的注释也能够看出来。具体这样操做,咱们在建立了method后使用method.setRequestHeader("Connection", "close")设置头部信息,并在使用完成后调用一次method.releaseConnection()。Http服务端在看到此头部后会在response的头部中也带上“Connection: close”,如此一来httpClient发现返回的头部有这个信息,则会在处理完响应后自动关闭链接。

HttpClient 4.x用法

既然官方已经再也不维护3.x,而是推荐全部使用者都升级到4.x上来,咱们就顺应时代潮流,重点看看4.x的用法。

(1)简易用法

最简单的用法相似于3.x,调用三方包提供的工具类静态方法建立一个CloseableHttpClient对象,而后发起调用,以下图。这种方式建立的CloseableHttpClient,默认使用的是PoolingHttpClientConnectionManager来管理链接。因为CloseableHttpClient是线程安全的,所以不须要每次调用时都从新生成一个,能够定义成static字段在多线程间复用。

如上图,咱们在获取到response对象后,本身决定如何处理返回数据。HttpClient的三方包中已经为咱们提供了EntityUtils这个工具类,若是使用这个类的toString()或consume()方法,则上图finally块红框中的respnose.close()就不是必须的了,由于EntityUtils的方法内部会在处理完数据后把底层流关闭。

(2)简易用法涉及到的核心类详解

CloseableHttpClient是一个抽象类,咱们经过HttpClients.createDefault()建立的实际是它的子类InternalHttpClient。

/**
 * Internal class.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
@SuppressWarnings("deprecation")
class InternalHttpClient extends CloseableHttpClient implements Configurable {
    ... ...
}

继续跟踪httpclient.execute()方法,发现其内部会调用CloseableHttpClient.doExecute()方法,实际会调到InternalHttpClient类的doExecute()方法。经过对请求对象(HttpGet、HttpPost等)进行一番包装后,最后实际由execChain.execute()来真正执行请求,这里的execChain是接口ClientExecChain的一个实例。接口ClientExecChain有多个实现类,因为咱们使用HttpClients.createDefault()这个默认方法构造了CloseableHttpClient,没有指定ClientExecChain接口的具体实现类,因此系统默认会使用RedirectExec这个实现类。

/**
 * Base implementation of {@link HttpClient} that also implements {@link Closeable}.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE)
public abstract class CloseableHttpClient implements HttpClient, Closeable {

    private final Log log = LogFactory.getLog(getClass());

    protected abstract CloseableHttpResponse doExecute(HttpHost target, HttpRequest request,
            HttpContext context) throws IOException, ClientProtocolException;

    ... ...
}

RedirectExec类的execute()方法较长,下图进行了简化。

能够看到若是远端返回结果标识须要重定向(响应头部是30一、30二、30三、307等重定向标识),则HttpClient默认会自动帮咱们作重定向,且每次重定向的返回流都会自动关闭。若是中途发生了异常,也会帮咱们把流关闭。直到拿到最终真正的业务返回结果后,直接把整个response向外返回,这一步没有帮咱们关闭流。所以,外层的业务代码在使用完response后,须要自行关闭流。

执行execute()方法后返回的response是一个CloseableHttpResponse实例,它的实现是什么?点开看看,这是一个接口,此接口惟一的实现类是HttpResponseProxy。

/**
 * Extended version of the {@link HttpResponse} interface that also extends {@link Closeable}.
 *
 * @since 4.3
 */
public interface CloseableHttpResponse extends HttpResponse, Closeable {
}

咱们前面常常看到的response.close(),实际是调用了HttpResponseProxy的close()方法,其内部逻辑以下:

/**
 * A proxy class for {@link org.apache.http.HttpResponse} that can be used to release client connection
 * associated with the original response.
 *
 * @since 4.3
 */
 class HttpResponseProxy implements CloseableHttpResponse {    

    @Override
    public void close() throws IOException {
        if (this.connHolder != null) {
            this.connHolder.close();
        }
    }

    ... ...
}
/**
 * Internal connection holder.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE)
class ConnectionHolder implements ConnectionReleaseTrigger, Cancellable, Closeable {
    ... ...
    @Override
    public void close() throws IOException {
        releaseConnection(false);
    }

}

能够看到最终会调用到ConnectionHolder类的releaseConnection(reusable)方法,因为ConnectionHolder的close()方法调用releaseConnection()时默认传入了false,所以会走到else的逻辑中。这段逻辑首先调用managedConn.close()方法,而后调用manager.releaseConnection()方法。

managedConn.close()方法实际是把链接池中已经创建的链接在socket层面断开链接,断开以前会把inbuffer清空,并把outbuffer数据所有传送出去,而后把链接池中的链接记录也删除。manager.releaseConnection()对应的代码是PoolingHttpClientConnectionManager.releaseConnection(),这段代码代码原本的做用是把处于open状态的链接的socket超时时间设置为0,而后把链接从leased集合中删除,若是链接可复用则把此链接加入到available链表的头部,若是不可复用则直接把链接关闭。因为前面传入的reusable已经强制为false,所以实际关闭链接的操做已经由managedConn.close()方法作完了,走到PoolingHttpClientConnectionManager.releaseConnection()中真正的工做基本就是清除链接池中的句柄而已。

若是想了解关闭socket的细节,能够经过HttpClientConnection.close()继续往下跟踪,最终会看到真正关闭socket的代码在BHttpConnectionBase中。

/**
 * This class serves as a base for all {@link HttpConnection} implementations and provides
 * functionality common to both client and server HTTP connections.
 *
 * @since 4.0
 */
public class BHttpConnectionBase implements HttpConnection, HttpInetConnection {
    ... ...
    @Override
    public void close() throws IOException {
        final Socket socket = this.socketHolder.getAndSet(null);
        if (socket != null) {
            try {
                this.inbuffer.clear();
                this.outbuffer.flush();
                try {
                    try {
                        socket.shutdownOutput();
                    } catch (final IOException ignore) {
                    }
                    try {
                        socket.shutdownInput();
                    } catch (final IOException ignore) {
                    }
                } catch (final UnsupportedOperationException ignore) {
                    // if one isn't supported, the other one isn't either
                }
            } finally {
                socket.close();
            }
        }
    }
    ... ...
}

为何说调用了EntityUtils的部分方法后,就不须要再显示地关闭流呢?看下它的源码就明白了。

/**
 * Static helpers for dealing with {@link HttpEntity}s.
 *
 * @since 4.0
 */
public final class EntityUtils {
    /**
     * Ensures that the entity content is fully consumed and the content stream, if exists,
     * is closed.
     *
     * @param entity the entity to consume.
     * @throws IOException if an error occurs reading the input stream
     *
     * @since 4.1
     */
    public static void consume(final HttpEntity entity) throws IOException {
        if (entity == null) {
            return;
        }
        if (entity.isStreaming()) {
            final InputStream instream = entity.getContent();
            if (instream != null) {
                instream.close();
            }
        }
    }

    ... ...
}

(3)HttpClient进阶用法

在高并发场景下,使用链接池有效复用已经创建的链接是很是必要的。若是每次http请求都从新创建链接,那么底层的socket链接每次经过3次握手建立和4次握手断开链接将是一笔很是大的时间开销。
要合理使用链接池,首先就要作好PoolingHttpClientConnectionManager的初始化。以下图,咱们设置maxTotal=200且defaultMaxPerRoute=20。maxTotal=200指整个链接池中链接数上限为200个;defaultMaxPerRoute用来指定每一个路由的最大并发数,好比咱们设置成20,意味着虽然咱们整个池子中有200个链接,可是链接到"http://www.taobao.com"时同一时间最多只能使用20个链接,其余的180个就算全闲着也不能给发到"http://www.taobao.com"的请求使用。所以,对于高并发的场景,须要合理分配这2个参数,一方面可以防止全局链接数过多耗尽系统资源,另外一方面经过限制单路由的并发上限可以避免单一业务故障影响其余业务。

private static volatile CloseableHttpClient instance;

    static {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        // Increase max total connection to 200
        cm.setMaxTotal(200);
        // Increase default max connection per route to 20
        cm.setDefaultMaxPerRoute(20);
        RequestConfig requestConfig = RequestConfig.custom()
            .setConnectTimeout(1000)
            .setSocketTimeout(1000)
            .setConnectionRequestTimeout(1000)
            .build();
        instance = HttpClients.custom()
            .setConnectionManager(cm)
            .setDefaultRequestConfig(requestConfig)
            .build();

    }

官方同时建议咱们在后台起一个定时清理无效链接的线程,由于某些链接创建后可能因为服务端单方面断开链接致使一个不可用的链接一直占用着资源,而HttpClient框架又不能百分之百保证检测到这种异常链接并作清理,所以须要自给自足,按照以下方式写一个空闲链接清理线程在后台运行。

public class IdleConnectionMonitorThread extends Thread {
    private final HttpClientConnectionManager connMgr;
    private volatile boolean shutdown;
    Logger logger = LoggerFactory.getLogger(IdleConnectionMonitorThread.class);

    public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) {
        super();
        this.connMgr = connMgr;
    }

    @Override
    public void run() {
        try {
            while (!shutdown) {
                synchronized (this) {
                    wait(5000);
                    // Close expired connections
                    connMgr.closeExpiredConnections();
                    // Optionally, close connections
                    // that have been idle longer than 30 sec
                    connMgr.closeIdleConnections(30, TimeUnit.SECONDS);
                } }
        } catch (InterruptedException ex) {
            logger.error("unknown exception", ex);
            // terminate
        }
    }

    public void shutdown() {
        shutdown = true;
        synchronized (this) {
            notifyAll();
        }
    }
}

咱们讨论到的几个核心类的依赖关系以下:

HttpClient做为你们经常使用的工具,看似简单,可是其中却有不少隐藏的细节值得探索。



本文做者:闲鱼技术-峰明

阅读原文

本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索