跳转至

15 · 性能、并发与背压

Herdr 的性能问题具有乘法效应。一个“每帧只花 1 ms”的 pane 操作,在 50 panes × 30 FPS 下就是 1.5 秒 CPU/秒;一次无界 frame clone 再乘多个远程 client,会同时变成 CPU、内存和网络问题。因此性能约束被写进架构边界,而不只靠最后 profile。

15.1 成本模型

总成本 ≈ workspaces
       × tabs/panes
       × PTY output rate
       × render frequency
       × connected clients
       × per-cell/per-process work

并非每一项总同时相乘,但任何放进最内层的 I/O、allocation 或锁竞争都会被其他维度放大。

15.2 并发所有权图

flowchart TB
    subgraph SingleWriter["单写者控制域"]
      Loop["App / HeadlessServer loop"]
      State["AppState"]
      Reg["Runtime registry"]
      Loop --> State
      Loop --> Reg
    end

    subgraph Producers["并发生产者"]
      Pty["PTY reader/actor threads"]
      API["API connection/wait workers"]
      Git["Git/worktree background threads"]
      Client["client transports"]
      Timer["timers/update/plugin tasks"]
    end

    Pty -->|"AppEvent + RenderSignal"| Loop
    API -->|"typed Request"| Loop
    Git -->|"completion + operation id"| Loop
    Client -->|"ClientMessage"| Loop
    Timer -->|"event/deadline"| Loop

核心 state 不被多个 worker 随意加锁修改。生产者通过消息/atomic signal 通知,单写者负责验证 identity 并提交 mutation。terminal VT 自身可能有窄锁供 reader/render 协调,但不会把整个 AppState 放进一个全局 mutex。

15.3 有界 AppEvent 队列

APP_EVENT_CHANNEL_CAPACITY 是 256,APP_EVENT_DRAIN_LIMIT 是 64。

容量 256 的作用

  • 限制生产者领先主循环的内存;
  • queue full 对生产者形成背压;
  • 暴露事件粒度过细的问题;
  • shutdown 时 drain 上限可预测。

每轮 drain 64 的作用

  • 摊薄 wakeup/调度成本;
  • 避免一个 burst 只处理一条;
  • 同时防止 API、client accept、timer、render 永久饥饿。

这两个数字不是普适最优值,但它们表达了明确 policy。源码测试还构造超过 drain limit/capacity 的事件,锁定公平和饱和行为。

15.4 为什么 PTY render 不逐事件排队

Agent 一次输出可能被 OS 拆成大量 read chunk。若每个 chunk 都发送 RenderNow 到有界队列:

  • 队列被可合并事件填满;
  • 真正 lifecycle/API completion 进不来;
  • 主循环渲染多个已经过时的中间状态。

RenderSignal 用 atomic/集合合并:

  • request_pty(pane_id) 记录哪些 source 脏;
  • request_generic() 表示无法安全局部化的变化;
  • 重复请求在 take() 前合并;
  • 主循环醒来后一次取走当前批次。

需要保留每条语义的 lifecycle event 仍走 AppEvent;只表达“最新画面脏了”的信号才合并。

15.5 PTY actor 的通道分级

用户 input 通道有界 1024;terminal response/control 使用不同路径。这样高吞吐 paste 不会无限增长,也不应把 resize/shutdown/DSR response 永久排在末尾。

设计队列时要问:

消息 能否丢/合并 是否需要顺序 适合机制
key/paste bytes 通常不能静默丢 是 有界 FIFO + backpressure
resize 可保留最新 只需最终值 合并 control state
render dirty 可合并 不需每次 RenderSignal
child exit 不能丢,但重复可去重 生命周期顺序 AppEvent
Agent status report 可拒绝旧 sequence 同 source 顺序 state arbiter
frame 中间可跳,最终必须一致 baseline 顺序 per-client queue/repaint

“所有消息用一个 channel”会抹掉这些不同语义。

15.6 Retained rendering 的 gate

Headless server先判断 render request:

  1. 只有 PTY source 脏、没有 generic full 变化?
  2. source 当前可见且 geometry 未变?
  3. runtime 能收集可信 dirty patch?
  4. patch 不与 hyperlink/graphics/overlay 等危险区域冲突?
  5. virtual frame baseline 仍有效?

全部通过才局部应用;任何 gate 失败回落 full render,并记录具体 fallback 原因供 profiling。

apply_terminal_dirty_patch 与 render_and_stream 周边展示这条链。

15.7 只优化可见呈现

隐藏 terminal 的 PTY 必须持续 drain/parse,否则 child 会因 PTY buffer 满而阻塞。可以省的是:

  • cell conversion;
  • app frame 合成;
  • client diff/serialization;
  • graphics texture/placement 呈现。

这条边界避免一个常见“优化”:后台 tab 不读输出。后者会改变程序行为,不是性能优化。

15.8 每客户端背压

一个慢 SSH client 不能阻塞主循环或其他 client。每个 transport 有自己的发送状态/queue:

flowchart LR
    Frame["new server frame"] --> C1["client A encoder/baseline"]
    Frame --> C2["client B encoder/baseline"]
    Frame --> C3["client C encoder/baseline"]
    C1 -->|fast| Commit1["send + commit"]
    C2 -->|queue full| Repaint["keep old baseline / mark repaint"]
    C3 -->|disconnect| Drop["remove client only"]

中间画面可丢,因为帧是 state projection;但 baseline 不能提前提交。恢复手段是发送 full repaint,不是回放所有旧动画。

15.9 大消息上限

分层上限保护内存放大:

数据 固定快照上限
普通 binary wire frame 约 2 MiB
graphics wire frame 约 32 MiB
clipboard image 约 16 MiB
Kitty image storage 约 64 MiB
API 初始 request 约 1 MiB
handoff 单批 FD 64

上限应在 allocation/read 完整 payload 前验证。不同类别使用不同 budget,避免为了允许图像而把普通控制消息也放大到 32 MiB。

15.10 锁的范围

PTY reader 会写 VT,renderer/detector会读。降低竞争的方式包括:

  • reader 每批字节一次进入 terminal write,而非逐 byte lock;
  • render 只抓当前 viewport/dirty patch,不 clone 整个 history;
  • process detection 离开 render lock;
  • metadata callback 通过事件更新 TerminalState;
  • background pane 跳过 cell snapshot;
  • 不持 VT lock 等待 client send 或磁盘 I/O。

最危险的锁不是“存在 Mutex”,而是持锁范围跨入未知延迟操作。

15.11 Git、插件、更新离开主循环

以下工作都可能秒级:

  • git worktree add/remove/status;
  • plugin external command;
  • curl manifest/download;
  • SSH probe/transfer;
  • notification/clipboard 平台命令;
  • 大 snapshot serialization/write。

它们由 background thread/task/独立 API worker执行,完成后发送小结果。主循环在提交前重新验证 operation id、identity 或 current revision。

异步化不是“spawn 后不管”;新鲜度验证和有界日志/通道才完成设计。

15.12 Deadline 合并

状态变化会安排:

  • session save debounce;
  • Agent detection poll;
  • Git status refresh;
  • notification;
  • update check;
  • deferred worktree/API response。

若每项都 spawn 独立 timer,会增加 task 和唤醒。主循环计算最近 deadline,把已到期任务批量处理,然后在 tokio::select! 中用最早时间唤醒。紧邻的 persistence/render 更新因此自然合并。

15.13 API wait 的扩展性

长 agent.wait 不占 App loop,只保留连接 worker + EventHub sequence + 偶尔 probe。EventHub history 有界,client disconnect 会取消 worker。

仍需关注:大量 wait 连接会产生线程/task和 probe burst。合理策略是:

  • 每连接/用户限制;
  • 事件唤醒后只 probe 相关目标;
  • timeout 强制有界或允许显式无限但断线可取消;
  • EventHub payload clone 保持小;
  • 不在每次 PTY chunk 发布大 output event。

15.14 Profiling 与 scale benchmark

just bench-render-scale 对 1、15、50 等不同 pane/workspace profile 运行 render scale 基准;src/render_prof.rs 和 render stream 测试记录 median/p95、fallback cause 等。

Scale benchmark 比单 pane microbenchmark 更能暴露:

  • O(panes) scan 潜入每帧;
  • hidden pane 仍做 cell conversion;
  • per-client clone;
  • cache key失效导致全量;
  • full fallback 率过高。

15.15 静态架构测试的作用

scripts/test_ui_hot_path_architecture.py 不测纳秒,而是禁止已知坏形状重新出现,例如 render 中 process inspection 或格式化大 snapshot。

性能回归经常在小测试中不可见,直到用户有 30 panes。把代码结构作为测试对象,可以在 benchmark 波动之前拦截乘法成本。

15.16 潜在瓶颈与评价

当前优势

  • 单写者减少领域锁复杂度;
  • render signal 与 event 分流语义正确;
  • retained rendering 有保守 fallback;
  • client baseline/backpressure隔离慢消费者;
  • Git/SSH/plugin/update 不阻塞 loop;
  • 有 scale benchmark 和架构守卫。

需要持续观察

  • HeadlessServer 和 PaneTerminal 文件体量大,局部变更容易无意触发 full path;
  • 每 client 的 full semantic frame clone 在大 viewport/多客户端下仍昂贵;
  • Agent detection 多 manifest/进程 poll 的调度频率;
  • snapshot/history 序列化峰值;
  • graphics generation、hyperlink 与 dirty patch 组合导致 fallback 率;
  • 单写者在大量 API mutation 下成为串行上限。

单写者不是永远的性能障碍:只要重量工作已外移,提交 state mutation 通常很短。应先 profile 哪个阶段占时,再决定是否引入更复杂并发写。

15.17 本章结论

  1. 先用乘法成本模型决定什么绝不能进入热路径;
  2. 对事件、输入、resize、render 和 frame 分别选择丢弃/合并/顺序策略;
  3. 有界队列需要公平 drain,不能只设置容量;
  4. 增量优化必须有明确 baseline 提交点和保守 full fallback;
  5. 重量 I/O 外移后,completion 仍要用 identity/operation id验证新鲜度;
  6. scale benchmark 与静态架构守卫互补。

最后一章用测试和代码演进反向验证整套理解,并对 Herdr 当前架构的成熟点、迁移接缝和适合贡献的方向做总结。