【Android】OkHttp 源码剖析系列(六)——连接复用机制及连接的建立


findConnection 的过程中无法从 transmitter 中取得 Connection 时,会调用 connectionPool.transmitterAcquirePooledConnection 方法来尝试从连接池中获取连接,让我们从这篇文章开始研究一下 OkHttp 中连接复用机制的实现,以及连接的建立过程。

本源码剖析系列基于 OkHttp 3.14

文章目录:

【Android】OkHttp 源码剖析系列(一)——请求的发起及拦截器机制概述

【Android】OkHttp 源码剖析系列(二)——拦截器大体流程分析

【Android】OkHttp 源码剖析系列(三)——缓存机制

【Android】OkHttp 源码剖析系列(四)——连接的建立概述

【Android】OkHttp 源码剖析系列(五)——路由选择与代理机制

【Android】OkHttp 源码剖析系列(六)——连接复用机制及连接的建立

【Android】OkHttp 源码剖析系列(七)——请求的发起及响应的读取

HTTP 中的复用机制

HTTP/1.0

在 HTTP/1.0 中,由于 HTTP 协议是一种无连接的网络协议,进行一次 HTTP 请求是这样的一条流程:

image-20190803100926274

这样设计可以保证每条 HTTP 请求都是独立的,互不干扰。但这样的设计有一个致命的缺点——如果我们向同一个服务器发起数十个 HTTP 请求,则我们的每条 HTTP 请求都需要与这个服务器建立一条 TCP 连接。而我们知道,建立 TCP 连接需要经过三次握手,而关闭 TCP 连接则需要四次挥手,可想而知这样频繁地建立与关闭 TCP 连接对网络资源的消耗是十分严重的,极大地降低了网络的效率,并且提高了服务器的压力。

在 HTTP/1.0 中存在一个名为 Connection:Keep-Alive 的 Header,但没有官方的标准规定其工作机制,它默认是关闭的,可以通过在 Header 中加入从而开启。当客户端及服务端都对 Keep-Alive 机制支持时,就可以维持该 TCP 连接从而使得下一次可以进行复用。

HTTP/1.1

而在 HTTP/1.1 中,真正引入了 Keep-Alive 机制,它默认是开启的,可以通过 Connection:close 进行关闭。在 HTTP 请求结束时,若启动了 Keep-Alive 机制,则该连接并不会立即关闭,此时如果有新的请求到来,且 host 相同,则会复用这条 TCP 连接进行请求,减少了 TCP 连接的频繁建立与关闭的资源消耗。

image-20190803103322539

通过这样的连接复用的做法,可以大幅地减少对资源的消耗,如下图所示:

同时,在 HTTP/1.1 中还引入了 Keep-Alive 请求头,在其中可以设定两个值:timeoutmax ,从而设定这个连接何时被关闭。

  • timeout:指定了一个空闲连接需要保持打开状态的最小时长(以秒为单位)
  • max:在连接关闭之前,在此连接可以发送的请求的最大值

但这样就存在了一个问题,在原来不采用 Keep-Alive 的时候,客户端可以通过 TCP 连接是否关闭来判断数据是否接收完成,但在采用了 Keep-Alive 的情况下,客户端如何才能得知自己需要的数据已经接收完毕了呢?

Content-Length

看过我之前的多线程下载的实现博文的读者,应该知道在服务端的 ResponseHeader 中,会包含 Content-Length 这一字段,它表示了实体内容的长度(比如文件 / 图片的大小),通过该字段客户端就可以确定自己需要接受的字节数。从而确认数据已接收完成。

Transfer-Encoding:chunked

前面的 Content-Length 看上去完美解决了无法判断数据接收完毕的问题。但对于一些动态的场景,比如一些动态页面,服务端是无法预先知道该页面的大小的,在该页面创建完成前,其长度是不可知的,服务端也就无法返回一个确切的 Content-Length 字段给客户端了,只能开启一个足够大的 buffer。

此时,就可以采用 Transfer-Encoding:chunked 来实现,它表示一种分块编码的意思,它只在 HTTP/1.1 中提供,允许服务端将发送给客户端的数据分成多个部分。

如果使用了分块编码,则请求及响应有以下的特点:

  1. 在 Header 中加入 Transfer-Encoding:chunked,表示使用分块编码
  2. 每一个分块有两行,每一行都以 \r\n 结尾,第一行表示这个分块的数据长度,是一个十六进制的数(不包括数据结尾的 \r\n,第二行则是这个分块的具体数据。
  3. 最后一个分块长度为0,且数据没有内容,表示整个实体的结束。

HTTP/2

在前面的 HTTP/1.1 中,虽然实现了 TCP 连接的复用,但仍有如下几个缺陷:

  1. 如果客户端想要发起并行的请求,则必须建立多个 TCP 连接,这对网络资源的消耗也是十分严重的。
  2. 不会读对请求及响应的 Header 进行压缩,造成了网络流量的浪费。
  3. 不支持资源优先级导致 TCP 连接利用率低下。

多路复用

为了解决上面几个问题,HTTP/2 引入了多路复用机制,同时引入了几个新的概念:

  • 数据流:基于 TCP 连接上的一个双向的字节流,每发起一个请求,就会建立一个数据流,后续的请求过程的数据传递都通过该流进行
  • 数据帧:HTTP/2 中的数据最小切片单位,其中又分为了 Header FrameData Frame 等等。
  • 消息:一个请求或响应对应的一系列数据帧。

引入了这些概念之后,在 HTTP 请求的过程中,服务端/客户端首先会将我们的请求/响应切分为不同的数据帧,当另一方接收到后再将其组装从而形成完整的请求/响应,如下所示

image-20190803114234258

这样,就实现了对 TCP 连接的多路复用,将一个请求或响应分为了一个个的数据帧,使得多个请求可以并行地进行。

多路复用与 Keep-Alive 的区别

  1. Keep-Alive 机制虽然解决了复用 TCP 连接问题,但没有解决请求阻塞的问题,需要等到上一个请求结束后,才能复用该 TCP 连接进行下一个请求。
  2. HTTP/1.x 对数据的传递仍然是以一个整体进行传递,而在 HTTP/2 中引入了数据帧的概念,使得多个请求可以同时在流中进行传递。
  3. HTTP/2 采用了 HPACK 压缩算法对 Header 进行压缩,降低了请求的流量消耗。

img

OkHttp 中的复用机制

前面提了 HTTP 中的复用机制,通过对 TCP 连接的复用,大幅提高了网络请求的效率。无论是 HTTP/1.1 中的 Keep-Alive 还是 HTTP/2 中的多路复用,都需要连接池来维护 TCP 连接,让我们看看 OkHttp 中连接池的实现。

我们知道,在 findConnection 过程中,若无法从 transimitter 中获取到连接,则会尝试从连接池中获取连接。

我们可以看到 RealConnectionPool.connections,它是一个 Deque,保存了所有的连接:

连接清理机制

同时会发现,在这个类中还存在着一个 executor,它的设置与 OkHttp 用于异步请求的线程池的设置几乎一样,它是用来做什么的呢?

通过上面的注释可以看出,它是用来执行清理过期连接的任务的,并且最多每个连接池只会有一个线程在执行清理任务。这个清理的任务就是下面的 cleanupRunnable

可以看到它是采用一个循环的方式调用 cleanup 方法进行清理,并从返回值中获取了需要 wait 的秒数,调用 wait 方法进入阻塞,也就是说每次清理的间隔由 cleanup 的返回值进行决定

我们看到 cleanup 方法:

可以看到,主要是下面几步:

  1. 调用 pruneAndGetAllocationCount 方法统计连接被引用的数量,大于 0 说明连接正在被使用
  2. 通过上面的方法统计空闲连接数及正在使用的连接数,并从中找出空闲最久的连接
  3. 若空闲最久的连接空闲的时间超过了所设定的 keepAliveDurationNs(这里不是指的 Keep-Alive 所设定时间),或者空闲连接数超过了所设定的 maxIdleConnections,清理该连接(移除并关闭socket),并返回 0 表示立即继续清理。
  4. 若还未超过,则返回下一次超过外部设定的 keepAliveDurationNs,表示等到下次超时的时候再进行清理
  5. 若当前连接都正处于使用中,返回所设定的 keepAliveDurationNs
  6. 若当前没有连接,则将 cleanupRunning 置为 false 停止清理

在 OkHttp 中,将空闲连接的最长存活时间设定为了 5 分钟,并且将最大空闲连接数设置为了 5

我们看看 pruneAndGetAllocationCount 是如何对连接被引用的数量进行统计的:

可以看到,connection 中是有维护一个引用它的 TransmitterReference 队列的,通过遍历并判断该 Transimitter 是否为 null 即可进行统计。这里的 Reference 所存的实际是一个继承自 WeakReferenceTransimitterReference 类:

可以发现,这种设计有点像 JVM 中的引用计数法 + 标记清除,实际上就是 OkHttp 仿照 JVM 的垃圾回收设计了这样一种类似引用计数法的方式来统计一个连接是否是空闲连接,同时采用标记清除法对空闲且不满足设定的规则的连接进行清除

获取连接

我们看到 connectionPool.transmitterAcquirePooledConnection 方法,了解一下连接池获取连接的过程:

可以看到,首先注释中对我们传入不同的 routes 参数进行了解释,若 routes 不为 null 说明这是已解析过的路由,可以将其合并到同一个 HTTP/2 连接。

而在 connection.isMultiplexed 的注释中说到,若该连接为 HTTP/2 连接,则会返回 true。

connection.isEligible 注释中则说到,若该连接可以给对应的 address 分配 stream,则返回 true。

在代码中,对 connections 进行了遍历:

  1. 当需要进行多路费用且当前的连接不是 HTTP/2 连接时,则放弃当前连接
  2. 当当前连接不能用于为 address 分配 stream,则放弃当前连接。
  3. 前两者都不满足,则获取该连接,并设置到 transimitter 中。

三次获取连接的区别

我们回顾一下 findConnection 中三次尝试从连接池获取连接的过程:

  • 第一次尝试:connectionPool.transmitterAcquirePooledConnection(address, transmitter, null, false)
  • 第二次尝试(需要在进行了路由选择的情况下):connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, false)
  • 第三次尝试:connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, true)

可以发现,其传入的参数是不同的。第一次由于是尝试从已经解析过的路由的连接池中获取连接,因此 route 设置为 null。

第二次由于是在无法找到对应的连接,在进行了路由选择的条件下进行的,因此将 route 设置为了 null。

而最后一次尝试从连接池获取连接之所以需要将 requireMultiplexed 设置为 true,因为这次只有可能是在多个请求并行进行的情况下才有可能发生,这种情况只有 HTTP/2 的连接才有可能发生。

加入连接

通过 RealConnectionPool.put 方法可以向连接池中加入连接:

由于之前判断了如果连接池中没有连接,就会暂停连接清理线程,所以这里如果放入了新的连接,就会判断连接清理线程是否正在执行,若已停止执行则将其继续执行。之后将该连接放入了 Deque 中。

通知连接空闲

每当外部调用了 Transimitter.releaseConnectionNoEvents 方法时,最后都会调用到 RealConnection.connectionBecameIdle 方法来通知连接池连接进入了空闲状态:

此时如果该连接不支持用于创建新 Exchange,或不允许有空闲连接,则会直接将该连接移除,否则会通过 notifyAll 方法唤醒阻塞的清理线程,尝试对空闲连接进行清理,这样能保证每当有空闲连接时最及时地对连接池进行清理。

连接的建立

我们知道,在寻找连接的过程中,若从 Transimitter 及连接池中都无法获取到连接时,就会创建一个新的连接,让我们看看这个创建连接的过程是怎样的:

在寻找连接的代码中,创建连接的核心代码如下:

我们先看到 RealConnection 的构造函数:

只是进行了简单的赋值,我们接着看到 RealConnection.connect 方法:

可以看到,这里是一个循环,不断尝试建立连接,其中核心步骤如下:

  1. 若使用了隧道技术,调用 connectTunnel 方法
  2. 若未使用隧道技术,调用 connectSocket 方法
  3. 调用 establishProtocol 方法建立协议

让我们看看三个方法分别是如何实现的。

直接连接

我们先看看直接连接是如何实现的,我们看到 connectSocket 方法:

可以看到,这里主要是进行 Socket 的连接,首先根据代理类型创建了 Socket,之后调用了 connectSocket 方法进行连接(里面调用的其实仍然是 socket.connect 方法)。最后调用 Okio 的方法获取 sourcesink

这个过程还是比较简单的,和正常使用 Socket 的流程大致相同:创建Socket=>连接=>获取 stream,其中在 connectSocket 时根据不同平台做了不同的处理。

通过隧道连接

首先我们要理解一下什么是隧道。这个其实是计网中的知识,之前在 《计算机网络——自顶向下方法》中看到过,不过书中没有详细介绍,这里刚好学习一下。

隧道技术的出现主要是为了适配 IPv4 到 IPv6 的转变。通过这种隧道技术,可以通过一种网络协议来传输另外一种网络协议的数据,比如 A 主机与 B 主机都是采用 IPv6,而连接 A 与 B 的是 IPv4 的网络,为了实现 A 与 B 的通信,可以使用隧道技术,数据包经过 IPv4 的多协议路由时将 IPv6 的数据包放入 IPv4 的数据包中,传递给 B。当到达 B 的路由器时,数据又被剥离之后传递给 B。这样在 A 与 B 看来,它们使用的都是 IPv6 与对方通信。如下图所示:

image-20190803160002636

那么怎么打开隧道呢?

HTTP 提供了一个特殊的 method—— CONNECT,它是 HTTP/1.1 协议中预留的方法,可以通过它将连接改为隧道的代理服务器。客户端发送一个 CONNECT 请求给隧道网关请求打开一条 TCP 连接,当隧道打通之后,客户端通过 HTTP 隧道发送的所有数据会转发给 TCP 连接,服务器响应的所有数据会通过隧道发给客户端。

而在 OkHttp 中,对隧道的支持主要是为了支持 SSL 隧道——SSL 隧道的初衷是为了通过防火墙来传输加密的 SSL 数据,此时隧道的作用就是将非 HTTP 的流量(SSL流量)传过防火墙到达指定的服务器(比如 HTTPS)。

接着我们看到 connectTunnel 方法的实现:

这里首先构建了一个隧道的 tunnelRequest。之后进行了循环,不断尝试建立隧道,不过 OkHttp 限制了其最大尝试次数为 21 次。

建立隧道的过程首先通过 connectSocket 方法建立了 Socket 连接,然后通过 createTunnel 方法建立隧道。

我们看看 createTunnelRequest 方法做了什么:

可以看到,这里构建了一个 method 为 CONENCT 的请求。

我们接着看看 createTunnel 方法又做了什么事情:

可以看到,这里主要进行如下的工作:

  1. 拼接 HTTP/1.1 请求
  2. 发出隧道请求,读取响应
  3. 若隧道请求返回 200,说明隧道建立成功,返回 null
  4. 若隧道返回 407,说明服务器需要进行代理认证,调用对应方法进行代理认证

隧道打通之后,就可以通过隧道进行网络请求了。

发布协议

经过前面的步骤,我们建立了一条与服务端的 Socket 通道,我们接着看到 establishProtocol 方法:

可以看到,这个方法主要是在建立了 Socket 连接的基础上,对各个协议进行支持。

首先判断了当前地址是否是 HTTPS 地址。

不是 HTTPS 的情况下,若协议中包含了 H2_PRIOR_KNOWLEDGE 则采用 HTTP/2 进行请求,调用 startHttp2 方法,否则采用 HTTP/1.1。

是 HTTPS 的情况下,首先调用了 connectTls 方法进行 TLS 握手,之后若是 HTTP/2 协议,则调用 startHttp2 方法。

启动 HTTP/2 连接

让我们先看看 startHttp2 方法究竟是做了什么:

这里主要是构建了一个 HTTP/2 的 Http2Connection,并且将 listener 设置为了该 RealConnection,之后通过 http2Connection.start 方法启动了 HTTP/2 连接。

这里 sendConnectionPreface 默认为 true,它首先调用了 writer.connectionPreface 方法,之后调用了 writer.settings 方法。最后,启用了一个 readerRunnable 的读取线程。

在 HTTP/2 中,每个终端都需要发送一个连接 preface 作为在使用的协议的一个最终的确认,并为 HTTP/2 连接建立初始的设定。客户端和服务器相互发送一个不同的连接 preface。

连接 preface 以字符串 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 开始,这个序列后面必须跟着一个 SETTINGS 帧。因此,在之后又调用了 writer.settings 方法,写入 SETTINGS 帧。

我们先看到 connectionPreface 方法:

这里实际上是向 HTTP/2 连接的 Socket 中写入了 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 这一字符串。之后我们看到 writer.settings 方法:

这里主要是写入了一些配置的数据,其中调用了 frameHeader 写入了帧头。

最后我们看到 readerRunnable.execute

可以看到,这里主要是调用了 reader.readConnectionPreface 方法读取服务端发送来的 preface,并判断是否为对应字符串,从而完成 HTTP/2 连接的启动。

TLS 握手

接着我们看到 TLS 握手的过程,让我们看看 connectTls 方法:

可以看到,这里的步骤主要是下列步骤:

  1. 基于之前建立的 Socket 建立包装类 SSLSocket
  2. 对 TLS 相关信息进行配置
  3. 通过 SSLSocket 进行握手
  4. 验证一些证书相关信息
  5. 获取 sourcesink

总结

OkHttp 中采用了连接池机制实现了连接的复用,避免了每次都创建新的连接从而导致资源的浪费。获取连接的过程主要如下:

  1. 尝试在 transimitter 中寻找已经分配的连接
  2. transimitter 中获取不到,尝试从连接池中获取连接
  3. 连接池中仍然获取不到,尝试进行一次路由选择,再次从连接池中获取连接
  4. 连接池中仍然找不到需要的连接,则创建一个新的连接
  5. 由于 HTTP/2 下采用了连接的多路复用机制,所以连接可以并行进行,因此再次尝试从连接池中获取连接,获取到则丢弃创建的连接
  6. 若连接池中仍获取不到连接,则将刚刚创建的连接放入连接池

其中,在连接池中采用了一个清理线程对超过了设定参数的空闲连接进行清理,每次清理后会计算下一次需要清理的时间并进入阻塞,每当有新连接进入或连接进入空闲时会重新唤醒该清理线程。

对于每个连接,都采用了一种类似 GC 中的引用计数法的形式,每个 RealConnection 都持有了使用它的 Transimitter 的弱引用,通过判断持有的弱引用个数从而判断该连接是否空闲。

OkHttp 默认将最大存活空闲连接个数设置为了 5,且每个连接空闲时间不能超过 5 分钟,否则将被清理线程所回收

而在连接建立过程中,首先会判断该连接是否需要 SSL 隧道,若不需要则直接建立了 Socket 并获取了其 sourcesink,若需要则会先尝试建立 SSL 隧道,最后再进行 Socket 连接。

Socket 连接建立成功后,会通过 establishProtocol 方法对每个协议进行不同的处理,从而对各个协议进行支持(如对 HTTPS 的支持)

参考资料

Keep-Alive

【HTTP】keep-alive

HTTP Keep-Alive模式

okhttp连接池复用机制

Okhttp对http2的支持简单分析


Android Developer in GDUT