Http 持久連線與 HttpClient 連線池
一、背景
HTTP協議是無狀態的協議,即每一次請求都是互相獨立的。因此它的最初實現是,每一個http請求都會開啟一個tcp socket連線,當互動完畢後會關閉這個連線。
HTTP協議是全雙工的協議,所以建立連線與斷開連線是要經過三次握手與四次揮手的。顯然在這種設計中,每次傳送Http請求都會消耗很多的額外資源,即連線的建立與銷燬。
於是,HTTP協議的也進行了發展,通過持久連線的方法來進行socket連線複用。
從圖中可以看到:
在序列連線中,每次互動都要開啟關閉連線
在持久連線中,第一次互動會開啟連線,互動結束後連線並不關閉,下次互動就省去了建立連線的過程。
持久連線的實現有兩種:HTTP/1.0+的keep-alive與HTTP/1.1的持久連線。
二、HTTP/1.0+的Keep-Alive
從1996年開始,很多HTTP/1.0瀏覽器與伺服器都對協議進行了擴充套件,那就是“keep-alive”擴充套件協議。
注意,這個擴充套件協議是作為1.0的補充的“實驗型持久連線”出現的。keep-alive已經不再使用了,最新的HTTP/1.1規範中也沒有對它進行說明,只是很多應用延續了下來。
使用HTTP/1.0的客戶端在首部中加上”Connection:Keep-Alive”,請求服務端將一條連線保持在開啟狀態。服務端如果願意將這條連線保持在開啟狀態,就會在響應中包含同樣的首部。如果響應中沒有包含”Connection:Keep-Alive”首部,則客戶端會認為服務端不支援keep-alive,會在傳送完響應報文之後關閉掉當前連線。
通過keep-alive補充協議,客戶端與伺服器之間完成了持久連線,然而仍然存在著一些問題:
在HTTP/1.0中keep-alive不是標準協議,客戶端必須傳送Connection:Keep-Alive來啟用keep-alive連線。
代理伺服器可能無法支援keep-alive,因為一些代理是”盲中繼”,無法理解首部的含義,只是將首部逐跳轉發。所以可能造成客戶端與服務端都保持了連線,但是代理不接受該連線上的資料。
三、HTTP/1.1的持久連線
HTTP/1.1採取持久連線的方式替代了Keep-Alive。
HTTP/1.1的連線預設情況下都是持久連線。如果要顯式關閉,需要在報文中加上Connection:Close首部。即在HTTP/1.1中,所有的連線都進行了複用。
然而如同Keep-Alive一樣,空閒的持久連線也可以隨時被客戶端與服務端關閉。不傳送Connection:Close不意味著伺服器承諾連線永遠保持開啟。
四、HttpClient如何生成持久連線
HttpClien中使用了連線池來管理持有連線,同一條TCP鏈路上,連線是可以複用的。HttpClient通過連線池的方式進行連線持久化。
其實“池”技術是一種通用的設計,其設計思想並不複雜:
當有連線第一次使用的時候建立連線
結束時對應連線不關閉,歸還到池中
下次同個目的的連線可從池中獲取一個可用連線
定期清理過期連線
所有的連線池都是這個思路,不過我們看HttpClient原始碼主要關注兩點:
連線池的具體設計方案,以供以後自定義連線池參考
如何與HTTP協議對應上,即理論抽象轉為程式碼的實現
4.1 HttpClient連線池的實現
HttpClient關於持久連線的處理在下面的程式碼中可以集中體現,下面從MainClientExec摘取了和連線池相關的部分,去掉了其他部分:
public class MainClientExec implements ClientExecChain {
@Override
public CloseableHttpResponse execute(
final HttpRoute route,
final HttpRequestWrapper request,
final HttpClientContext context,
final HttpExecutionAware execAware) throws IOException, HttpException {
//從連線管理器HttpClientConnectionManager中獲取一個連線請求ConnectionRequest
final ConnectionRequest connRequest = connManager.requestConnection(route, userToken);final HttpClientConnection managedConn;
final int timeout = config.getConnectionRequestTimeout();
//從連線請求ConnectionRequest中獲取一個被管理的連線HttpClientConnection
managedConn = connRequest.get(timeout > 0 ? timeout : 0, TimeUnit.MILLISECONDS);
//將連線管理器HttpClientConnectionManager與被管理的連線HttpClientConnection交給一個ConnectionHolder持有
final ConnectionHolder connHolder = new ConnectionHolder(this.log, this.connManager, managedConn);
try {
HttpResponse response;
if (!managedConn.isOpen()) {
//如果當前被管理的連線不是出於開啟狀態,需要重新建立連線
establishRoute(proxyAuthState, managedConn, route, request, context);
}
//通過連線HttpClientConnection傳送請求
response = requestExecutor.execute(request, managedConn, context);
//通過連線重用策略判斷是否連線可重用
if (reuseStrategy.keepAlive(response, context)) {
//獲得連線有效期
final long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
//設定連線有效期
connHolder.setValidFor(duration, TimeUnit.MILLISECONDS);
//將當前連線標記為可重用狀態
connHolder.markReusable();
} else {
connHolder.markNonReusable();
}
}
final HttpEntity entity = response.getEntity();
if (entity == null || !entity.isStreaming()) {
//將當前連線釋放到池中,供下次呼叫
connHolder.releaseConnection();
return new HttpResponseProxy(response, null);
} else {
return new HttpResponseProxy(response, connHolder);
}
}
這裡看到了在Http請求過程中對連線的處理是和協議規範是一致的,這裡要展開講一下具體實現。
PoolingHttpClientConnectionManager是HttpClient預設的連線管理器,首先通過requestConnection()獲得一個連線的請求,注意這裡不是連線。
public ConnectionRequest requestConnection(
final HttpRoute route,
final Object state) {final Future<CPoolEntry> future = this.pool.lease(route, state, null);
return new ConnectionRequest() {
@Override
public boolean cancel() {
return future.cancel(true);
}
@Override
public HttpClientConnection get(
final long timeout,
final TimeUnit tunit) throws InterruptedException, ExecutionException, ConnectionPoolTimeoutException {
final HttpClientConnection conn = leaseConnection(future, timeout, tunit);
if (conn.isOpen()) {
final HttpHost host;
if (route.getProxyHost() != null) {
host = route.getProxyHost();
} else {
host = route.getTargetHost();
}
final SocketConfig socketConfig = resolveSocketConfig(host);
conn.setSocketTimeout(socketConfig.getSoTimeout());
}
return conn;
}
};
}
可以看到返回的ConnectionRequest物件實際上是一個持有了Future<CPoolEntry>,CPoolEntry是被連線池管理的真正連線例項。
從上面的程式碼我們應該關注的是:
Future<CPoolEntry> future = this.pool.lease(route, state, null)
如何從連線池CPool中獲得一個非同步的連線,Future<CPoolEntry>
HttpClientConnection conn = leaseConnection(future, timeout, tunit)
如何通過非同步連線Future<CPoolEntry>獲得一個真正的連線HttpClientConnection
4.2 Future<CPoolEntry>
看一下CPool是如何釋放一個Future<CPoolEntry>的,AbstractConnPool核心程式碼如下:
private E getPoolEntryBlocking(
final T route, final Object state,
final long timeout, final TimeUnit tunit,
final Future<E> future) throws IOException, InterruptedException, TimeoutException {
//首先對當前連線池加鎖,當前鎖是可重入鎖ReentrantLockthis.lock.lock();
try {
//獲得一個當前HttpRoute對應的連線池,對於HttpClient的連線池而言,總池有個大小,每個route對應的連線也是個池,所以是“池中池”
final RouteSpecificPool<T, C, E> pool = getPool(route);
E entry;
for (;;) {
Asserts.check(!this.isShutDown, "Connection pool shut down");
//死迴圈獲得連線
for (;;) {
//從route對應的池中拿連線,可能是null,也可能是有效連線
entry = pool.getFree(state);
//如果拿到null,就退出迴圈
if (entry == null) {
break;
}
//如果拿到過期連線或者已關閉連線,就釋放資源,繼續迴圈獲取
if (entry.isExpired(System.currentTimeMillis())) {
entry.close();
}
if (entry.isClosed()) {
this.available.remove(entry);
pool.free(entry, false);
} else {
//如果拿到有效連線就退出迴圈
break;
}
}
//拿到有效連線就退出
if (entry != null) {
this.available.remove(entry);
this.leased.add(entry);
onReuse(entry);
return entry;
}
//到這裡證明沒有拿到有效連線,需要自己生成一個
final int maxPerRoute = getMax(route);
//每個route對應的連線最大數量是可配置的,如果超過了,就需要通過LRU清理掉一些連線
final int excess = Math.max(0, pool.getAllocatedCount() + 1 - maxPerRoute);
if (excess > 0) {
for (int i = 0; i < excess; i++) {
final E lastUsed = pool.getLastUsed();
if (lastUsed == null) {
break;
}
lastUsed.close();
this.available.remove(lastUsed);
pool.remove(lastUsed);
}
}
//當前route池中的連線數,沒有達到上線
if (pool.getAllocatedCount() < maxPerRoute) {
final int totalUsed = this.leased.size();
final int freeCapacity = Math.max(this.maxTotal - totalUsed, 0);
//判斷連線池是否超過上線,如果超過了,需要通過LRU清理掉一些連線
if (freeCapacity > 0) {
final int totalAvailable = this.available.size();
//如果空閒連線數已經大於剩餘可用空間,則需要清理下空閒連線
if (totalAvailable > freeCapacity - 1) {
if (!this.available.isEmpty()) {
final E lastUsed = this.available.removeLast();
lastUsed.close();
final RouteSpecificPool<T, C, E> otherpool = getPool(lastUsed.getRoute());
otherpool.remove(lastUsed);
}
}
//根據route建立一個連線
final C conn = this.connFactory.create(route);
//將這個連線放入route對應的“小池”中
entry = pool.add(conn);
//將這個連線放入“大池”中
this.leased.add(entry);
return entry;
}
}
//到這裡證明沒有從獲得route池中獲得有效連線,並且想要自己建立連線時當前route連線池已經到達最大值,即已經有連線在使用,但是對當前執行緒不可用
boolean success = false;
try {
if (future.isCancelled()) {
throw new InterruptedException("Operation interrupted");
}
//將future放入route池中等待
pool.queue(future);
//將future放入大連線池中等待
this.pending.add(future);
//如果等待到了訊號量的通知,success為true
if (deadline != null) {
success = this.condition.awaitUntil(deadline);
} else {
this.condition.await();
success = true;
}
if (future.isCancelled()) {
throw new InterruptedException("Operation interrupted");
}
} finally {
//從等待佇列中移除
pool.unqueue(future);
this.pending.remove(future);
}
//如果沒有等到訊號量通知並且當前時間已經超時,則退出迴圈
if (!success && (deadline != null && deadline.getTime() <= System.currentTimeMillis())) {
break;
}
}
//最終也沒有等到訊號量通知,沒有拿到可用連線,則拋異常
throw new TimeoutException("Timeout waiting for connection");
} finally {
//釋放對大連線池的鎖
this.lock.unlock();
}
}
上面的程式碼邏輯有幾個重要點:
連線池有個最大連線數,每個route對應一個小連線池,也有個最大連線數
不論是大連線池還是小連線池,當超過數量的時候,都要通過LRU釋放一些連線
如果拿到了可用連線,則返回給上層使用
如果沒有拿到可用連線,HttpClient會判斷當前route連線池是否已經超過了最大數量,沒有到上限就會新建一個連線,並放入池中
如果到達了上限,就排隊等待,等到了訊號量,就重新獲得一次,等待不到就拋超時異常