OkHttp 并发模型解析:HTTP/2 连接的三类线程、三把锁与连接池的线程安全设计
【免费下载链接】okhttpA meticulous HTTP client for the JVM, Android, and GraalVM.项目地址: https://gitcode.com/gh_mirrors/okh/okhttp
本文基于 OkHttp 官方的并发设计文档(docs/contribute/concurrency.md),结合okhttp3.internal.http2与okhttp3.internal.connection包下的真实实现,系统讲解 OkHttp 如何在 http/2 分帧协议之上对外暴露阻塞式 API,以及连接池如何做到线程安全。读完本文,你将能理解 OkHttp 内部线程的分工边界(谁读 socket、谁写 socket、谁跑应用代码)、三把核心锁的持有规则与加锁顺序约定,并能在阅读 Http2Connection、Http2Stream、RealConnectionPool 等源码时,对每一处withLock、wait()与异步队列调用"知其所以然"。
一、问题的起点:阻塞 API 与分帧协议的矛盾
1.1 阻塞式 API 的价值与代价
OkHttp 对外提供的是阻塞式的 HttpURLConnection 风格 API:发起请求是一次阻塞写,接收响应是一次阻塞读。文档明确指出了这种 API 的取舍:
- 便利:自上而下的过程式代码,没有回调间接层。网络调用就像普通方法调用——要数据就返回数据;请求失败时,
IOException的堆栈直接出现在调用发生的位置,异常传播路径清晰(即所谓 exception transparency)。 - 代价:等待网络期间线程处于闲置状态。线程既有内存开销也有上下文切换开销,长时间空等不经济。
1.2 分帧协议为何天然不适合单线程阻塞 I/O
http/2 这类分帧(framed)协议无法简单地"在一个阻塞线程上正确实现",核心原因有两条:
- 流与 socket 是多路复用的。每个应用层线程都想对自己那条 stream 做阻塞 I/O,但所有 stream 共享同一个 socket。你不能直接和 socket 说话,必须与共享该 socket 的其他应用层线程协作。
- 流控规则制造了读写之间的反馈环。流控要求"写必须确认读、读必须节流写":读到的字节需要向对端回发
WINDOW_UPDATE确认;写端则要在发送窗口耗尽时阻塞等待。这种读写相互依赖的语义,在一个既读又写的单一阻塞线程上是不可实现的——线程读的时候无法同时确认自己的读,写的拥塞也无法被自身的读解除。
OkHttp 的解法是:在分帧协议之上包装出阻塞 API。这背后是一整套线程分工与锁策略,下文逐一展开,每一节都有对应源码佐证。
二、线程模型:调用线程、共享读线程与"稍后执行"线程池
OkHttp 的 http/2 实现中运行着三类角色线程,边界划分得非常严格。
2.1 应用调用线程:写必须同步完成,读允许阻塞
应用层调用线程的行为规则有两条,都直接决定阻塞 API 的正确性:
- 写 I/O 必须阻塞。写方法只有在字节真正推上 socket 之后才能返回。否则一旦写失败,OkHttp 就无法把这次
IOException交付给发起写的应用代码——表面上已经告诉应用"写成功了",实际上字节根本没发出去,这就破坏了阻塞 API 的异常透明性。 - 读 I/O 可以阻塞。应用请求读数据但当前无字节可读时,需要扣住该线程,直到字节到达、流关闭或超时发生。如果字节到了却没有人在读,就先缓冲起来;在应用消费之前,这些字节不算已完成流控确认。
文档中给出的视频流场景非常直观:用户暂停视频、应用停止从 stream 读字节,缓冲区填满,流控自动阻止服务端继续发送;用户恢复播放后缓冲区排空、读操作被确认(发送WINDOW_UPDATE),服务端继续传输。
这一机制在 Http2Connection.writeData 中有精确实现:写入前先检查连接级写窗口,耗尽时在连接锁上wait(),直到读者线程处理WINDOW_UPDATE帧后notifyAll()唤醒;而读侧字节何时算"被确认",由 updateConnectionFlowControl 管理——只有当未确认字节达到初始窗口一半时才异步回发WINDOW_UPDATE,且只有应用真正消费后acknowledged计数才推进。
2.2 共享读线程:每个 socket 一个,且只干一件事
应用线程是"临时工":有时在读写,有时在做别的应用层事情。但 socket 是长期的,需要持续有人处理。因此OkHttp 为每个 socket 启动一个专用读线程,职责只有读帧并分派。
在源码中,这个读线程由 Http2Connection.start 启动:把readerRunnable提交到 TaskRunner 队列执行。ReaderRunnable的 invoke() 就是一个死循环:读取连接前导(preface),然后while (reader.nextFrame(...))逐帧分发,直到连接出错或关闭。
文档对读线程有两条铁律,源码逐条印证:
铁律一:读线程绝不能运行应用层代码。否则一条慢 stream 就能卡死整条连接。观察ReaderRunnable的headers()回调:发现远端新 stream 时,它只在连接锁内登记 stream,然后把listener.onStream(newStream)这样的应用回调通过taskRunner.newQueue().execute(...)派发到每条 stream 独立的后台队列(Http2Connection.kt L698-L707),注释写明"每条 stream 用独立任务队列,因为各 stream 应当并行处理"。读线程本身只是登记与转发。
铁律二:读线程绝不能阻塞在写操作上,否则会死锁连接。文档构造了一个经典死锁场景:客户端和服务端都违反这条规则,双方 TCP 缓冲区被写满(写开始阻塞),此时各自用读线程去写帧——两端都没有人在读了,缓冲区永远排不空,永久互锁。
源码的对策是:ReaderRunnable中所有需要写帧的动作全部异步化,读线程只投递任务、不亲自写:
- 收到
SETTINGS帧 →writerQueue.execute(...)异步applyAndAckSettings,注释专门解释了为什么必须在 writer 线程上原子地"应用设置 + 回 ACK"(某些实现对未 ACK 前就使用新设置的帧会报错),并依赖 writer 任务队列不重排来保证设置按接收顺序生效(Http2Connection.kt L728-L799); - 收到对端
PING→writerQueue.execute(...)异步回 pong; - 需要回
RST_STREAM或WINDOW_UPDATE→ 走writeSynResetLater/ writeWindowUpdateLater。
2.3 "稍后执行"线程池(do-stuff-later pool)
有时发现某件"该做的事"的线程,并不是"应该做这件事"的线程——比如读线程发现要回 ping、要回窗口确认,或要回调应用层。此时把 runnable 入队,交给线程池中的某个线程去执行。
在实现里这个角色由TaskRunner(位于okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/concurrent/包)承担。Http2Connection为不同语义建了多个有序队列,各自承担不同用途:
| 队列 | 创建位置 | 用途 |
|---|---|---|
writerQueue | taskRunner.newQueue() | 一切异步写帧:回 ping、RST_STREAM、WINDOW_UPDATE、应用并 ACK 对端设置、周期性 ping |
pushQueue | taskRunner.newQueue() | 保证 push promise 相关回调按 stream 顺序投递给PushObserver |
settingsListenerQueue | taskRunner.newQueue() | 按序通知Listener.onSettings |
| 每 stream 独立队列 | taskRunner.newQueue() | 新入站 stream 的onStream回调,各 stream 之间并行 |
把"应用回调"与"写帧"分到不同队列,正是"读线程不碰应用代码、不碰阻塞写"这两条铁律在任务调度层的落地。
三、三把锁:各自的保护范围与持有规则
OkHttp 在 http/2 连接上同步了三类对象,文档称之为 3 个锁,源码中全部是Lockable接口 +withLock的显式封装。
3.1 Http2Connection 锁:保护连接内部状态,永不持有做阻塞操作
Http2Connection 类顶部的注释是全文档最浓缩的一条契约:
Internal state of this connection is guarded by
lock. No blocking operations may be performed while holding this lock!(连接内部状态由 lock 保护;持锁期间禁止执行任何阻塞操作)
"永不持有做阻塞操作"意味着:拿锁 → 读写几个字段 → 放锁,全程无 I/O、无应用层回调。所有对streams映射、nextStreamId、writeBytesMaximum等字段的访问都包在极短的withLock { }中。
3.2 Http2Stream 锁:保护 stream 状态,wait/notify 就发生在它上面
每条 stream 有自己的锁(Http2Stream 注释同样声明:持锁期间不执行长耗时或可能阻塞的操作)。当需要扣住应用线程做阻塞读时,利用的正是在 stream 锁上wait():wait()期间锁被释放,字节到达后读者侧(经读线程或异步任务)notifyAll()唤醒。
具体落点:
- Http2Stream.waitForIo() 就是对
this锁的wait()薄封装,被中断时转抛InterruptedIOException; - takeHeaders() 中,应用线程在 headers 队列为空且无错误时循环
waitForIo(),直到receiveHeaders入队并notifyAll(); FramingSource.read()同样在 synchronized 块内阻塞等待可读字节,并在读窗口耗尽时通过连接级wait()等待WINDOW_UPDATE。
3.3 Http2Writer 锁:保护 socket 写,是唯一可以持锁做阻塞 I/O 的锁
socket 写由Http2Writer保护。因为同一时刻只允许一条 stream 在写,消息才不会交错。Http2Writer 实现了Lockable,每个写帧方法(connectionPreface()、headers()、data()、ping()等)都以withLock { }包裹完整的"组帧 + 写 sink + 必要时 flush"流程——也就是说持有 writer 锁时确实在做阻塞 I/O,这是它和前两把锁的本质区别。
发起写的线程只有两类:应用调用线程(同步路径,如writeData中调用writer.data(...))和 do-stuff-later 线程池(异步路径,如writerQueue里的任务)。
四、多锁顺序约定:Writer 锁可先于 Connection 锁,反之绝对禁止
这是整份并发文档最关键的一条规则:
允许持着 Http2Writer 锁去拿 Http2Connection 锁,但不允许持着 Http2Connection 锁去拿 Http2Writer 锁。因为 Http2Writer 锁可以阻塞。
为什么需要这种嵌套?文档给出的场景是新建 stream 时的簿记:正确分帧要求 socket 上的 stream ID 严格顺序递增,因此"分配 ID"和"发送该 stream 的首个帧"必须捆绑为原子操作——分配了 ID 却还没把帧写出去,或者 ID 被并发地跳号,都会破坏协议。
对照 Http2Connection.newStream 的实现,嵌套结构一字不差:
writer.withLock { // 外层:writer 锁(可阻塞 I/O) withLock { // 内层:connection 锁(只改字段,绝不阻塞) streamId = nextStreamId nextStreamId += 2 stream = Http2Stream(streamId, ...) streams[streamId] = stream } writer.headers(outFinished, streamId, requestHeaders) // 写首帧 }类头注释(Http2Connection.kt L62-L66)把这条约定上升为通用原则:"某些操作(如 SYN_STREAM)需要同时同步 frameWriter(做阻塞 I/O)和 this(创建 stream),此类操作必须最后同步this,确保永远不在持有this时等待阻塞操作。" 同样的writer.withLock { withLock { ... } }模式也出现在 shutdown()、setSettings() 与applyAndAckSettings()中,说明它不是孤例而是贯穿全连接的一致性写法。
(注:文档写作于 HTTP/2 草案期,彼时首帧为SYN_STREAM;当前实现中客户端首帧已按规范演进为由writer.headers(...)发送的HEADERS帧,但"分配 ID 与首帧发送必须原子"的原则完全一致。)
五、流控闭环:读写反馈环在源码里如何运转
前面说流控"要求写确认读、读节流写",把它和线程模型拼起来,整个环是这样闭合的:
- 读 → 确认读:应用消费字节后,
updateConnectionFlowControl(read)更新readBytes计数器;未确认量达到初始窗口一半时,writeWindowUpdateLater把WINDOW_UPDATE投递到writerQueue,由池线程完成真正的 socket 写。 - 对端窗口 → 解除写阻塞:读线程处理对端的
WINDOW_UPDATE帧,在连接锁内增加writeBytesMaximum并notifyAll()(ReaderRunnable.windowUpdate),正在writeData中wait()的写线程随即醒来继续推数据。 - 写 → 节流读:写端受
writeBytesMaximum约束,窗口耗尽即阻塞,间接限制了应用灌入 socket 的速度。
两个值得注意的实现细节:
- 客户端窗口调优:客户端构造时把
INITIAL_WINDOW_SIZE提到 16 MiB(OKHTTP_CLIENT_WINDOW_SIZE,Http2Connection.kt L111-L119)。源码注释解释了动机:流控更面向服务端/代理,边缘客户端若沿用默认 64 KiB 窗口会频繁"抖动"窗口更新;16 MiB 既避免抖动,又不至于撑爆堆内存。 - 超时与降级 ping:stream 读超时由 StreamTimeout(okio 的
AsyncTimeout)看门狗触发,超时回调closeLater(CANCEL)异步发RST_STREAM(不阻塞等待写锁),同时connection.sendDegradedPingLater()发一个降级 ping,用来区分"单条 stream 超时"与"整条连接已坏"——这在 Http2Connection 的注释中有完整推理。
六、连接池的并发设计:无锁队列 + 每连接一把锁
6.1 职责与四个核心类
连接管理是任何 HTTP 客户端的首要职责:新建连接(尤其 TLS 握手)开销与延迟显著,OkHttp 会尽一切努力复用连接。每个OkHttpClient持有一个连接池,请求发起时先从池中找可复用连接,找不到才新建并放入池中。关键差异:HTTP/2 连接一旦建立可立即被多个请求复用;HTTP/1 连接必须等本次请求完成后才能被下一个请求复用。由于 HTTP 请求经常并行发生,连接池必须线程安全。
文档点名的四个核心类及各自职责:
- RealConnectionPool:管理 HTTP 与 HTTP/2 连接的复用以降低延迟。每个
OkHttpClient拥有一个,生命周期与客户端一致。 - RealConnection:一条 HTTP/1 或 HTTP/2 连接的 socket 与 stream 集合,按需创建、可服务多次请求/响应交换,生命周期通常短于连接池。
- Exchange:承载单一请求/响应对。
- ExchangeFinder:为每个 exchange 挑选连接,倾向于让同一次调用(call)的所有 exchange 走同一条连接,且优先复用池中连接而非新建。
6.2 每连接一把锁:最大化并发
RealConnectionPool 的字段声明是这段文档的直接实现:
/** * ... guarded by [RealConnection.noNewExchanges] property. This defends against races where a * connection is ... */ private val connections = ConcurrentLinkedQueue<RealConnection>()三条设计要点:
- 池本体是无锁的
ConcurrentLinkedQueue。由于数据竞争的存在,该队列的迭代器可能返回已被移除的连接——这是无锁队列的正常语义而非缺陷。 - 因此调用方在使用池取出的连接前,必须检查连接的
noNewExchanges属性。该属性定义在 RealConnection.kt L96,在连接因关闭、超时淘汰等原因退出复用时被置为true,RealConnectionPool 在复用判断与淘汰循环中反复检查/设置它,正是文档所说的"防御迭代竞态"。 - 每条连接有自己的锁,用每连接锁来最大化并发。并且与 http/2 层的约定一脉相承:连接锁在 I/O 期间(哪怕是关闭 socket)也绝不持有,防止锁争用。
6.3 HTTP/2 复用与连接降级的衔接
连接池与 http/2 层并非孤立:前文提到的noNewExchanges()回调(由 RealConnection 触发)同时通知ConnectionListener更新统计;而 HTTP/2 侧通过shutdown(GOAWAY)(Http2Connection.kt L423-L437)优雅降级——只拒绝新stream、不影响存量 stream——为连接池"不再分配新请求但允许存量请求跑完"的复用模型提供了协议层支撑。
七、全文要点回顾
把这份并发模型压缩成一张规则表,即 OkHttp http/2 实现的不变量清单:
| 角色/对象 | 规则 | 源码依据 |
|---|---|---|
| 应用调用线程 | 写必须同步完成以交付异常;读可阻塞在 stream 锁上 | Http2Connection.writeData、Http2Stream.takeHeaders |
| 读线程 | 只读帧与分派;不跑应用代码、不阻塞写 | ReaderRunnable及writerQueue.execute(...) |
| do-stuff-later 池 | 承接一切异步写帧与应用回调,分队列保序/并行 | writerQueue/pushQueue/settingsListenerQueue |
| Connection 锁 | 护连接状态,持锁期间零阻塞操作 | Http2Connection类注释 |
| Stream 锁 | 护 stream 状态;wait/notifyAll实现阻塞读 | Http2Stream.waitForIo |
| Writer 锁 | 护 socket 写,唯一可持锁做阻塞 I/O 的锁 | Http2Writer各方法 |
| 加锁顺序 | Writer 锁可包 Connection 锁,绝不反向 | newStream/shutdown/setSettings |
| 连接池 | 无锁队列 +noNewExchanges检查 + 每连接锁且 I/O 不持锁 | RealConnectionPool.connections、RealConnection.noNewExchanges |
理解了这套"线程各安其位、锁各司其职、顺序有严格约定"的模型,就掌握了阅读与扩展 OkHttp 网络层代码的基础:任何新增的帧处理逻辑,都要先回答三个问题——它该在哪个线程执行、需要哪把锁、是否可能阻塞;答案错一个,等待死锁或应用代码卡死读线程就是迟早的事。
想继续深入,建议直接对照阅读 docs/contribute/concurrency.md 原文,以及 Http2Connection、Http2Stream、Http2Writer、RealConnectionPool、RealConnection 五个文件;okhttp/src/jvmTest/kotlin/okhttp3/internal/http2/目录下的测试(如围绕 stream 读写与流控的用例)可作为验证上述行为的活文档。
【免费下载链接】okhttpA meticulous HTTP client for the JVM, Android, and GraalVM.项目地址: https://gitcode.com/gh_mirrors/okh/okhttp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考