Newshot
Account

使用 glimmer 和 Datastar 进行批量渲染

Translation(model translation, not the source's own text)

使用 glimmer 和 Datastar 进行批量渲染

Anders Murphy 最近写了一篇关于用批次与聚合实现更快的流式 HTML 的文章。思路很简单。当服务器向页面流式传输 HTML 时,应当把变更收集起来,对整个批次只渲染一次。同样,对两个不同页面避免重复渲染同样的内容也是合理的,这样就不会把客户端已经拥有的数据再发送一遍。

我一直在为 jolt 构建 glimmer 和 glimmer-datastar,用来做响应式服务端渲染,并遇到了类似的问题。本文介绍这两个库是如何被重构以支持批量处理的,以及这些改动在 newsroom 应用中是如何被使用的。

基本设置

Glimmer 是一个 Reagent 风格的库,状态存放在 ratom 中,UI 组件订阅这些 ratom。最初的目标是 glimmer-gtk 和 glimmer-tui 这类原生 GUI,但响应式核心与具体工具包无关。所以,我自然好奇能否在服务端使用同一套模型、前端用 Datastar。

它的工作方式是:每个打开的页面都与服务器保持一条 Server-Sent Events 流。页面渲染时,页面读取过的任何 ratom 都会被记录下来,流会订阅它。之后只要这些 ratom 之一发生变化,就会触发该流重新渲染页面。因此,当对某个 ratom 执行 swap! 时,就会向所有渲染它的页面发送一个携带新 HTML 的 datastar-patch-elements 事件。


        (def state (ratom/atom {:greetings []}))

(def handler
  (-> (fn [_] {:status 200 :body (render-page @state)})
      (datastar/wrap-datastar {:tick-ms 15})))
      

第一版做的是最直接的做法:在写入时唤醒每个读取该 ratom 的流去执行渲染。这种朴素做法在只有少数页面、偶尔写入时运行良好,但在负载下无法扩展,因为开销最终是 写入次数 × 页面数。

每个 tick 渲染一次,而不是每次写入渲染一次

这里的解决办法是把写入与渲染请求解耦。写入只需要把读取该 ratom 的流标记为脏,然后由一个独立的渲染循环按照 :tick-ms 指定的固定定时器运行。每个脏流每次醒来时恰好被渲染一次,并带上自上次以来累积的所有变更:


        (defn mark-dirty! [sched conn]
  (swap! (:dirty sched) conj conn)
  (wake! sched))

(defn- tick! [{:keys [txs dirty batch-fn] :as sched}]
  (run-batch! batch-fn (drain txs))
  ;; snapshot after the writes, so the connections they dirtied render now
  (let [[conns _] (swap-vals! dirty (constantly #{}))]
    (render-all! sched (vec conns))))
      

该循环会先睡掉当前 tick 剩余的时间再渲染,让所有到达的变更都落进同一批。同时,空闲的循环只是停在 channel 上,不产生任何额外开销。

对于一个有十条打开的流、连续一百次 swap! 的突发,效果如预期般相当显著:

渲染次数发送帧数
每次写入唤醒一次100055
渲染 tick2020

之前,服务器不得不做一千次渲染,其中大部分都被丢弃了。有了 tick 之后,渲染次数取决于页面数量和突发持续的时间,而不是其中包含多少次写入。顺带一提,glimmer 的 GUI 一侧本来就是这么工作的,因为组件的 watcher 会持有一个 pending 标志,确保一连串变更在 UI 线程上只排入一次延迟渲染。

慢客户端丢帧而不是排队

一个 tick 每条流最多产生一帧,每条流通过一个滑动缓冲区大小为 1 的 channel 发送帧。如果下一帧准备好时客户端还没取走上一帧,旧帧就会被丢弃,这样客户端总能拿到最新的更新。这样一来,糟糕的连接既不能把渲染循环堵住,也不会堆积出无界的陈旧 HTML 队列。

用 tx! 进行批量写入

自从 tick 会批量渲染,写入也顺理成章地可以按需批量处理。这是通过 tx! 完成的:它把一个 thunk 排入队列,该 thunk 会在下一个 tick 开始时、渲染发生之前,在循环线程上运行:


        (fn [request]
  (datastar/tx! request #(swap! state update :greetings conj "hello"))
  {:status 204})
      

写入和渲染必须轮流进行,这样渲染就不会观察到只更新了一半的状态。如果你传入 :batch-fn,它会一次性收到某个 tick 的所有 thunk,因此你可以在一个事务中运行它们;同时调用 tx! 会返回一个 promise,其结果为该 thunk 的返回值。

使用组件缓存避免重复渲染同一内容

虽然 tick 减少了每个页面的渲染次数,但不同页面之间的浪费同样值得考虑。如果有一百个人在看同一个 300 行的看板,每个 tick 把它渲染一百次就会产生一百个完全相同的字符串。

为了解决这个问题,glimmer-datastar 在 jolt.datastar.html 中提供了自己的 hiccup 渲染器。向量的头部是 [row item] 的组件,它的 HTML 缓存在该向量本身之下,因此键就是函数加上它的参数。现在整个看板每次变化只需渲染一次,所有展示它的页面共享这一结果。

一如既往,棘手之处在于判断缓存条目何时过期;由于组件除了参数之外还会读取 ratom,glimmer 现在为此提供了 capture:


        (let [a (atom 1) b (atom 2)]
  (r/capture #(+ @a @b)))   ; => [3 [a b]]
      

它运行一个函数,并返回其结果,以及该函数读取过的每一个响应式 cell。缓存会把每个条目连同这些 cell 以及它当时看到的值一起存储;一旦其中任何一个值发生变化,就说明需要刷新缓存。而在命中时,缓存只需再次读取这些 cell。

缓存只有在某个条目被请求不止一次时才会接纳它,因此只渲染一次、之后再也不会渲染的组件不会把每帧都渲染的组件挤出去。它按代进行淘汰,这大致相当于 LRU,但在命中路径上不需要对每个条目做额外记录。

在一台 4 核机器上,用 100 条流展示 300 行的看板、每个 tick 有一行发生变化时,一个 tick 的耗时如下:

render-threads1248
no component cache891 ms472 ms481 ms480 ms
component cache68 ms55 ms55 ms47 ms

在这里,缓存是最大的收益来源,而渲染线程数则趋于平稳,因此默认值是 2。

此外,capture 还解决了另一个与速度无关的问题。由于一条流现在确切知道自己上一次渲染读取了哪些 cell,它只需要监视这些 cell。它不再读取的 cell 可以立即取消监视,而不是留下一个订阅,在之后触发多余的渲染。

不发送客户端已有的内容:patch

既然渲染不再是瓶颈,网络传输就成了主要成本,因为对那个 300 行看板做单行修改,仍然要发送 40 KB 的页面。这里一个显而易见的解决方案是启用流式压缩,这能解决一部分问题。使用 ring-chez-adapter 的 wrap-gzip 可以跨帧用单个 deflate 窗口压缩 SSE 流,因此一帧如果与上一帧几乎相同,压缩后几乎不占什么空间。但这只在单帧能放进 deflate 的 32 KB 窗口内时才成立。超过之后,节省就基本消失了,即使只改一行也要花费 4316 个 gzip 字节。

所以,我们需要用 patch 来解决根本问题。根元素带有 id 的组件,例如 [:tr {:id ...}],是页面的一部分。由于每条流都会把新页面与上一个页面逐部分比较,它只需要发送发生变化的部分。然后,在前端,Datastar 会按 id 把每个部分替换进去。当页面在其各部分周围发生变化时,例如添加了一行、每个部分之外的文本被编辑,或者客户端漏掉了一帧,整个页面仍然会被发送。

rowsgzip stream, per changepatch, gzip stream
5092 bytes28 bytes
3004316 bytes35 bytes

由于补丁的大小取决于变更而不是页面,比较的开销也很低。一个被缓存的组件在每一页都会以同一个共享向量带上它的各个部分,所以 diff 可以通过同一性检查跳过它。在我的机器上,这大致把每个 tick 的耗时减半:从使用整帧的 21.7 ms 降到使用补丁的 7.0 ms。使用增量对于增量视图维护至关重要,因为只有页面中发生变化的部分才需要作为更新发送。

聚合:跟随变更的派生状态

不过我们还没完,因为还可以应用另一个技巧:使用聚合。以 (reaction (count (filter open? @todos))) 这样的 reaction 为例。它在每次写入时都会从头重新计算,因此无论是一行还是一千行发生变化,这个 reaction 仍然要花费一千行的代价。

Glimmer 新的 glimmer.table 借鉴了 DBSP。table 是一个带键的集合 cell,而它之上的 view 只对发生变化的行进行映射或过滤。对于操作存在逆操作的聚合,比如 count 或 sum,只需把旧行取出、把新行放入:


        (def todos (t/table {1 {:title "a" :done false}}))
(def open (t/view todos (fn [_ v] (when-not (:done v) v))))
(def open-count (t/aggregate open t/count-of))

(t/put! todos 2 {:title "b" :done true})   ; costs one row, not the table
      

这些都是普通的响应式 cell,所以页面像其他任何东西一样用 @ 订阅它们。借助 tick、缓存和补丁,一次写入只需派生一行,现在只需渲染一个组件,并发送一个部分。

释放已关闭的页面

当然,批处理只有在你不为没人看的页面做工作时才有帮助。因此,当客户端离开时,服务器会关闭该流的 channel。该 channel 上的一个 transducer 会在关闭时运行,并取消监视该流原本跟踪的每一个 cell,从而可以立即对连接进行垃圾回收。而处理程序以重定向或错误(例如退出登录后的 401)回应的渲染,也会以同样的方式清理该流。

这在实践中是什么样子

我一直有兴趣理解世界新闻和事件是如何相互关联的。任何一天都会发生许多不同的事情,而那些常常看起来孤立的事件,往往可能有共同的深层原因。

决策者会得到由分析师准备的精致每日简报,分析师审阅事件以产出准备完美的报告。所以我想,为什么我不能也有类似的东西呢?

于是,我构建了 Newsroom,这是一个自托管的新闻工作台应用,用来收集来源并撰写适合我所关注内容的每日简报。该应用结合使用 RSS 和网页抓取,遍历数百个来源。然后它将新闻分组到类别中,并让 LLM 构建事件及其连接关系的图,为报告提供基础。

这是一个用来检验该库的完美项目,因为它有一个实时侧边栏,显示每个来源被聚合时的进度,以及模型流式输出时的进度。

这种工作负载对于每次写入渲染一次来说是最坏情况。一次运行会从多个线程并发获取其来源,而每个来源都会报告一个结果和一行日志。与此同时,模型的进度在流式输出时会每秒更新多次。过去,这些事件中的每一个都是对运行的 status ratom 的一次 swap!,而每一次 swap! 又都会转化为对每个正在观察该读者运行的页面的渲染。

现在,服务器请求 {:tick-ms 200},这可以防止事件泛滥。无论状态变化多快,一个打开的页面仍然最多每秒渲染五次,而一批同时完成的来源都会被打包进一帧。整个改动只有一行,因为 newsroom 已经通过旧的 :rate-limit-ms 选项请求了相同的 200 ms。不过底层的变化在于,现在一个 tick 会合并写入,而不只是把它们拉开间隔。

tick 还可能把两个相关的写入拆分到不同帧,以确保正确顺序。某个 source 过去会先设置自身状态,然后在第二次 swap! 中追加日志行,因此页面可能会在日志提到之前,就在 source 列表里显示 “ok, 12 items”。现在两者合并为一次写入:


        (update-status! (:run-id ctx)
                (fn [st]
                  (-> st
                      (update-in [:sources name] merge {:state :ok :count (count items)})
                      (add-event {:text (str name ": " (count items) " items") :level :ok}))))
      

这与 tx! 所强制保证的是同一个思路,只是在一个函数同时负责两次写入的地方手工完成。它还依赖 glimmer 的 swap! 是原子的,因为 sources 会同时从多个线程调用它。

每个 newsroom 页面有两个实时区域,侧边栏和文章,二者运行在独立的流上。侧边栏读取运行状态,该状态在运行期间不断变化。文章只读取一个在存储某一天时递增的计数器。由于流只监视其上次渲染读取过的内容,因此运行进行中时文章不需要重绘。

所有 reader 都有自己的通道,形式是一个 status ratom 和一个存储计数器,因此每个 reader 的运行只会弄脏他们自己的页面。当标签页关闭,或 reader 退出登录且流收到 401 时,流会丢弃其订阅。

顺带一提,同类的批处理对数据库也适用。让一次运行的状态持久化,就能在中断后从断点继续。但我们之前讨论过的同一个问题又从另一个方向回来了。每次 swap! 都把状态写入 SQLite,会导致每个进度 tick 都产生一次数据库写入。因此,Newsroom 改为把该运行标记为未保存,并由后台线程每秒写入一次发生变化的运行:


        (defn- update-status! [run-id f & args]
  (swap! (status-of run-id) (fn [st] (if (identical? run-id (:run st)) (apply f st args) st)))
  (when (and (instance? Run run-id) (.-job ^Run run-id))
    (swap! unsaved conj run-id)
    @saver))
      

这与标记状态为脏并在定时器上执行 flush 的 tick 模式相同。重启最多丢失一秒的日志,而无需担心 IO 压力。并且可以在运行结束时同步写入最终状态。

总结

我在本文中讨论的每一层都消除了不同种类的重复工作:

  • tick 确保每个页面每个 tick 只发生一次渲染,而不是每次写入都渲染
  • 组件缓存:进一步让每个页面上的每次变更只渲染一次
  • 滑动缓冲区让每个客户端只有一个在途帧
  • tx! 使得一次渲染看不到部分更新
  • 补丁只针对发生变化的页面组件发送变更
  • 表从变更中推导状态,而不是从集合本身推导
  • 关闭时释放会清理无人查看的页面

响应式模型是这一切的关键,因为用于决定重新渲染什么的依赖跟踪,也可以用来推断什么可以跳过。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗