news 2026/9/18 17:28:50

OkHttp 并发模型解析:HTTP/2 连接的三类线程、三把锁与连接池的线程安全设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OkHttp 并发模型解析:HTTP/2 连接的三类线程、三把锁与连接池的线程安全设计

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.http2okhttp3.internal.connection包下的真实实现,系统讲解 OkHttp 如何在 http/2 分帧协议之上对外暴露阻塞式 API,以及连接池如何做到线程安全。读完本文,你将能理解 OkHttp 内部线程的分工边界(谁读 socket、谁写 socket、谁跑应用代码)、三把核心锁的持有规则与加锁顺序约定,并能在阅读 Http2Connection、Http2Stream、RealConnectionPool 等源码时,对每一处withLockwait()与异步队列调用"知其所以然"。

一、问题的起点:阻塞 API 与分帧协议的矛盾

1.1 阻塞式 API 的价值与代价

OkHttp 对外提供的是阻塞式的 HttpURLConnection 风格 API:发起请求是一次阻塞写,接收响应是一次阻塞读。文档明确指出了这种 API 的取舍:

  • 便利:自上而下的过程式代码,没有回调间接层。网络调用就像普通方法调用——要数据就返回数据;请求失败时,IOException的堆栈直接出现在调用发生的位置,异常传播路径清晰(即所谓 exception transparency)。
  • 代价:等待网络期间线程处于闲置状态。线程既有内存开销也有上下文切换开销,长时间空等不经济。

1.2 分帧协议为何天然不适合单线程阻塞 I/O

http/2 这类分帧(framed)协议无法简单地"在一个阻塞线程上正确实现",核心原因有两条:

  1. 流与 socket 是多路复用的。每个应用层线程都想对自己那条 stream 做阻塞 I/O,但所有 stream 共享同一个 socket。你不能直接和 socket 说话,必须与共享该 socket 的其他应用层线程协作。
  2. 流控规则制造了读写之间的反馈环。流控要求"写必须确认读、读必须节流写":读到的字节需要向对端回发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 就能卡死整条连接。观察ReaderRunnableheaders()回调:发现远端新 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);
  • 收到对端PINGwriterQueue.execute(...)异步回 pong;
  • 需要回RST_STREAMWINDOW_UPDATE→ 走writeSynResetLater/ writeWindowUpdateLater。

2.3 "稍后执行"线程池(do-stuff-later pool)

有时发现某件"该做的事"的线程,并不是"应该做这件事"的线程——比如读线程发现要回 ping、要回窗口确认,或要回调应用层。此时把 runnable 入队,交给线程池中的某个线程去执行。

在实现里这个角色由TaskRunner(位于okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/concurrent/包)承担。Http2Connection为不同语义建了多个有序队列,各自承担不同用途:

队列创建位置用途
writerQueuetaskRunner.newQueue()一切异步写帧:回 ping、RST_STREAMWINDOW_UPDATE、应用并 ACK 对端设置、周期性 ping
pushQueuetaskRunner.newQueue()保证 push promise 相关回调按 stream 顺序投递给PushObserver
settingsListenerQueuetaskRunner.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 bylock. No blocking operations may be performed while holding this lock!(连接内部状态由 lock 保护;持锁期间禁止执行任何阻塞操作)

"永不持有做阻塞操作"意味着:拿锁 → 读写几个字段 → 放锁,全程无 I/O、无应用层回调。所有对streams映射、nextStreamIdwriteBytesMaximum等字段的访问都包在极短的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 与首帧发送必须原子"的原则完全一致。)

五、流控闭环:读写反馈环在源码里如何运转

前面说流控"要求写确认读、读节流写",把它和线程模型拼起来,整个环是这样闭合的:

  1. 读 → 确认读:应用消费字节后,updateConnectionFlowControl(read)更新readBytes计数器;未确认量达到初始窗口一半时,writeWindowUpdateLaterWINDOW_UPDATE投递到writerQueue,由池线程完成真正的 socket 写。
  2. 对端窗口 → 解除写阻塞:读线程处理对端的WINDOW_UPDATE帧,在连接锁内增加writeBytesMaximumnotifyAll()(ReaderRunnable.windowUpdate),正在writeDatawait()的写线程随即醒来继续推数据。
  3. 写 → 节流读:写端受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>()

三条设计要点:

  1. 池本体是无锁的ConcurrentLinkedQueue。由于数据竞争的存在,该队列的迭代器可能返回已被移除的连接——这是无锁队列的正常语义而非缺陷。
  2. 因此调用方在使用池取出的连接前,必须检查连接的noNewExchanges属性。该属性定义在 RealConnection.kt L96,在连接因关闭、超时淘汰等原因退出复用时被置为true,RealConnectionPool 在复用判断与淘汰循环中反复检查/设置它,正是文档所说的"防御迭代竞态"。
  3. 每条连接有自己的锁,用每连接锁来最大化并发。并且与 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.writeDataHttp2Stream.takeHeaders
读线程只读帧与分派;不跑应用代码、不阻塞写ReaderRunnablewriterQueue.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.connectionsRealConnection.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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 17:28:48

SpringBoot网络流量样本管理系统设计与实践

1. 项目概述与背景网络流量数据样本管理是网络安全分析、业务监控和性能优化等领域的基础工作。这个基于SpringBoot的JavaWeb系统&#xff0c;专门用于对网络流量样本进行采集、存储、分析和可视化展示。我在实际企业级安全项目中&#xff0c;发现传统文件系统管理流量样本存在…

作者头像 李华
网站建设 2026/9/18 17:28:47

faster-whisper 离线语音转写快速上手

faster-whisper 离线语音转写快速上手 【免费下载链接】faster-whisper Faster Whisper transcription with CTranslate2 项目地址: https://gitcode.com/GitHub_Trending/fa/faster-whisper 如果 40 分钟的会议录音跑十几分钟才出稿、内存几乎被打满&#xff0c;这就是…

作者头像 李华
网站建设 2026/9/18 17:28:14

Java Swing多线程实现无人机防空仿真系统

1. 项目概述这个无人机自动防空平台项目是一个基于Java Swing和多线程技术的仿真系统。作为一名有多年Java开发经验的程序员&#xff0c;我发现这个项目很好地结合了GUI编程和并发编程的核心知识点。系统模拟了无人机防御场景&#xff0c;包含雷达扫描、敌机追踪等基础功能模块…

作者头像 李华
网站建设 2026/9/18 17:26:12

JDK与IDEA安装配置全攻略:从零搭建Java开发环境

新手学Java&#xff0c;第一步永远绕不开两样东西&#xff1a;JDK和IDEA&#xff08;IntelliJ IDEA&#xff09;。我见过太多人卡在环境配置这一步&#xff0c;教材看了一大堆&#xff0c;代码一行没写&#xff0c;光是配个环境变量就劝退了一大半。其实这件事本身没有任何技术…

作者头像 李华
网站建设 2026/9/18 17:24:16

如何用WebGoat入门Web安全实战:一份免费的完整教程

如何用WebGoat入门Web安全实战&#xff1a;一份免费的完整教程 【免费下载链接】WebGoat WebGoat is a deliberately insecure application 项目地址: https://gitcode.com/GitHub_Trending/we/WebGoat 想练SQL注入、XSS&#xff0c;又不敢碰真实的生产环境&#xff1f;…

作者头像 李华
网站建设 2026/9/18 17:23:44

Git分支全面解析:从指针原理到合并冲突与实战管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华