Swift协议性能Shellby

mosh 卡得要死,我差点怪罪自己刚做的架构取舍

Shellby 自研的 mosh 兼容传输上线后卡到不可用。我刚做过「停等状态同步」这个明确会牺牲吞吐的取舍,第一嫌疑人自然是它。但有一个指标不对——它把架构洗清了,真凶是一个回填错的时间戳,让服务端主动节流到 100ms 一个包。

Shellby 的 mosh 兼容传输刚跑通,用户反馈就来了:卡到完全不可用。

这个反馈让我心里一沉,因为我知道它可能是对的——我在这套实现里做过一个明确会牺牲吞吐的取舍。

我给自己准备好的那个解释

mosh 的 SSP 协议在 UDP 上同步的是终端状态而不是字节流。服务端发来的 Instruction 带一对状态号 oldNum → newNum,意思是「这个 diff 把你从状态 oldNum 推到 newNum」。问题在于 oldNum 是服务端假设的客户端状态,这个假设永远滞后 RTT/2,所以高延迟下必然出现「客户端已经到状态 5,收到的包却是 4 → 6」。

完整的 mosh 客户端靠保存历史帧缓冲来消化这种错位。而实现帧缓冲意味着自建 Framebuffer、受限 ANSI 解析器和帧差分器——工作量超过 OCB3 加密、protobuf 编解码、SSP 状态机三样加起来的总和,而且 SwiftTerm 和 xterm.dart 都不可能廉价克隆。

所以我绕开了它:只保留 head 状态,oldNum ≠ head 就丢包,而且不 ack。 服务端的 assumed_receiver_state 因此不会前进,下次会基于我们最后确认的状态重新 diff——收敛,而且正确。一旦保证「只应用 oldNum == head 的包」,服务端发来的内容就是从当前屏幕出发的正确 diff,可以直接喂给终端,不需要我自己的帧模型。

代价写在设计文档里,是我自己写的:吞吐退化成停等协议。实测数据也是我自己测的:

单向延迟 RTT 命中率 刷新间隔(中位)
0 ~0 96.0% 0.25s
25ms 50ms 96.2% 0.25s
100ms 200ms 51.7% 1.27s
250ms 500ms 45.2% 1.26s

RTT 200ms 时命中率掉到一半、1.3 秒才刷新一次。「卡」这个症状,和这张表对得严丝合缝。

于是我有了一个完整、自洽、有实测数据支撑的解释。这正是它危险的地方。

一个对不上的数

动手改架构之前,我先量了三个数。测试环境是本地回环、逐字回显——RTT 约等于 0,按上面那张表应该落在最好的一档:0.25 秒刷新、96% 命中率。

  • 中位回显延迟:102ms
  • 首个入站包延迟:102ms
  • oldNum 不匹配的丢弃数:0

第三个数把停等方案洗清了。

停等方案造成卡顿的机制只有一条:oldNum ≠ head 时丢包不 ack,于是要多等一个来回。丢弃数为 0,意味着这条路径一次都没走到。包不是被我丢了,是根本没来

第二个数补上了另一半。「首个入站包」是握手后第一个数据包——那时候还没有任何状态需要同步,停等与否根本不参与。它同样是 102ms,说明慢在包到达之前。

两个数指向同一个结论:不是我丢得太多,是对方发得太少

谁在节流

服务端为什么不发包?翻 mosh 的行为:mosh-server 的发送间隔是 clamp(SRTT/2, 20ms, 250ms)。它会根据估算出的往返时延主动节流——RTT 越大,发得越稀。

那它的 SRTT 从哪来?从我们发过去的包里。每个 mosh 数据报的明文头部有一对时间戳:

明文 Packet = be16(timestamp) || be16(timestampReply) || fragmentBytes

timestampReply 是回填字段,规则是:

reply = 对端时间戳 + (我们的发送时刻 - 我们的接收时刻)

也就是对端的时间戳,加上这个包在我们手里停留的时长。加上停留时长这一项是必须的:对端拿 自己的当前时间 - reply 算往返,如果我们不把自己的处理耗时剔出去,这段时间就会被算进网络延迟。

还有一个坑:尚未收到任何包时,哨兵值必须是 0xFFFF,不能填 0——0 是一个合法的时间戳,填 0 等于谎报了一个真实读数。

我回填的值不对。服务端据此算出 RTT ≈ 200ms,于是 clamp(200/2, 20, 250) = 100ms,老老实实按 100ms 一个包节流。中位延迟 102ms 里,那 100ms 是服务端在等一个它以为该等的间隔。

改对之后:**102ms → 16ms,快 6 倍。**同样 6 秒的互操作测试,累积传输字节从 758 涨到 1074。

「卡」是复合症状

真凶不止一个。同一次排查里还挖出两个独立的原因,它们都表现为「卡」:

快速打字丢按键。 UserMessage.1repeated Instruction——一个 diff 声称覆盖 oldNum → newNum,就必须真的包含这之间的全部用户动作。我的原实现每次都覆盖 pendingDiff,却照样递增 newNum,于是中间按键被永久丢弃。打 ls 只有 s 到达服务端。用户感受到的当然是「卡」,但实际是数据没了。修法是维护未 ack 动作队列,按 ack 裁剪。

收包路径上一版误加的发送节流。 我之前担心 ack 风暴,在收包路径上加了发送节流。但在停等方案里,ack 是推进状态的唯一动力——节流 ack 就等于直接节流整个会话。而且本来就只有 oldNum == head 命中时才回包,根本形不成风暴。这个节流纯粹是在给一个不存在的问题打补丁,顺带把真实吞吐砍了一刀。

三个原因叠在一起,任何一个单独修都不会让体验变正常。这也是为什么最初的现象那么容易被归因到架构上——症状的严重程度确实“配得上”一个架构级的缺陷。

留下的三条

一、刚做完的取舍是最危险的嫌疑人。 不是因为它经常有罪,而是因为你对它的罪状最熟悉,解释起来最顺。我手里有一张自己测的性能退化表,把症状套进去毫无阻力——自洽的解释比正确的解释更容易得到。

二、每个取舍都要配一个能给它脱罪的指标。 这次救场的是「oldNum 不匹配丢弃数」。它不是为了排查加的,是实现停等时顺手记的计数器,而它恰好是那条退化路径的唯一入口:走了这条路径,计数就不为 0。取舍的代价如果不可测量,它就会吸走所有归因——因为没有任何数据能反驳你。

三、优化之前,先确认自己不是在补偿一个 bug。 那个误加的 ack 节流就是典型:担心一个从未发生的问题,加了一层限制,然后这层限制成了新的瓶颈。加节流、加缓存、加批处理之前,先量一遍它要解决的问题是不是真的存在。

三个修复现在都由回归测试钉死:testTimestampReplyEchoesPeerPlusHoldTime 断言回填值等于「对端时间戳 + 持有时长」,testUnackedKeystrokesAccumulatetestAckPrunesUserActions 守住未 ack 队列的累积与裁剪。停等方案本身一行没改——它从头到尾都是清白的。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。