源碼閱讀計劃 - OkHttp複用連接池 Connect緩存 請求計數 請求限制 連接的斷開 低版本的清理流程

蠻久之前寫過一篇博客OkHttp源碼解析,相信大多數同學也看過或者瞭解過OkHttp的整體架構使用的是基於責任鏈模式的攔截器鏈。

其實這個庫的其他設計也是蠻有意思的,這篇筆記我們就來看看它的Http連接是怎麼實現的。

這部分的代碼我們從ConnectInterceptor這個攔截器看起,它主要就是負責http的連接。新版本的OkHttp已經用kotlin重寫了,所以下面的代碼都是kotlin的:

object ConnectInterceptor : Interceptor {
  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    val realChain = chain as RealInterceptorChain
    val exchange = realChain.call.initExchange(chain)
    val connectedChain = realChain.copy(exchange = exchange)
    return connectedChain.proceed(realChain.request)
  }
}

class RealCall(...) : Call {
    internal fun initExchange(chain: RealInterceptorChain): Exchange {
        ...
        val codec = exchangeFinder.find(client, chain)
        val result = Exchange(this, eventListener, exchangeFinder, codec)
        ...
        return result
    }
}

ConnectInterceptor的代碼很簡單,主要的功能就是初始化RealCall的Exchange。這個Exchange的功能就是基於http的Connection進行數據交換。

Connect緩存

在代碼裏面我們可以看到這個Exchange的codec是find出來的,codec的功能就算http報文的編解碼。一般來講用find的話就意味着它可能存在緩存:

class ExchangeFinder(
  private val connectionPool: RealConnectionPool,
  internal val address: Address,
  private val call: RealCall,
  private val eventListener: EventListener
) {
    ...
    fun find(client: OkHttpClient, chain: RealInterceptorChain ): ExchangeCodec {
        ...
        val resultConnection = findHealthyConnection(...)
        return resultConnection.newCodec(client, chain)
        ...
    }

    private fun findHealthyConnection(...): RealConnection {
        ...
        val candidate = findConnection(...)
        ...
    }

    private fun findConnection(...): RealConnection {
        ...
    // 從連接池裏面查找可用連接
        if (connectionPool.callAcquirePooledConnection(address, call, null, false)) {
            val result = call.connection!!
            eventListener.connectionAcquired(call, result)
            return result
        }
        ...
    // 找不到的話創建新的連接
        val newConnection = RealConnection(connectionPool, route)
        ...
    // 連接socket
        newConnection.connect(...)
        ...
    // 將新連接丟到連接池
        connectionPool.put(newConnection)
    // 綁定RealCall和連接
        call.acquireConnectionNoEvents(newConnection)
        ...
        return newConnection
    }
    ...
}
           
class RealCall(...) : Call {
    fun acquireConnectionNoEvents(connection: RealConnection) {
        ...
        this.connection = connection
        connection.calls.add(CallReference(this, callStackTrace))
    }
}

從上面的代碼我們可以看出來,實際上並不是codec有緩存,而是http的Connection有緩存。codec是通過這個緩存的Connection創建出來的。

Connection實際上就維護着一條socket連接,我們可以看newConnection.connect的具體實現:

fun connect(...) {
    ...
    connectSocket(connectTimeout, readTimeout, call, eventListener)
    ...
}

private fun connectSocket(...) {
    val proxy = route.proxy
    val address = route.address

    val rawSocket = when (proxy.type()) {
        Proxy.Type.DIRECT, Proxy.Type.HTTP -> address.socketFactory.createSocket()!!
        else -> Socket(proxy)
    }
    ...
    Platform.get().connectSocket(rawSocket, route.socketAddress, connectTimeout)
    ...
}

也就是並不是每次請求都會創建一條新的socket連接:

請求計數

HTTP/1.0中,每次http請求都要創建一個tcp連接,而tcp連接的創建會消耗時間和資源(需要三次握手)。HTTP/1.1中引入了重用連接的機制,就是在http請求頭中加入Connection: keep-alive來告訴對方這個請求響應完成後不要關閉,下次請求可以繼續使用這條鏈接。

HTTP 1.X中一條連接同時只能進行一次請求,也就是說必須一個將上次Request的Response完全讀取之後才能發送下一次Request,而HTTP 2中添加了鏈路複用的機制同時可以發送多個Request。

於是這裏就存在了請求計數和請求數量計算的問題,那麼OkHttp是如何實現的呢?

前面章節中創建或者複用Connect的時候都會調用到RealCall.acquireConnectionNoEvents,將RealCall的弱引用丟到connection.calls裏面,於是就完成了請求的計數:

class RealCall(...) : Call {
    fun acquireConnectionNoEvents(connection: RealConnection) {
        ...
        this.connection = connection
        connection.calls.add(CallReference(this, callStackTrace))
    }
}

internal class CallReference(...) : WeakReference<RealCall>(referent)

有add就有remove,正如我們上面所說,一次請求實際上就是發送一個Request並將它的Response完全讀取。我們用Response.string舉例,它最終是通過Exchange使用socket從服務端讀取的數據:

abstract class ResponseBody : Closeable {
    @Throws(IOException::class)
    fun string(): String = source().use { source ->
        source.readString(charset = source.readBomAsCharset(charset()))
    }
}

class Exchange(...) {
    override fun read(sink: Buffer, byteCount: Long): Long {
        ...
        val read = delegate.read(sink, byteCount)
        ...
        if (read == -1L) {
            complete(null)
            return -1L
        }
        val newBytesReceived = bytesReceived + read
        ...
        if (newBytesReceived == contentLength) {
            complete(null)
        }
        return read
        ...
    }
}

中間的過程過於曲折我就不一步步跟蹤了,大家只要知道最終會調到Exchange.read方法。裏面有兩種情況讀取到-1代表與服務器已經斷開連接,讀取的長度等於Response header裏面的Content-Length字段,代表本次Response的全部數據已經讀取完成。

這兩者都代表這這次請求已經完成,會調用complete方法,最終調到RealCall.releaseConnectionNoEvents將它從connection.calls裏面刪掉:

class Exchange(...) {
    ...
    fun <E : IOException?> complete(e: E): E {
        ...
        return bodyComplete(bytesReceived, responseDone = true, requestDone = false, e = e)
    }
    ...
    fun <E : IOException?> bodyComplete(...): E {
        ...
        return call.messageDone(this, requestDone, responseDone, e)
    }
    ...
}

class RealCall(...) : Call {
    ...
    internal fun <E : IOException?> messageDone(...): E {
        return callDone(e)
    }
    ...
    private fun <E : IOException?> callDone(e: E): E {
        releaseConnectionNoEvents() 
    }
    ...
    internal fun releaseConnectionNoEvents(): Socket? {
        ...
        val calls = connection.calls
        val index = calls.indexOfFirst { it.get() == this@RealCall }
        ...
        calls.removeAt(index)
        ...
        if (calls.isEmpty()) {
            connection.idleAtNs = System.nanoTime()
            ...
        }
        ...
    }
    ...
}

這裏就涉及到兩個使用OkHttp容易不小心出現的錯誤:

  1. Response.string只能調用一次
  2. Response必須被讀取

由於Response.string讀取完成之後這次請求其實就已經結束了,而且OkHttp並沒有對這個結果做緩存,所以下次再讀取就會出現java.lang.IllegalStateException: closed異常。

而我們從上面的流程知道,connection.calls的remove要Response讀取完成後執行,如果我們得到一個Response之後一直不去讀取的話實際上它會一直佔中這這個Connect,下次HTTP 1.X的請求就不能複用這套鏈接,要新建一條Connect。

請求限制

通過connection.calls我們能知道當前有多少個請求在佔用這條connection,所以在連接池裏面就能對次數進行限制。

從前面篇幅我們知道ExchangeFinder是通過RealConnectionPool.callAcquirePooledConnection從連接緩存池查找的Connection:

fun callAcquirePooledConnection(...): Boolean {
    for (connection in connections) {
        synchronized(connection) {
            if (requireMultiplexed && !connection.isMultiplexed) return@synchronized
            if (!connection.isEligible(address, routes)) return@synchronized
            call.acquireConnectionNoEvents(connection)
            return true
        }
    }
    return false
}

connection.isEligible裏面除了判斷address是否相等之外還會判斷請求數量是否已滿:

internal fun isEligible(address: Address, routes: List<Route>?): Boolean {
    ...
    // 連接次數是否已滿,在HTTP 1.X的情況下allocationLimit總是爲1
    if (calls.size >= allocationLimit || noNewExchanges) return false

    // 判斷地址是否線條
    if (!this.route.address.equalsNonHost(address)) return false

    // 判斷host是否相同
    if (address.url.host == this.route().address.url.host) {
        return true // This connection is a perfect match.
    }
    ...
}

allocationLimit在HTTP 1.X的情況下allocationLimit總是爲1就保證了HTTP 1.X的情況下每次只能跑一個請求。

連接的斷開

從上面的流從我們看到,連接在請求完成之後是不會斷開的,等待下次請求複用。如果一直不去斷開的話,就會有一個資源佔用的問題。那麼OkHttp是在什麼時候斷開連接的呢?

其實RealConnectionPool內部會有個cleanupTask專門用於連接的清理

private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
    override fun runOnce() = cleanup(System.nanoTime())
}

它會在RealConnectionPool的put(加入新連接)、connectionBecameIdle(有連接空閒)裏面被調用:

fun put(connection: RealConnection) {
    connection.assertThreadHoldsLock()

    connections.add(connection)
    cleanupQueue.schedule(cleanupTask)
}
  
fun connectionBecameIdle(connection: RealConnection): Boolean {
  connection.assertThreadHoldsLock()

  return if (connection.noNewExchanges || maxIdleConnections == 0) {
    connection.noNewExchanges = true
    connections.remove(connection)
    if (connections.isEmpty()) cleanupQueue.cancelAll()
    true
  } else {
    cleanupQueue.schedule(cleanupTask)
    false
  }
}

cleanupQueue會根據Task.runOnce的返回值等待一段時間再次調用runOnce:

abstract class Task(...) {
  ...
  /** Returns the delay in nanoseconds until the next execution, or -1L to not reschedule. */
  abstract fun runOnce(): Long
  ...
}

這裏的runOnce實際就是cleanup方法,我們看看裏面幹了啥:

fun cleanup(now: Long): Long {
    var inUseConnectionCount = 0
    var idleConnectionCount = 0
    var longestIdleConnection: RealConnection? = null
    var longestIdleDurationNs = Long.MIN_VALUE

    // 找到下一次空閒連接超時的時間
    for (connection in connections) {
      synchronized(connection) {
        // 如果這個connection還在使用(Response還沒有讀完),就計數然後繼續搜索
        if (pruneAndGetAllocationCount(connection, now) > 0) {
          inUseConnectionCount++
        } else {
          idleConnectionCount++

          // 這個連接已經空閒,計算它空閒了多久,並且保存空閒了最久的連接
          val idleDurationNs = now - connection.idleAtNs
          if (idleDurationNs > longestIdleDurationNs) {
            longestIdleDurationNs = idleDurationNs
            longestIdleConnection = connection
          } else {
            Unit
          }
        }
      }
    }

    when {
      longestIdleDurationNs >= this.keepAliveDurationNs
          || idleConnectionCount > this.maxIdleConnections -> {
        // 如果空閒最久的連接比keepAliveDurationNs這個值要大就回收
        val connection = longestIdleConnection!!
        ...
        // 關閉socket
        connection.socket().closeQuietly()
        if (connections.isEmpty()) cleanupQueue.cancelAll()

        // 我們只回收了空閒超時最久的連接,可能還會有其他連接也超時了,返回0讓它立馬進行下一次清理
        return 0L
      }

      idleConnectionCount > 0 -> {
        // 如果有空閒連接,就計算最近的一次空閒超時的時間,去等待
        return keepAliveDurationNs - longestIdleDurationNs
      }

      inUseConnectionCount > 0 -> {
        // 如果所有連接都在使用,就等待這個超時時間去重新檢查清理
        return keepAliveDurationNs
      }

      else -> {
        // 如果沒有連接,就不需要再檢查了
        return -1
      }
    }
}

也就是說這裏面會查找空閒過久的連接,然後關閉它的socket。然後計算下一次進行cleanup的等待時長。

pruneAndGetAllocationCount返回的是正在佔用的請求數,用於檢測連接是否空閒。但是其實它內部還會去回收泄露的Response:

private fun pruneAndGetAllocationCount(connection: RealConnection, now: Long): Int {
    connection.assertThreadHoldsLock()

    val references = connection.calls
    var i = 0
    while (i < references.size) {
      val reference = references[i]

      if (reference.get() != null) {
        i++
        continue
      }

      // We've discovered a leaked call. This is an application bug.
      val callReference = reference as CallReference
      val message = "A connection to ${connection.route().address.url} was leaked. " +
          "Did you forget to close a response body?"
      Platform.get().logCloseableLeak(message, callReference.callStackTrace)

      references.removeAt(i)
      connection.noNewExchanges = true

      // If this was the last allocation, the connection is eligible for immediate eviction.
      if (references.isEmpty()) {
        connection.idleAtNs = now - keepAliveDurationNs
        return 0
      }
    }

    return references.size
}

這裏的"A connection to ${connection.route().address.url} was leaked. Did you forget to close a response body?"指定就是前面請求計數那裏講的容易出現問題的第二點:得到一個Response之後一直不去讀取的話實際上它會一直佔中這這個Connect,具體可能是下面的樣子:

client.newCall(getRequest()).enqueue(new Callback() {
  @Override
  public void onFailure(@NotNull Call call, @NotNull IOException e) {
  }

  @Override
  public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
    // 啥都不幹
  }
});

onResponse傳入的response沒有人去讀取數據,就會一直佔用連接,但是由於它在後面又沒有人引用就會被GC回收導致這條連接再也不能斷開。

pruneAndGetAllocationCount裏面就通過弱引用get返回null的方式去檢查到這樣的異常,進行清理動作。

低版本的清理流程

上面講的是最新版本的清理流程,低版本的流程稍微有點差異但是原理大致相同:

private final Runnable cleanupRunnable = new Runnable() {
    @Override public void run() {
      while (true) {
        long waitNanos = cleanup(System.nanoTime());
        if (waitNanos == -1) return;
        if (waitNanos > 0) {
          long waitMillis = waitNanos / 1000000L;
          waitNanos -= (waitMillis * 1000000L);
          synchronized (ConnectionPool.this) {
            try {
              ConnectionPool.this.wait(waitMillis, (int) waitNanos);
            } catch (InterruptedException ignored) {
            }
          }
        }
      }
    }
  };

會專門爲連接的清理開一條線程用while true的方式不斷檢查,當然類似的會使用wait方法等待cleanup返回的時間,減少cpu佔用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章