OkHttp源码分析之连接池复用

2,180 阅读4分钟

预备知识

通常我们进行HTTP连接网络的时候我们会进行TCP的三次握手,然后传输数据,然后再释放连接。 大量的连接每次连接关闭都要三次握手四次分手的很显然会造成性能低下, 因此http有一种叫做keep-alive connections的机制(HTTP1.1以后默认开启),它可以在传输数据后仍然保持连接, 当客户端需要再次获取数据时,直接使用刚刚空闲下来的连接而不需要再次握手。

OkHttp的复用连接池就是为了复用这些没有断开连接的TCP连接的。

连接池概述

okhttp连接的建立主要是围绕ConnectInterceptor来的,流程为首先通过RealCall的initExchange(chain)创建一个Exchange对象,其中会打开与目标服务器的链接, 并调用 Chain.proceed()方法进入下一个拦截器。 initExchange()方法中会先通过 ExchangeFinder 尝试去 RealConnectionPool 中寻找已存在的连接,未找到则会重新创建一个RealConnection 并开始连接, 然后将其存入RealConnectionPool,现在已经准备好了RealConnection 对象,然后通过请求协议创建不同的ExchangeCodec 并返回,返回的ExchangeCodec正是创建Exchange对象的一个参数。 ConnectInterceptor的代码很简单,主要的功能就是初始化RealCall的Exchange。这个Exchange的功能就是基于RealConnection+ExchangeCodec进行数据交换。

我们主要关注连接的这几个方面,如何放入?如何取出?复用条件?如何清理回收?

核心类介绍

RealConnection

RealConnection 实现了 Connection接口,其中使用 Socket建立HTTP/HTTPS连接,并且获取 I/O 流,内部持有输入和输出流。

如果拥有了一个RealConnection就代表了我们已经跟服务器有了一条通信链路,而且通过RealConnection代表是连接socket链路,RealConnection对象意味着我们已经跟服务端有了一条通信链路了,同一个 Connection 可能会承载多个 HTTP 的请求与响应。

类构造与关键属性:

<!--
class RealConnection(
  val connectionPool: RealConnectionPool,
  private val route: Route
) : Http2Connection.Listener(), Connection {
  //和服务器直接通信的socket实例和用于数据读写的输入输出流
  private var rawSocket: Socket? = null
  private var source: BufferedSource? = null
  private var sink: BufferedSink? = null
  ...
  //引用计数法记录本连接被多少个请求持有,用于回收管理中判断该连接是否空闲
  val calls = mutableListOf<Reference<RealCall>>()
  ...
}
-->

RealConnectionPool

这是用来存储 RealConnection 的池子,内部使用一个双端队列来进行存储。

在 OkHttp 中,一个连接(RealConnection)用完后不会立马被关闭并释放掉,而且是会存储到连接池(RealConnectionPool)中。 除了缓存连接外,缓存池还负责定期清理过期的连接,在 RealConnection 中会维护一个用来描述该连接空闲时间的字段,每添加一个新的连接到连接池中时都会进行一次检测,遍历所有的连接,找出当前未被使用且空闲时间最长的那个连接,如果该连接空闲时长超出阈值,或者连接池已满,将会关闭该连接。

类构造与关键属性:

<!--
class RealConnectionPool(
	taskRunner: TaskRunner,
	//每个空闲Socket的最大连接数,默认为5
	private val maxIdleConnections: Int,
	keepAliveDuration: Long,
	timeUnit: TimeUnit
) {
	//连接保活时间,默认5分钟
	private val keepAliveDurationNs: Long = timeUnit.toNanos(keepAliveDuration)

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

	//RealConnection是Socket对象的包装类,connections也就是对这些连接的缓存
	private val connections = ArrayDeque<RealConnection>()
	
	...
}
-->

ExchangeCodec

ExchangeCodec 的功能就是对http报文的编解码,负责对Request 编码及解码 Response,也就是写入请求及读取响应,我们的请求及响应数据都通过它来读写。

其实现类有两个:Http1ExchangeCodec 及 Http2ExchangeCodec,分别对应两种协议版本。

Exchange

功能类似 ExchangeCodec,但它是对应的是单个请求,其在 ExchangeCodec 基础上担负了一些连接管理及事件分发的作用。 具体而言,Exchange 与 Request 以及ExchangeCodec 一一对应,新建一个请求时就会创建一个 Exchange,该 Exchange 负责将这个请求发送出去并读取到响应数据,而发送与接收数据使用的是 ExchangeCodec。

关键流程解析

如何从连接池取出和放入连接?

调用链

--ConnectInterceptor.intercept
	--RealCall.initExchange
		--ExchangeFinder.find
			--ExchangeFinder.findHealthyConnection
				--ExchangeFinder.findConnection
				       --RealConnectionPool.callAcquirePooledConnection
						...
				--RealConnection.newCodec

通过ExchangeFinder找到或者新建连接

如果看过okHttp以前的源码的话,这里ExchangeFinder.find的功能其实和3.x版本的StreamAllocation.newStream相似:

<!--
class ExchangeFinder(
 ...
) {
	...
	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))
	}
}
-->

我们可以看到这里主要干了:

1.从连接池里面查找可用连接

2.找不到的话创建新的连接RealConnection,连接socket

3.将新连接丢到连接池

4.绑定RealCall和连接RealConnection,也就是将RealConnection内部的引用计数+1

可以用图来描述就是:

从连接池里面查找可用连接

我们详细看一下是如何从连接池获取符合条件的connection并弄清楚连接复用的条件。

<!--RealConnectionPoll.kt
//
fun callAcquirePooledConnection(
	...
): Boolean {
	...
	for (connection in connections) {
		//当需要进行多路复用且当前的连接不是 HTTP/2 连接时,则放弃当前连接
		if (requireMultiplexed && !connection.isMultiplexed) continue
		//当前连接不能用于此次请求传入的address 分配 stream,则放弃当前连接。
		if (!connection.isEligible(address, routes)) continue
		call.acquireConnectionNoEvents(connection)	//将call对象作为弱引用加入connection的calls连接队列中
		return true
	}
	return false
}
-->
<!--RealConnection.kt
//判断连接池已有的Connection是否满足此次新请求的复用条件
internal fun isEligible(address: Address, routes: List<Route>?): Boolean {
	assertThreadHoldsLock()

	//连接次数是否已满,在HTTP 1.X的情况下allocationLimit总是为1,即线头阻塞,
	//前一个请求完全结束后后一个请求才能复用,否则只能新开Connection
	if (calls.size >= allocationLimit || noNewExchanges) return false

	//非Host的地址部分是否相等,内部会比较dns、protocols、proxy、sslSocketFactory、port等
	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.host相同就直接可复用
	}
	if (http2Connection == null) return false
	...
	return true // The caller's address can be carried by this connection.
  }
-->

我们可以得到连接池复用的条件为:

1.当前连接可用请求次数已满不可复用,在HTTP 1.X的情况下allocationLimit总是为1,即线头阻塞,前一个请求完全结束后后一个请求才能复用此连接,否则只能新开Connection

2.非Host的地址部分不相等不可复用,内部会比较dns、protocols、proxy、sslSocketFactory、port等

3.前面满足的前提下,host如果相同可复用

4.如果host不相等,但当前http协议是http2那就需要继续判断其他条件决定是否可复用

这里要特别注意

allocationLimit 在HTTP 1.X的情况下allocationLimit总是为1,保证了HTTP 1.X的情况下每次只能跑一个请求, 也就是说必须一个将上次Request的Response完全读取之后才能发送下一次Request。

但http2在多路复用+二进制帧的加持下是允许一个连接同时被多个请求使用的,允许连续发送多个Request 。

Connection引用计数

创建或者复用Connection的时候都会调用到RealCall.acquireConnectionNoEvents,将RealCall的弱引用丢到connection.calls里面,于是就完成了请求对Connection引用计数+1;

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

有add就有remove,请求完成后,会调用Exchange.complete方法,最终调到RealCall.releaseConnectionNoEvents将引用从connection.calls里面删掉,于是就完成了请求对Connection引用计数-1;

<!--RealCall.kt
  internal fun releaseConnectionNoEvents(): Socket? {
	...
	val calls = connection.calls
	val index = calls.indexOfFirst { it.get() == this@RealCall }//找到当前请求RealCall对应的Connecton.calls列表中的RealCall
	check(index != -1)

	calls.removeAt(index)
	this.connection = null
	...

	return null
  }	
-->

Sockt连接的建立

连接的真正建立其实也是通过Socket来完成的,我们可以简单看一下:

<!--RealConnection.kt
  fun connect(
	connectTimeout: Int,
	readTimeout: Int,
	writeTimeout: Int,
	pingIntervalMillis: Int,
	connectionRetryEnabled: Boolean,
	call: Call,
	eventListener: EventListener
  ) {
	...//检查ssl
	while (true) {
	  try {
		if (route.requiresTunnel()) {
		  connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener)
		  if (rawSocket == null) {
			// We were unable to connect the tunnel but properly closed down our resources.
			break
		  }
		} else {
		  connectSocket(connectTimeout, readTimeout, call, eventListener)//socket连接建立
		}
		establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener)//协议建立
		...
	  }
	}
	...
  }
	private fun connectSocket(
	connectTimeout: Int,
	readTimeout: Int,
	call: Call,
	eventListener: EventListener
  ) {
	...
	val rawSocket = when (proxy.type()) {
	  Proxy.Type.DIRECT, Proxy.Type.HTTP -> address.socketFactory.createSocket()!!
	  else -> Socket(proxy)
	}
	...
	rawSocket.soTimeout = readTimeout
	try {
	  Platform.get().connectSocket(rawSocket, route.socketAddress, connectTimeout)
	} catch (e: ConnectException) {
	  throw ConnectException("Failed to connect to ${route.socketAddress}").apply {
		initCause(e)
	  }
	}
	...
	try {//拿到输入输出流
	  source = rawSocket.source().buffer()
	  sink = rawSocket.sink().buffer()
   }
   ...
  }
-->

Sockt连接的断开

由于我们需要复用连接,因此Socket连接并不是在请求完成后就断开的,如果空闲连接数在允许范围内(默认5个),他会保持空闲存活keep-alive的时间后(默认5分钟)由okhttp的自动清理机制进行清理并关闭。具体分析见下面的空闲连接清理部分。

如何清理空闲连接?

从上面的复用机制我们看到,socket连接在上一次请求完成之后是不会断开的,等待下次请求复用。 如果一直不去断开的话,就会有一个资源占用的问题。

那么OkHttp是在什么时候断开连接的呢? 其实RealConnectionPool内部会有个cleanupTask专门用于连接的清理,它会在RealConnectionPool的put(加入新连接)、connectionBecameIdle(有连接空闲)里面被调用。

<!--RealConnectionPool.kt
  private val cleanupQueue: TaskQueue = taskRunner.newQueue()
  private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
    override fun runOnce() = cleanup(System.nanoTime())
  }
  //加入新连接
  fun put(connection: RealConnection) {
    ...
    cleanupQueue.schedule(cleanupTask)
  }
  //有连接空闲,对应引用计数的-1,也就是RealCall.releaseConnectionNoEvents
  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, 这样设计是为了在本次执行清理后,拿到最近一个需要清理的连接到期剩余的时间,方便第一时间将过期的连接清理掉。 这里的runOnce实际就是cleanup方法,这里面会查找空闲过久的连接,然后关闭它的socket:

<!--RealConnectionPool.kt
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
      }
    }
}
-->

这里面主要是干了这几个事情:

1.遍历缓存池中所有的Connection,根据pruneAndGetAllocationCount计算每个Connection被多少个请求引用决定该Connection是否要进入回收判断逻辑, 如果需要被回收,得到空闲时间最长的Connection和时间

2.对比刚才收集到的最长时间的Connection和keepAlive的时间

3.对比当前空闲的数量和连接池允许的最大的空闲数量

4.对满足23条件的Connection进行Socket断开操作,并且返回0马上进行下一次cleanup回收,因为我们只回收了空闲时间最久的连接

5.如果有空闲连接,但是还没到最大空闲时间,那就返回时间差值,等待这个时间后再次执行cleanup回收

6.如果没有空闲连接,就等待keepAlive时间后再次进行检查

7.如果没有连接,就返回-1不检查了

pruneAndGetAllocationCount返回的是正在占用的请求数,用于检测连接是否空闲,prune有修剪的意思, 除了计算被引用的次数外,内部遍历RealConnection的allocations弱引用列表,修剪并移除掉RealConnection.calls引用计数列表中已经内存回收的RealCall对应的弱引用本身 Refrence,这里巧妙利用了弱引用的原理,类似WeakedHashMap:

<!--RealConnectionPool.kt
  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) {//如果得到的为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
  }
-->

什么情况下会发生reference.get()==null的情况呢?

既然前面提到RealCall.releaseConnectionNoEvents中会主动对引用计数进行remove,那什么时候才会发生泄漏的情况呢?比如得到一个Response之后一直不去读取的话实际上它会一直占中这个RealConnection,具体可能是下面的样子:

<!--
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的方式去检查到这样的异常,进行清理动作。

我们也可以联想到OkHttp常常需要注意的两个问题的原因:

1.Response.string只能调用一次

由于Response.string读取完成之后这次请求其实就已经结束了,而且OkHttp并没有对这个结果做缓存, 所以下次再读取就会出现java.lang.IllegalStateException: closed异常

2.Response必须被及时读取

如果我们得到一个Response之后一直不去读取的话实际上它会一直占中这这个Connect,下次HTTP 1.X的请求就不能复用这套链接,要新建一条Connection

遗留问题

:由于allocationLimit限制了同一个RealConnection只能有一个请求同时使用, 那就是说Http1.1的管道化piepling其实在okhttp中没有应用?