在现代前端框架和响应式系统设计中,信号(Signals)已经成为一种重要的状态管理范式。而 Push-Pull 算法作为实现信号系统的核心机制,结合了推模式与拉模式的优点,能够在保证数据一致性的同时实现高效的细粒度更新。本文将从工程实现的角度,深入剖析 Push-Pull 算法的设计原理与关键参数。

响应式系统的基本需求

在设计响应式引擎时,我们需要满足几个核心需求。首先是高效性:每个计算单元在单次更新周期内最多只计算一次,不会执行任何立即被丢弃的计算。其次是细粒度:只有受到输入变化影响的节点才会被更新,未受影响的节点保持不变。第三是无冲突性:当节点值发生变化时,所有相关节点同时更新,不存在中间状态可供外部观察。第四是动态性:每次计算时节点可以动态添加或移除依赖关系。

传统的 Push(推)模式和 Pull(拉)模式各有优劣。Push 模式在输入变化时立即向下游推送更新通知,优点是延迟低、通知精准,但缺点是容易产生重复计算,且难以保证无冲突性。Pull 模式则在需要时从上向下拉取数据,天然具备无冲突性,但可能导致大量不必要的计算。Push-Pull 混合模式正是为了解决这些问题而提出的。

Push 阶段:脏标记传播

Push 阶段的核心任务是识别哪些节点需要更新,而不是真正执行更新。具体实现上,每个节点维护一个布尔类型的脏标记(dirty flag)。当输入节点发生变化时,系统从该节点出发,递归遍历其所有下游节点。遍历算法遵循以下规则:如果当前节点已被标记为脏,则跳过;如果未被标记,则将其标记为脏,并根据节点类型决定后续行为 —— 对于输出节点,将其加入待更新列表;对于中间节点,则继续递归访问其子节点。

这种设计的关键优势在于遍历顺序无关性。传统 Push 模式需要计算拓扑排序来确定最优更新路径,而 Push-Pull 模式只需简单递归并跳过已脏节点即可。这意味着节点只需维护直接上游和下游的邻接关系,无需维护全局有序列表,大大降低了动态依赖场景下的实现复杂度。

Pull 阶段:按需计算与缓存

识别出需要更新的节点后,Pull 阶段负责实际执行计算。与简单 Pull 模式不同的是,此时系统已经明确知道哪些输出节点需要更新,因此可以精确地只计算这些节点。计算过程中,每个节点检查自身状态:如果节点是干净的,直接返回缓存的计算结果;如果节点是脏的,则递归计算所有上游依赖项,标记自身为干净,并存储计算结果。

Pull 阶段的缓存机制是性能关键。常用的实现方式包括生成计数器(generation counter)和 LRU 缓存。生成计数器方案较为简单:每次输入变化时递增全局计数器,所有缓存值随之失效。LRU 缓存则更精细,可以保留部分近期使用过的计算结果,但需要额外处理缓存失效逻辑。

无冲突更新的保证

Push-Pull 模式能够天然保证无冲突性,原因在于整个 Pull 过程在一个完整的执行周期内完成。只要在 Pull 阶段执行期间不修改任何输入节点,所有节点看到的就是一致的输入状态。在单线程运行时(如 JavaScript)中这个条件很容易满足;在多线程场景下,则需要使用简单的锁机制确保 Pull 阶段完全结束后才允许修改输入。

这种设计避免了传统 Push 模式中节点间状态不一致的问题。在 Push 模式下,节点 A 更新后可能立即触发节点 B 的更新,但节点 C 尚未更新,此时外部观察者可能看到不一致的中间状态。Push-Pull 模式将更新分为识别和执行两个阶段,有效规避了这一风险。

工程实践参数建议

在实际实现中,以下参数值得注意:脏标记传播建议使用深度优先遍历并配合已访问集合去重;对于中等规模图(节点数在千级),两阶段遍历的复杂度为 O (n),其中 n 为需要更新的节点数;缓存策略建议默认采用生成计数器方案,只有在性能分析显示缓存命中率显著时才考虑 LRU 方案;动态依赖场景下,应在节点计算完成后立即更新其下游邻接关系。

对于需要处理长计算任务的场景,建议将计算拆分为状态机,分多次 Tick 完成更新,避免阻塞主线程。但这会引入额外的复杂性,需要权衡利弊后决定是否采用。


资料来源:Jonathan Frere, "Pushing and Pulling: Three Reactivity Algorithms" (https://jonathan-frere.com/posts/reactivity-algorithms/)