一条命令从执行到结果出现在屏幕上,看起来只是“输出了一段文字”。但在构建日志持续刷屏、远端服务不断输出状态、窗口需要快速滚动时,终端要完成的工作远比想象中复杂:既要把内容完整接住,也要及时画到屏幕上,还不能让输入和粘贴失去响应。
一、背景
1. 先认识一下 OrcaTerm
OrcaTerm 最初是一个 Web 终端,主要服务于云服务器、轻量云服务器和 TKE 等终端登录场景。随着使用范围扩大,以及用户对本地终端能力的需求增加,我们也推出了客户端版本。
OrcaTerm 客户端采用 Tauri 架构,可以简单理解为:后端负责连接和搬运数据,前端负责把终端内容显示出来。具体来说:
- Rust 后端:负责 PTY/SSH 连接、读取远端输出、处理协议,并通过 Tauri 的 IPC 将数据传到前端。
- React 前端:基于 xterm.js 实现终端渲染,并通过流量控制模块对终端数据进行排队和发送确认。
其中,PTY 可以理解为终端程序与 Shell 或远端会话之间的一条数据通道;IPC 则负责让 Rust 后端与 WebView 前端互相传递消息。用户看到的一行行内容,正是沿着这条链路抵达屏幕的。
为了让后文的技术链路更容易理解,我们会始终用同一个生活场景辅助说明:午高峰时,一位顾客在麦当劳点了一份套餐,后厨出餐、打包、交到前台、放入取餐区,最后完整交到顾客手中。
2. 为什么要做渲染优化
作为终端产品,OrcaTerm 不仅要保证命令能够正常执行,还要保证输出完整、连续、及时,并保留基本的交互响应。日常轻量使用时,这些问题或许不明显;但在高频日志、持续滚动和大段输出场景中,渲染能力会直接决定使用体验。
为了衡量终端渲染性能,业内通常使用 vtebench。它会生成固定、可复现的 VT/ANSI 输入,并测量从输入开始到终端完成处理的耗时。这样做的好处是:每次测试的“题目”一致,结果才能横向比较。
我们使用 vtebench 测试 OrcaTerm 时,却发现测试有时无法完整跑完;有时运行过程中屏幕会长时间空白,最终产出的数据也明显偏少。
进一步排查后,我们发现了一个很隐蔽的问题:流量控制模块中设置了 maxQueueBytes=8MB 的队列上限。在 vtebench 的高压场景下,问题会按下面的顺序发生:
- 出现一次较长的卡顿(stall)时,xterm 暂停渲染,
renderPaused=true,数据停止继续推送到渲染侧。 - 等待处理的数据持续累积,超过 8MB 后,队列会丢弃最早的一部分数据块(chunk)。
- 这些被丢弃的数据此前已经收到确认(ACK),后端会认为它们已经交付完成。
- 屏幕上的内容因此不完整,但测试探针可能会更早命中结束条件,最终得到一个被大幅低估的耗时。
换句话说,原先“看起来更快”的部分成绩,并不代表终端真的更快完成了全部工作,而是有内容没有被完整显示出来。对终端而言,这样的结果没有参考价值。
放到这位顾客的午餐场景中,问题就很好理解:系统显示这份套餐已经出餐,但取餐区空间不足时,最早放上去的一部分餐品被清走了。顾客最终拿到的托盘并不完整,系统却认为订单已经完成。若只按“订单完成得多快”计时,数字会显得很好看;但这显然不能代表顾客真的更快拿到了完整的一餐。
我们逐步提高 maxQueueBytes。当它提高到 64MB 后,测试可以稳定、完整地跑完,数据丢失率已经很低;再继续增大上限,测试数据的变化也不明显。于是,我们以 64MB 时的结果作为真实基线。
| benchmark | Warp(参考) | OrcaTerm 真实(64MB) | 虚高(8MB 丢弃) | 虚高 vs 真实 |
|---|---|---|---|---|
| dense_cells | 7 | 125 | 115 | -8% |
| medium_cells | 14 | 470 | 140 | -70% |
| scrolling | 38 | 1450 | 185 | -87% |
| scrolling_top_region | 19 | 2700 | 185 | -93% |
| sync_medium_cells | 20 | 1950 | 140 | -93% |
| unicode | 12 | 845 | 120(含 5603ms 飞刺) | -86% |
表中的测试项分别代表:
- dense_cells:密集写入字符,观察单元格解析、批量处理和基础渲染能力。
- medium_cells:中等密度的普通终端输出,观察通用输出路径的稳定性。
- scrolling:持续输出并推动屏幕滚动,观察滚动、回滚缓冲区(scrollback)、渲染调度和反压表现。
- scrolling_top_region:只更新屏幕顶部区域,观察局部刷新、滚动区域和脏区处理能力。
- sync_medium_cells:带同步输出语义的终端控制序列,观察同步刷新和 flush 行为。
- unicode:Unicode 和多字节字符密集输出,观察解码、字符宽度计算和异常长尾。
基于真实基线,OrcaTerm 与 Warp、Ghostty 等终端性能第一梯队仍有 3 至 17 倍的差距。渲染已经成为明显瓶颈,也会影响用户在高频输出场景下的实际体验。
二、怎么把瓶颈找出来
2.1 先把渲染链路“切开看”
终端从后端“吐出”一个字符,到它最终出现在屏幕上,中间要经过 6 个环节:
- PTY 读取:从伪终端持续读取 Shell 或远程会话输出的原始字节流。
- Rust 数据处理:对原始数据进行转换、分块和聚合,整理成适合传输的数据包。
- Tauri IPC 传输:通过 IPC 将 Rust 侧数据跨进程传递到 WebView 前端。
- JS 收包:前端接收并解析 IPC 消息,还原为 xterm 可消费的终端数据。
- 流量控制排队:通过缓冲、排队和批量调度,在数据吞吐与前端渲染压力之间取得平衡。
- xterm 渲染:xterm 解析终端控制序列,更新终端状态并将最终画面绘制到屏幕。
对应到这份套餐:PTY 读取像后厨出餐口持续交出餐品;Rust 数据处理负责整理和打包;IPC 将餐品从后厨一侧转交到前台;JS 收包负责核对和接收;流量控制排队相当于安排取餐区;xterm 渲染则是把完整托盘最终交到顾客手中。只有最后一步完成,用户才真正“看到了输出”。
为了知道时间究竟花在哪里,我们在这条链路的三个位置加入埋点:
| 埋点位置 | 测量环节 | 关键信号 |
|---|---|---|
| Rust 侧 | [1] + [2] | 读取阻塞时长、IPC 投递、等待前端 ACK 的反压时长 |
| JS 侧 | [4] + [5] | 数据归一化、解码、流量控制入队、OSC 序列解析 |
| xterm 侧 | [6] | 从 write 到渲染完成的端到端时延、渲染次数 |
这里有一个容易误解的点:前半段 [1][2][3] 跑在 Rust 后端线程,后半段 [4][5][6] 跑在前端 WebView 线程。两者是并行运行的,并不是一条首尾相接、总占比刚好为 100% 的流水线。
因此,不能简单地把“后端占 30%”和“前端占 70%”相加。更准确的理解是:每一层内部各段时间加起来是 100%,层与层之间通过“反压信号”相互影响。前端消费不及时,后端就需要等待;后端输出节奏变化,也会反过来影响前端。
2.2 实测定位:问题不在“画得慢”,而在“消息太碎”
有了埋点后,我们用 vtebench 测得真实基线中各个阶段的占比:
| 环节(层内占比) | 数值 | 说明 |
|---|---|---|
| 后端读取 | 34.7% | PTY 读取阻塞 |
| 后端反压等待 | 65.0% | 前端消费不过来,反压传回后端 |
| 前端入队 | 63.3% | 流量控制入队,onmessage 是第一热点 |
| 前端 OSC 解析 | 19.4% | OSC 序列解析 |
| 前端解码 | 15.8% | 解码 + zmodem 符号扫描 |
| xterm 渲染时延 | median 86.6ms | 从写入到渲染完成的端到端时延 |
| 每帧字节 | 约 1KB | 8KB 缓冲,但实际每次 read 只返回约 1KB |
| 1MB 帧数 | 约 1025 帧 | 高频 onmessage 的强度 |
| 有效吞吐 | 703 KB/s | 有效吞吐偏低 |
仅有埋点还不够。我们又使用 Safari Web Inspector Timelines 做了第二种独立验证:
| 层 | 现象 | 解读 |
|---|---|---|
| Network(蓝) | 密集蓝条几乎铺满 | Tauri IPC 帧持续到达,帧间几乎没有间隙 |
| JavaScript(紫) | 极密集紫色竖条 | onmessage 高频触发,Microtask 是主要来源 |
| Layout & Rendering(绿/红) | 稀疏绿条 + 大量空白 | xterm 渲染被挤压,Paint 几乎为空 |
| CPU | 持续 120%+ | 主线程满载,甚至超载 |
两组证据指向同一个结论:Rust 侧以极小的数据块快速输出,约 1KB 一帧。于是,vtebench 中每 1MB 样本数据要经历约 1025 次传输,也就是约 1025 次 onmessage 同步工作。
继续用这份套餐来理解:如果后厨每完成一个很小的部分,就立刻单独交给前台,前台就要反复核对、接收和转交。高峰期时,前台忙着处理一连串零散交接,反而没有足够时间把同一份套餐整理到托盘上。终端遇到的问题类似:主线程忙于处理大量细碎消息,xterm 渲染拿不到足够的时间片;前端变慢又会反压到后端,后端输出被卡住,最终出现渲染“饿死”、终端卡住的现象。
三、优化方案与收益
3.1 第一步:合批(Batching),消灭 onmessage 风暴
从 Rust PTY 读取到的每一帧数据,原本都会通过 IPC 立即发送给前端处理。由于每帧只有约 1KB,每处理 1MB 数据就会产生约 1025 帧,也就是约 1025 次 IPC 通信。此时,前端入队占比达到 63%,反压又让后端等待占比达到 65%。
因此,第一个优化方向很明确:调整发送帧的大小,把连续的小数据合并后再发送,减少跨进程通信和前端调度开销。
经过多轮参数设计与对比,我们最终确定了两条规则:
- 数据累计到 64KB 时,立即发送一帧。这样可以显著压缩帧数,同时又避免单帧过大,给渲染侧带来新的压力。
- 距离上次发送超过 5ms 时,即使数据没有攒到 64KB,也强制发送一帧,避免小数据等待太久。
这里还有一个必须处理的细节:只在读取循环中检查超时并不够。读取阻塞等待数据时,小数据,例如粘贴回显,可能被卡住,表现为“粘贴只显示首字符,按一下空格才补全”。
因此,我们让“PTY 读取”和“5ms 定时器”并行竞速,谁先到就触发发送。这样既能在高吞吐时合并消息,也不会让低吞吐时的小数据滞留。
回到这份套餐,合批并不是让顾客一直等到凑满一大队订单才开始处理,而是将同一份订单中短时间内陆续完成的部分整理后一次交给前台,同时规定一个很短的最长等待时间。这样既减少重复交接,也不让顾客因为某一项很少、很快完成的内容而久等。
| 指标 | 无合批基线 | 合批后(64KB / 5ms) | 变化 |
|---|---|---|---|
| 1MB IPC 帧数 | 约 1025 | 18 | 57 倍下降 |
| 单次 run 总帧数 | 135,639 | 26,371 | 5.1 倍下降 |
| 平均每帧字节 | 约 1KB | 约 57KB | 57 倍提升 |
| 后端反压等待 | 65.0% | 52.3% | 下降 12.7pt(125s 至 86s) |
| xterm 渲染时延 | 86.6ms | 80.8ms | 略降 |
| 有效吞吐 | 703 KB/s | 9,209 KB/s | 13 倍提升 |
分场景耗时对比,单位为 ms,越低越好:
| benchmark | 无合批(ms) | 合批后(ms) | 变化 |
|---|---|---|---|
| dense_cells | 413.9 | 34.3 | -91.7% |
| medium_cells | 588.0 | 377.5 | -35.8% |
| scrolling | 1072.0 | 576.1 | -46.3% |
| scrolling_bottom_small_region | 1771.8 | 65.1 | -96.3% |
| scrolling_fullscreen | 2711.2 | 96.8 | -96.4% |
| scrolling_top_region | 2187.2 | 57.8 | -97.4% |
| sync_medium_cells | 2522.0 | 108.6 | -95.7% |
| unicode | 1113.4 | 352.7 | -68.3% |
通过引入“64KB 合批 + 5ms 超时发送”机制,并让 PTY 读取与定时器并行竞速,我们既解决了高吞吐场景下大量小帧引发的 IPC 与 onmessage 风暴,也避免了低吞吐场景中小数据滞留。
优化后,1MB 数据的 IPC 帧数从约 1025 帧降至 18 帧,单次运行总帧数减少 5.1 倍,后端反压等待下降 12.7%,有效吞吐从 703KB/s 提升至 9209KB/s,达到原来的 13 倍;各类渲染场景耗时降低 35.8% 至 97.4%。整体来看,合批显著削减了跨进程通信和前端入队调度开销,是本轮终端渲染性能提升最关键、收益最明显的优化之一。
3.2 第二步:流量控制调参,给链路留出缓冲空间
合批消除了 IPC 帧数带来的“风暴”,但反压等待(wait%)仍然偏高。这说明前端只要短暂变慢,后端就会很快被暂停。为让后端不再因为前端瞬时消费变慢而频繁断流,我们继续优化流量控制。
先说明这套机制如何工作:Rust 后端通过反压阈值控制发送节奏。未确认的数据达到阈值时,后端暂停读取;收到前端 ACK 后,再继续输出。前端的 FlowControl 将 ACK 与 xterm 渲染解耦,并通过 FIFO 队列吸收瞬时渲染抖动,减少频繁断流,同时避免队列无限增长。
继续看这位顾客的订单:取餐区需要留出适当空间,让已经做好的汉堡、薯条和饮料按顺序等待组合、交付。某一份餐暂时慢一点,不应该让整个后厨都停下来;但取餐区也不能无限扩大,否则空间和管理成本都会失控。终端的反压阈值与渲染队列,解决的正是同样的平衡问题。
我们主要调整了两个参数:
反压阈值。 合批后,单帧已经从约 1KB 提升到约 57KB(median 为 64KB)。原先 500KB 的阈值意味着,前端只要晚消费不到 10 帧,后端就会被暂停,反而重新制造断流。阈值需要明显大于单帧尺寸,给前端留下足够的消费缓冲。
我们在 5MB、10MB、20MB 几档之间进行了对照。10MB 在“不再频繁断流”和“后端堆积可控”之间取得了较好平衡:它约等于单帧的 175 倍,足以吸收前端瞬时抖动,又不会让后端无限堆积。
FIFO 渲染队列。 前面的排查已经证明,8MB 太小会导致数据丢失,让测试结果虚低。实测中,单轮 vtebench 总输出约 132MB / 192s,峰值堆积远低于 50MB;同时,xterm 内部也设计了 50MB 的数据处理队列。因此,将前端队列设为 50MB,能够在覆盖峰值、避免丢包的同时,把内存占用控制在可接受范围内。继续增大队列,对真实使用场景的边际收益很小。
调参后的数据如下:
| benchmark | 合批(500KB / 64MB) | 调参后(10MB / 50MB) | 变化 |
|---|---|---|---|
| dense_cells | 34.3 | 8.9 | -73.9% |
| medium_cells | 377.5 | 25.4 | -93.3% |
| scrolling | 576.1 | 25.9 | -95.5% |
| scrolling_bottom_region | 594.3 | 14.6 | -97.5% |
| scrolling_fullscreen | 96.8 | 19.9 | -79.5% |
| scrolling_top_region | 57.8 | 14.1 | -75.7% |
| sync_medium_cells | 108.6 | 21.2 | -80.4% |
| unicode | 352.7 | 67.4 | -80.9% |
| 指标 | 合批(500KB / 64MB) | 合批(10MB / 50MB) | 变化 |
|---|---|---|---|
| 后端读取 | 47.6% | 55.7% | +8.1pt,更少被反压阻塞 |
| 后端反压等待 | 52.3% | 43.4% | -8.9pt(86s 至约 71s) |
| 前端入队占比 | 62.3% | 58.1% | -4.2pt |
| xterm 渲染时延 | 80.8ms | 80.8ms | 持平,优化作用在反压段而非渲染侧 |
将后端反压阈值从 500KB 提升至 10MB、前端 FIFO 渲染队列调整为 50MB 后,系统能够更好地吸收前端瞬时抖动,显著减少后端频繁断流。各场景耗时进一步下降 74% 至 98%,后端读取占比提升,反压等待明显降低。
四、优化后整体效果
对比 Warp、Ghostty 等终端,OrcaTerm 在 10 项测试中取得 6 项第一、8 项进入前三,尤其包揽全部 scrolling 场景第一,滚动性能已形成明显优势。dense_cells 和 sync_medium_cells 也达到第一梯队水平。
从贯穿全文的午餐场景来看,这轮优化不是单纯让后厨“做得更快”,而是让一份套餐从出餐、转交、暂存到最终交付的整个过程更顺畅:高峰期不会因为零散交接堵住前台,短暂波动也不会让后厨频繁停工,更不会为了看起来更快而交付不完整的餐盘。对应到终端,就是输出更完整、滚动更连续、交互更及时。
与优化前相比,结果如下:
| benchmark | 优化前 | 优化后 | 相对优化前提升 |
|---|---|---|---|
| dense_cells | 125 | 9.0 | 约 14 倍 |
| medium_cells | 470 | 23.4 | 约 20 倍 |
| scrolling | 1450 | 27.6 | 约 52 倍 |
| scrolling_bottom_region | 1320 | 15.1 | 约 87 倍 |
| scrolling_bottom_small_region | 1180 | 14.2 | 约 83 倍 |
| scrolling_fullscreen | 1660 | 20.2 | 约 82 倍 |
| scrolling_top_region | 2700 | 14.3 | 约 189 倍 |
| scrolling_top_small_region | 2820 | 13.7 | 约 206 倍 |
| sync_medium_cells | 1950 | 17.5 | 约 111 倍 |
| unicode | 845 | 67.4 | 约 13 倍 |
优化后的延迟分布,单位为 ms:
| benchmark | p50 | p90 | p99 |
|---|---|---|---|
| dense_cells | 9.0 | 11.0 | 12.0 |
| medium_cells | 8.0 | 88.0 | 192.2 |
| scrolling | 15.0 | 26.0 | 227.0 |
| scrolling_top_region | 14.0 | 16.0 | 37.2 |
| sync_medium_cells | 8.0 | 11.0 | 171.2 |
| unicode | 8.0 | 121.0 | 397.5 |
相对优化前,整体耗时下降 92% 至 100%,多个场景已经逼近 Warp 的参考水平。
最后,再看一组与其他终端的对比数据,单位为 P50(ms,越低越好):
| Case | OrcaTerm P50 | Ghostty P50 | Warp P50 | WezTerm P50 | iTerm2 P50 | Terminal.app P50 |
|---|---|---|---|---|---|---|
| dense_cells | 8.89 | 6.59 | 7.02 | 9.87 | 95.77 | 21 |
| medium_cells | 41.35 | 11.28 | 14.00 | 36.36 | 633.94 | 34 |
| scrolling | 20.71 | 24.80 | 41.06 | 71.00 | 42.10 | 151 |
| scrolling_bottom_region | 14.98 | 40.68 | 20.97 | 64.37 | 559.39 | 132 |
| scrolling_bottom_small_region | 14.81 | 41.00 | 20.86 | 64.62 | 558.17 | 132 |
| scrolling_fullscreen | 20.55 | 37.46 | 26.58 | 87.81 | 62.88 | 173 |
| scrolling_top_region | 16.06 | 27.41 | 66.89 | 87.14 | 2812.75 | 135 |
| scrolling_top_small_region | 15.48 | 40.98 | 20.65 | 64.88 | 551.89 | 135 |
| sync_medium_cells | 19.51 | 13.17 | 22.50 | 48.78 | 660.94 | 40 |
| unicode | 48.81 | 6.30 | 13.37 | 99.10 | 83.15 | 5 |
五、后续方向
这一轮优化主要聚焦在“IPC 风暴”和“反压段”。xterm 渲染侧本身的时延没有被直接触动,avgFlush 在优化前后始终维持在约 80ms。也就是说,链路前半段已经更加通畅,但后半段的渲染上限仍有进一步优化空间。
后续,我们将继续推进三个方向:
- xterm.js 升级与渲染侧瓶颈。 实测中,JS 消化侧的解码、OSC 解析和入队仍是前端第一热点,而 xterm 单次渲染时延在优化前后几乎没有变化。我们会持续跟踪 xterm.js 上游版本演进,评估升级带来的渲染收益;必要时,也会在解析和渲染路径上做更细的裁剪。
- 极端场景的飞刺(stall)收口。 在 unicode 等场景中,p99 仍接近 400ms,偶发卡顿尚未完全消除。下一步,我们计划结合更细的帧级时间线,定位飞刺发生在解析、布局还是 Paint 环节,再针对性地优化缓冲与调度。
- 参数随场景自适应。 当前 10MB / 50MB 等阈值是在 vtebench 固定负载下选出的平衡点。真实环境中,慢速 SSH 与本地 PTY 的吞吐差异很大。后续可以让反压阈值和队列上限根据实测吞吐动态浮动,而不是固定写死一个值。
总结
综合本轮基于 vtebench 的标准化测试结果,OrcaTerm 的终端渲染性能已经进入终端产品的第一梯队:在 10 项测试中取得 6 项第一、8 项进入前三,并在全部 scrolling(滚动)场景中排名第一。与优化前相比,整体耗时下降 92% 至 100%。
这意味着,在高频日志、持续滚动和大段输出等场景中,OrcaTerm 不仅能更快地呈现内容,也能更稳定地保持完整输出和交互响应。性能优化没有终点,但经过这一轮系统性改造,OrcaTerm 已经具备了与终端产品第一梯队对比和竞争的渲染能力。
如何体验
目前 OrcaTerm 已提供 Web 端、客户端和小程序,欢迎体验。大家有任何问题和建议,也欢迎加入内部交流群交流。