OkHttp3原始碼分析[複用連線池]

yangxi_001發表於2017-06-19

OkHttp系列文章如下


1. 概述

HTTP中的keepalive連線在網路效能優化中,對於延遲降低與速度提升的有非常重要的作用。

通常我們進行http連線時,首先進行tcp握手,然後傳輸資料,最後釋放


圖源: Nginx closed

這種方法的確簡單,但是在複雜的網路內容中就不夠用了,建立socket需要進行3次握手,而釋放socket需要2次握手(或者是4次)。重複的連線與釋放tcp連線就像每次僅僅擠1mm的牙膏就合上牙膏蓋子接著再開啟接著擠一樣。而每次連線大概是TTL一次的時間(也就是ping一次),在TLS環境下消耗的時間就更多了。很明顯,當訪問複雜網路時,延時(而不是頻寬)將成為非常重要的因素。

當然,上面的問題早已經解決了,在http中有一種叫做keepalive connections的機制,它可以在傳輸資料後仍然保持連線,當客戶端需要再次獲取資料時,直接使用剛剛空閒下來的連線而不需要再次握手


圖源: Nginx keep_alive

在現代瀏覽器中,一般同時開啟6~8個keepalive connections的socket連線,並保持一定的鏈路生命,當不需要時再關閉;而在伺服器中,一般是由軟體根據負載情況(比如FD最大值、Socket記憶體、超時時間、棧記憶體、棧數量等)決定是否主動關閉。

Okhttp支援5個併發KeepAlive,預設鏈路生命為5分鐘(鏈路空閒後,保持存活的時間)

當然keepalive也有缺點,在提高了單個客戶端效能的同時,複用卻阻礙了其他客戶端的鏈路速度,具體來說如下

  1. 根據TCP的擁塞機制,當總水管大小固定時,如果存在大量空閒的keepalive connections(我們可以稱作殭屍連線或者洩漏連線),其它客戶端們的正常連線速度也會受到影響,這也是運營商為何限制P2P連線數的道理
  2. 伺服器/防火牆上有併發限制,比如apache伺服器對每個請求都開執行緒,導致只支援150個併發連線(資料來源於nginx官網),不過這個瓶頸隨著高併發server軟硬體的發展(golang/分散式/IO多路複用)將會越來越少
  3. 大量的DDOS產生的殭屍連線可能被用於惡意攻擊伺服器,耗盡資源

好了,以上科普完畢,本文主要是寫客戶端的,服務端不再介紹。

下文假設伺服器是經過專業的運維配置好的,它預設開啟了keep-alive,並不主動關閉連線

2. 連線池的使用與分析

首先先說下原始碼中關鍵的物件:

  • Call: 對http的請求封裝,屬於程式設計師能夠接觸的上層高階程式碼
  • Connection: 對jdk的socket物理連線的包裝,它內部有List<WeakReference<StreamAllocation>>的引用
  • StreamAllocation: 表示Connection被上層高階程式碼的引用次數
  • ConnectionPool: Socket連線池,對連線快取進行回收與管理,與CommonPool有類似的設計
  • Deque: Deque也就是雙端佇列,雙端佇列同時具有佇列和棧性質,經常在快取中被使用,這個是java基礎

在okhttp中,連線池對使用者,甚至開發者都是透明的。它自動建立連線池,自動進行洩漏連線回收,自動幫你管理執行緒池,提供了put/get/clear的介面,甚至內部呼叫都幫你寫好了。

在以前的記憶體洩露分析文章中我寫到,我們知道在socket連線中,也就是Connection中,本質是封裝好的流操作,除非手動close掉連線,基本不會被GC掉,非常容易引發記憶體洩露。所以當涉及到併發socket程式設計時,我們就會非常緊張,往往寫出來的程式碼都是try/catch/finally的迷之縮排,卻又對這樣的程式碼無可奈何。

在okhttp中,在高層程式碼的呼叫中,使用了類似於引用計數的方式跟蹤Socket流的呼叫,這裡的計數物件是StreamAllocation,它被反覆執行aquirerelease操作(點選函式可以進入github檢視),這兩個函式其實是在改變Connection中的List<WeakReference<StreamAllocation>>大小。List中Allocation的數量也就是物理socket被引用的計數(Refference Count),如果計數為0的話,說明此連線沒有被使用,是空閒的,需要通過下文的演算法實現回收;如果上層程式碼仍然引用,就不需要關閉連線。

引用計數法:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用。它不能處理迴圈引用的問題。

2.1. 例項化

在原始碼中,我們先找ConnectionPool例項化的位置,它是直接new出來的,而它的各種操作卻在OkHttpClientstatic區實現了Internal.instance介面作為ConnectionPool的包裝。

至於為什麼需要這麼多此一舉的分層包裝,主要是為了讓外部包的成員訪問非public方法,詳見這裡註釋

2.2. 構造

  1. 連線池內部維護了一個叫做OkHttp ConnectionPoolThreadPool,專門用來淘汰末位的socket,當滿足以下條件時,就會進行末位淘汰,非常像GC

    1. 併發socket空閒連線超過52. 某個socket的keepalive時間大於5分鐘
  2. 維護著一個Deque<Connection>,提供get/put/remove等資料結構的功能

  3. 維護著一個RouteDatabase,它用來記錄連線失敗的Route的黑名單,當連線失敗的時候就會把失敗的線路加進去(本文不討論)

2.3 put/get操作

在連線池中,提供如下的操作,這裡可以看成是對deque的一個簡單的包裝

//從連線池中獲取
get
//放入連線池
put
//執行緒變成空閒,並呼叫清理執行緒池
connectionBecameIdle
//關閉所有連線
evictAll

隨著上述操作被更高階的物件呼叫,Connection中的StreamAllocation被不斷的aquirerelease,也就是List<WeakReference<StreamAllocation>>的大小將時刻變化

2.4 Connection自動回收的實現

java內部有垃圾回收GC,okhttp有socket的回收;垃圾回收是根據物件的引用樹實現的,而okhttp是根據RealConnection的虛引用StreamAllocation引用計數是否為0實現的。我們先看程式碼

cleanupRunnable:

當使用者socket連線成功,向連線池中put新的socket時,回收函式會被主動呼叫,執行緒池就會執行cleanupRunnable,如下

//Socket清理的Runnable,每當put操作時,就會被主動呼叫
//注意put操作是在網路執行緒
//而Socket清理是在`OkHttp ConnectionPool`執行緒池中呼叫
while (true) {
  //執行清理並返回下場需要清理的時間
  long waitNanos = cleanup(System.nanoTime());
  if (waitNanos == -1) return;
  if (waitNanos > 0) {
    synchronized (ConnectionPool.this) {
      try {
        //在timeout內釋放鎖與時間片
        ConnectionPool.this.wait(TimeUnit.NANOSECONDS.toMillis(waitNanos));
      } catch (InterruptedException ignored) {
      }
    }
  }
}

這段死迴圈實際上是一個阻塞的清理任務,首先進行清理(clean),並返回下次需要清理的間隔時間,然後呼叫wait(timeout)進行等待以釋放鎖與時間片,當等待時間到了後,再次進行清理,並返回下次要清理的間隔時間...

Cleanup:

cleanup使用了類似於GC的標記-清除演算法,也就是首先標記出最不活躍的連線(我們可以叫做洩漏連線,或者空閒連線),接著進行清除,流程如下:

long cleanup(long now) {
  int inUseConnectionCount = 0;
  int idleConnectionCount = 0;
  RealConnection longestIdleConnection = null;
  long longestIdleDurationNs = Long.MIN_VALUE;

  //遍歷`Deque`中所有的`RealConnection`,標記洩漏的連線
  synchronized (this) {
    for (RealConnection connection : connections) {
      // 查詢此連線內部StreamAllocation的引用數量
      if (pruneAndGetAllocationCount(connection, now) > 0) {
        inUseConnectionCount++;
        continue;
      }

      idleConnectionCount++;

      //選擇排序法,標記出空閒連線
      long idleDurationNs = now - connection.idleAtNanos;
      if (idleDurationNs > longestIdleDurationNs) {
        longestIdleDurationNs = idleDurationNs;
        longestIdleConnection = connection;
      }
    }

    if (longestIdleDurationNs >= this.keepAliveDurationNs
        || idleConnectionCount > this.maxIdleConnections) {
      //如果(`空閒socket連線超過5個`
      //且`keepalive時間大於5分鐘`)
      //就將此洩漏連線從`Deque`中移除
      connections.remove(longestIdleConnection);
    } else if (idleConnectionCount > 0) {
      //返回此連線即將到期的時間,供下次清理
      //這裡依據是在上文`connectionBecameIdle`中設定的計時
      return keepAliveDurationNs - longestIdleDurationNs;
    } else if (inUseConnectionCount > 0) {
      //全部都是活躍的連線,5分鐘後再次清理
      return keepAliveDurationNs;
    } else {
      //沒有任何連線,跳出迴圈
      cleanupRunning = false;
      return -1;
    }
  }

  //關閉連線,返回`0`,也就是立刻再次清理
  closeQuietly(longestIdleConnection.socket());
  return 0;
}

太長不想看的話,就是如下的流程:

  1. 遍歷Deque中所有的RealConnection,標記洩漏的連線
  2. 如果被標記的連線滿足(空閒socket連線超過5個&&keepalive時間大於5分鐘),就將此連線從Deque中移除,並關閉連線,返回0,也就是將要執行wait(0),提醒立刻再次掃描
  3. 如果(目前還可以塞得下5個連線,但是有可能洩漏的連線(即空閒時間即將達到5分鐘)),就返回此連線即將到期的剩餘時間,供下次清理
  4. 如果(全部都是活躍的連線),就返回預設的keep-alive時間,也就是5分鐘後再執行清理
  5. 如果(沒有任何連線),就返回-1,跳出清理的死迴圈

再次注意:這裡的“併發”==(“空閒”+“活躍”)==5,而不是說併發連線就一定是活躍的連線

pruneAndGetAllocationCount:

如何標記並找到最不活躍的連線呢,這裡使用了pruneAndGetAllocationCount方法,它主要依據弱引用是否為null而判斷這個連線是否洩漏

//類似於引用計數法,如果引用全部為空,返回立刻清理
private int pruneAndGetAllocationCount(RealConnection connection, long now) {
  //虛引用列表
  List<Reference<StreamAllocation>> references = connection.allocations;
  //遍歷弱引用列表
  for (int i = 0; i < references.size(); ) {
    Reference<StreamAllocation> reference = references.get(i);
    //如果正在被使用,跳過,接著迴圈
    //是否置空是在上文`connectionBecameIdle`的`release`控制的
    if (reference.get() != null) {
      //非常明顯的引用計數
      i++;
      continue;
    }

    //否則移除引用
    references.remove(i);
    connection.noNewStreams = true;

    //如果所有分配的流均沒了,標記為已經距離現在空閒了5分鐘
    if (references.isEmpty()) {
      connection.idleAtNanos = now - keepAliveDurationNs;
      return 0;
    }
  }

  return references.size();
}
  1. 遍歷RealConnection連線中的StreamAllocationList,它維護著一個弱引用列表
  2. 檢視此StreamAllocation是否為空(它是線上程池的put/remove手動控制的),如果為空,說明已經沒有程式碼引用這個物件了,需要在List中刪除
  3. 遍歷結束,如果List中維護的StreamAllocation刪空了,就返回0,表示這個連線已經沒有程式碼引用了,是洩漏的連線;否則返回非0的值,表示這個仍然被引用,是活躍的連線。

上述實現的過於保守,實際上用filter就可以大致實現,虛擬碼如下

return references.stream().filter(reference -> {
    return !reference.get() == null;
}).count();

總結

通過上面的分析,我們可以總結,okhttp使用了類似於引用計數法與標記擦除法的混合使用,當連線空閒或者釋放時,StreamAllocation的數量會漸漸變成0,從而被執行緒池監測到並回收,這樣就可以保持多個健康的keep-alive連線,Okhttp的黑科技就是這樣實現的。

最後推薦一本《圖解HTTP》,日本人寫的,看起來很不錯。

再推薦閱讀開源Redis客戶端Jedis的原始碼,可以看下它的JedisFactory的實現。

如果你期待更多高質量的文章,不妨關注我或者點贊吧!

Ref

  1. https://www.nginx.com/blog/http-keepalives-and-web-performance/

相關文章