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:
- 只有 PTY source 脏、没有 generic full 变化?
- source 当前可见且 geometry 未变?
- runtime 能收集可信 dirty patch?
- patch 不与 hyperlink/graphics/overlay 等危险区域冲突?
- 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 本章结论¶
- 先用乘法成本模型决定什么绝不能进入热路径;
- 对事件、输入、resize、render 和 frame 分别选择丢弃/合并/顺序策略;
- 有界队列需要公平 drain,不能只设置容量;
- 增量优化必须有明确 baseline 提交点和保守 full fallback;
- 重量 I/O 外移后,completion 仍要用 identity/operation id验证新鲜度;
- scale benchmark 与静态架构守卫互补。
最后一章用测试和代码演进反向验证整套理解,并对 Herdr 当前架构的成熟点、迁移接缝和适合贡献的方向做总结。