云计算百科
云计算领域专业知识百科平台

远程桌面连接丢帧一致性:帧可以丢但块不能忘

06-丢帧一致性:帧可以丢但块不能忘

前面几篇我们把画面怎么抓、怎么算差分、怎么压成 JPEG、怎么自适应码率都讲完了。这一篇聊一个很容易被忽略、但一旦出事就很要命的话题:网络不好的时候,帧该不该丢?丢了之后画面会不会缺一块补不回来?

很多远程桌面一卡就花屏,往往不是编码的问题,而是"丢帧"这件事没想明白。ALSPD-DESK 在这块的设计可以用一句话概括:帧可以丢,但块不能忘。下面拆开讲。

一、为什么"丢帧"是个技术活

先说一个朴素的目标:带宽不够时,宁可掉帧,也不要积压延迟。

道理很直白。假如你一边看直播一边操作,画面比实际操作慢了三秒,那远程桌面的体验就废了——你点了按钮,三秒后才看到反馈,鼠标都不知道往哪点。与其让每一帧都慢慢排队、越积越慢,不如直接把旧的、过时的画面扔了,只保留最新的那一帧。反正旧画面看过去也没意义,最新的才代表"现在"。

这个思路在视频流里很常见,叫"丢弃过期帧"。但问题来了:前面几篇讲过,ALSPD-DESK 的更新包是差分编码的——一个更新包只携带"相对上一帧变了的那几个块",而不是完整画面。这就让"随便丢帧"变成了一个坑。

如果你丢的是一整帧完整画面,那丢了就丢了,下一帧还是完整的。但差分更新包一旦丢了,Viewer 那边就少了几块拼图,而且它不知道自己少了——因为它只收到"这几块更新了",剩下的它以为还是旧的、没变的。结果就是:那几块画面永远停在旧状态,直到下一次关键帧来整屏覆盖,才会被纠正。

在静止办公场景里这可能只是"某个窗口标题栏几个月才动一次的地方看着旧了一点点",但在你正在打字、正在拖窗口的瞬间,丢块就是肉眼可见的花屏。所以"丢帧"在差分系统里不能直接照搬视频流的经验。

二、差分更新包的本质:只有增量

回顾一下前面的内容。ALSPD-DESK 把屏幕切成 64×64 的块,每帧只把"和上一帧不一样"的块挑出来,拼成一张 atlas 图,压成一次 JPEG,然后打成一个帧包发出去。帧包的字段里记录的是每个 tile 的块坐标(ty、tx),而不是像素坐标——Viewer 收到后,按块坐标贴回自己那张画布上对应位置。

关键点在这里:普通更新包不是"全量",而是"增量"。它假设 Viewer 已经正确地贴好了之前所有帧,它只负责把"新变化"叠上去。这是一个有状态的协议——每一帧都依赖前面所有帧都正确到达。

那么一旦中间某一帧丢了,后面的帧只管补"再后面"的变化,绝不会回头去补你丢的那几块。这就是所谓的"累积误差"隐患:丢一块,少一块,而且丢的越多越难看。

有人会说:那干脆每帧都发完整画面(关键帧)不就行了?问题是关键帧体积大得多。前面实测过一个关键帧约 137 KB,而增量更新包平均才 7.5 KB,差了十几个数量级。真要每帧都发关键帧,带宽直接爆掉,反而更卡。所以增量更新是必须保留的主路径,丢块问题得在别处解决。

三、Outbox:发送端的"邮箱"

要讲清楚怎么解决丢块,得先讲清楚发送端的结构。

ALSPD-DESK 是多线程 + 异步事件循环混合的。采集和编码是 CPU 密集且阻塞的操作,放在一个独立线程里跑;而真正把数据发出去走的是 asyncio 事件循环(WebSocket 发送协程)。两个世界之间,靠一个叫 Outbox 的组件传话。

Outbox 可以理解成一个"发送邮箱"。采集编码线程把打包好的帧塞进 Outbox,发送协程从 Outbox 里取出来往外发。它有三个核心成员:

  • 一个 deque(双端队列)当缓冲区;
  • 一把 threading.Lock 保证多线程读写安全;
  • 一个 asyncio.Event 用来唤醒发送协程。

它的工作逻辑是:容量满了怎么办?丢掉最旧的包,保留最新的画面状态。这里又一次体现了"宁可掉帧不积压延迟"——旧画面已经过时,留着只会挡住新画面。丢完之后,它用 loop.call_soon_threadsafe(event.set) 把发送协程唤醒,让发送协程赶紧去把剩下的包发掉。

注意 call_soon_threadsafe 这个细节。发送协程在事件循环里跑,而 Outbox 的 put 是从采集线程调用的,跨线程直接操作事件循环是危险的。Python 的 asyncio 规定:从别的线程唤醒事件循环,必须用 call_soon_threadsafe,它内部会做正确的线程同步和唤醒(往自管道写一个字节,事件循环被 poll 唤醒)。少写这一句,轻则唤醒不及时,重则程序在某些平台上崩溃。这是异步编程里最容易踩的坑之一。

所以 Outbox 的"线程安全"不是一句空话,而是靠 deque + Lock + Event + call_soon_threadsafe 四件套共同保证的:Lock 管队列读写,Event 管唤醒信号,call_soon_threadsafe 管跨线程安全唤醒。少一个都不行。

四、丢帧不等于忘块:补发集合

现在回到核心矛盾:Outbox 满了要丢最旧的包,可那包里的块要是丢了,Viewer 就残了。

项目源码的解法很巧妙:Outbox.put() 在决定丢弃某个最旧的包时,不是简单地 pop 扔掉,而是把这个包"涉及哪几个块"的信息返回给采集线程。采集线程拿到这些块编号后,把它们并回一个"待补发集合"(pending / resend set)。在生成下一帧的时候,这个集合里的块会被强制算作"脏块",跟着新帧一起重新发送。

举个例子。第 100 帧带了块 (3,5)、(3,6) 的变化,结果 Outbox 满了被丢掉。采集线程立刻记下:块 (3,5)、(3,6) 还没送达。第 101 帧本来只变了 (10,2),但生成时会把 (3,5)、(3,6) 也拉进来一起发。Viewer 收到 101 帧,既更新了 (10,2),又把之前丢的 (3,5)、(3,6) 补齐了。画面自愈。

这个设计最妙的地方在于:它不需要为此发整屏关键帧。对比一下"拥塞时干脆发个全屏关键帧来兜底"的朴素想法——那恰恰是最坏的选择。拥塞的本质是带宽不够,这时候你还塞一个 137 KB 的大帧进去,只会让队列更堵、丢得更多、延迟更高,雪上加霜。而补发集合只重发"真正丢了的那几个块",可能就几 KB,对带宽几乎无感,却能把画面修好。

一句话总结这个机制:丢的是"帧",记的是"块";帧过期可弃,块缺失必补。

五、关键帧策略的全套逻辑

补发集合解决了"偶发丢块"的自愈,但关键帧才是真正的"大兜底"。ALSPD-DESK 里关键帧(flags 的 bit0 = 1)的语义是:tile_count = 0,JPEG 即整屏图像,Viewer 收到后整屏替换,一次性抹掉所有累积误差。什么时候该发关键帧?项目里是一套组合拳:

  • 首帧必须是关键帧。_force_keyframe.set()。新连上来的 Viewer 手里什么都没有,必须先把整屏给它,之后才能基于差分累加。前面文章也提过,新 Viewer 连上来时即使画面完全静止,也要用 force=True 抓一帧,就为了这个首屏关键帧。

  • Viewer 可主动请求。协议里有个 CTRL_KEYFRAME 控制消息。Viewer 发现自己画面有问题(比如还没收到过关键帧、或者某帧处理失败),就置 _need_keyframe = True 主动要一个。这属于"端到端自愈":Viewer 自己最清楚自己残了没,残了就喊一声。

  • 尺寸变化时重发。屏幕分辨率变了(比如你拖窗口进了全屏、或者换了显示器),之前的块坐标和画布全失效,必须立刻来一帧关键帧重新对齐基准。

  • 定期关键帧,但有前提。每隔 keyframe_interval = 2.0 秒补一帧关键帧,防止误差长期累积。但项目加了个聪明的前提:只在"上次关键帧之后确实发过更新"时才补(dirty_since_keyframe)。为什么?因为如果画面一直静止,Viewer 那边的图本来就是对的,你白发一整屏关键帧纯属浪费带宽。前面实测也印证了这点:办公场景里 31 个关键帧占了总流量的一半多,要不是它在丢包兜底,早该砍了。所以这个定期关键帧是"宁可多花点带宽换稳妥",而不是无脑地每 2 秒硬发。

  • 把这四点连起来看,关键帧策略的精髓是:能靠差分和补发集合自愈的,绝不动关键帧;只有首帧、Viewer 求救、尺寸变化、或者确实在持续变化且隔了 2 秒,才动用这个大招。

    六、实测 0 丢帧的秘密

    讲了这么多丢帧和补发的机制,可能有读者担心:那实际跑起来到底丢没丢?

    实测结果是:整轮 0 丢帧。

    原因其实很朴素——帧太小了。增量更新包平均才 7.5 KB。而 websockets 库默认的写缓冲是 32 KB,再加上项目自己那层发送队列(容量 8 帧),合计能缓冲大约 32 KB + 8 × 7.5 KB ≈ 92 KB 的待发数据。正常办公场景下,即便某个瞬间变化集中爆发(比如突然滚动了一大屏),这点尖峰也完全能被缓冲吸收,根本不至于触发 Outbox 的丢包逻辑。

    换句话说,前面那套"丢帧 + 补发 + 关键帧"的精巧机制,是用来兜底最坏情况的,平时基本用不上。等网络真正恢复,队列里积压的帧会被发送协程顺利追发完,不会把延迟累积下来。这和"被动积压、越积越慢"的烂实现是本质区别:这里是"有缓冲额度的小幅排队 + 超额才主动丢旧帧",所以既不会卡,也不会花。

    这也给做同类项目的同学一个启发:先让单帧足够小(差分 + atlas + JPEG 调优都为此服务),"丢帧一致性"这套复杂机制的压力就会小得多。架构是相辅相成的——前三篇的省体积功夫,到这一篇才显出它的战略价值。

    七、缓冲能扛多大尖峰:一道算术题

    前面说"写缓冲 32 KB + 队列 8 帧"足以吸收尖峰,这不是拍脑袋,算一笔就很清楚:

    单帧平均体积 ≈ 7.5 KB
    队列容量(8 帧) = 8 × 7.5 = 60 KB
    websockets 写缓冲 = 32 KB
    合计可缓冲 ≈ 92 KB ≈ 12 个增量帧

    也就是说,即便某一瞬间变化集中爆发,只要积压不超过约 12 个增量帧(折合 20 fps 下约 0.6 秒的突发),缓冲就能在 Outbox 触发丢包之前把它全兜住,发送协程随后平稳追发,用户毫无感知。

    真正会戳破这个缓冲的,是关键帧——整屏约 137 KB,已经超过 92 KB。但关键帧天生稀少:锁在 2 秒周期、且只在真有变化时才补,平均下来摊到每秒才几十 KB,根本不会形成持续压力。换句话说,项目把"大帧"的频率压得足够低,让"小帧"的缓冲永远游刃有余。这又回到前面那个环环相扣的设计哲学:单帧小,是后面所有稳定性机制能轻松运转的前提。

    八、小结

    这一篇的核心就一句话:差分系统里,丢帧容易,丢块要命。

    • 带宽不够时主动丢最旧帧,保留最新画面,这是对的;
    • 但差分更新包只含增量,丢了块会让 Viewer 永久残缺,不能不管;
    • Outbox 丢弃旧包时把涉及的块返回采集线程,并入待补发集合,下一帧重发自愈——比拥塞时发整屏关键帧聪明得多;
    • 关键帧是终极兜底:首帧必须、Viewer 可求、尺寸变化必发、只在真有变化且隔 2 秒才定期补;
    • Outbox 的线程安全靠 deque + Lock + Event + call_soon_threadsafe 四件套保证;
    • 实测 0 丢帧,因为单帧才 7.5 KB,32 KB 写缓冲加 8 帧队列≈92 KB,够扛约 12 个增量帧的突发,不累积延迟;
    • 大帧(关键帧)频率被压得足够低,才让小帧缓冲永远游刃有余。

    回看整条链路——选采集后端、算差分、拼 atlas、压 JPEG、调码率、处理丢帧与一致性——你会发现每一步都在为"小、快、稳"这三个字服务:单帧小了,码率好调了,丢帧兜底也轻了,三者环环相扣。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 远程桌面连接丢帧一致性:帧可以丢但块不能忘
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!