39 --- 系统的系统
到目前为止的主干假设每个系统都运行每个滴答,并在滴答预算内完成。这涵盖了模拟器的大部分工作------运动、EBP 分发、清理、持久化------以及围绕这些假设的章节。但这个假设并非普遍适用。实际模拟器至少有三种不符合此假设的工作类别。
- 优化。 一个调度器决定每个仓库机器人接下来应该取哪个任务。一个战斗 AI 选择一个对抗策略。一个约束求解器找到一个可行的计划。这些可能需要几秒钟或几分钟;它们无法适应 33 毫秒的滴答。
- 搜索。 在一个大地图上寻路。在一个有百万生物的世界中进行邻居查询。即使有[第 28 节](#第 28 节)的空间排序,一些搜索确实需要的时间超过了一个滴答所能承受的时间。
- 循环外工作。 一个游戏 AI 在单独的进程中演进其策略。一个在远程服务器上运行的定价模型。一个交给工作池的预计算。模拟器从不阻塞等待;结果在到达时到达。
本章命名了三种模式,它们涵盖了这些情况,而不会破坏主干之前的任何规则。它们不是新的架构。它们是主干现有的规则,应用于更广泛的节奏集。
统一的原则是:一个系统有一个节奏,而这个节奏不一定是一个滴答。 一个系统可以每个滴答运行一次(运动)。它可以每 N 个滴答运行一次([第 28 节](#第 28 节)的空间排序每 50 帧重新运行一次)。它可以有一个截止时间 ,并在截止时间到达时返回其当前的最佳答案。它可以跨滴答暂停和恢复 ,其进度是其状态的一部分。它可以完全脱离循环 ,仅通过[第 35 节](#第 35 节)的队列与模拟器通信。DAG 自然地推广:边仍然代表依赖关系,但一些依赖关系等待的是承诺(promise),而不是同步返回。
随时算法
一个随时 算法在其开始后的任何时间都能产生一个有效的答案。它运行的时间越长,答案就越好。蒙特卡洛树搜索、模拟退火、进化算法、分支定界、CP-SAT------都是随时算法。它们有一个共同的形态:维护一个当前最佳 ;只要时间允许就改进它;当预算用完时返回当前最佳。
python
def plan_route(world: "World", deadline: float) -> Route:
"""返回在 `deadline`(一个 perf_counter() 值)之前找到的最佳路径。"""
best = greedy_route(world)
while time.perf_counter() < deadline:
candidate = improve(best, world)
if score(candidate) > score(best):
best = candidate
return best
截止时间就是预算。算法尊重它。质量是可用的时间量的函数------在 5 毫秒时,它是平庸但有效的;在 50 毫秒时,它很好;在 500 毫秒时,它接近最优。模拟器可以给予它滴答所允许的任何预算,并且永远不会被阻塞。
这是[第 4 节](#第 4 节)应用于长时间计算:预算被明确命名,算法尊重它。已经内化了预算计算的学生已经知道如何设计这些算法;唯一的新词汇是随时契约。
时间片计算
有些工作无法变成随时算法------在工作完成之前没有"最佳部分答案"。一个已经检查了 20% 单元格的空间搜索有 20% 的机会找到了答案;否则它就没有什么有用的信息可以报告。对于这些,模式是时间切片 :将工作分配到多个滴答中,系统的进度作为其持久状态的一部分。
python
@dataclass
class SpatialSearch:
target_x: float
target_y: float
cursor: int = 0 # 下一个要检查的单元格索引
best_id: int = -1 # 迄今为止的最佳候选
best_dist: float = float("inf")
def step(self, world: "World", max_cells: int) -> bool:
"""检查最多 `max_cells` 个单元格。完成后返回 True。"""
end = min(self.cursor + max_cells, len(world.cells))
for cell_idx in range(self.cursor, end):
for cid in world.cells[cell_idx]:
d = (world.pos_x[cid] - self.target_x) ** 2 + \
(world.pos_y[cid] - self.target_y) ** 2
if d < self.best_dist:
self.best_id = cid
self.best_dist = d
self.cursor = end
return self.cursor >= len(world.cells)
每次调用检查 max_cells 个单元格。模拟器每滴答(或每 N 个滴答)运行一次 step;进度累积在 cursor 和迄今最佳字段中;当 cursor 到达末尾时,搜索完成并交付结果。从模拟器的角度来看,搜索是一个系统,它在完成之前每滴答占用其预算。
这是[第 15 节](#第 15 节)应用于长时间计算:系统在滴答开始时的状态包括其正在进行的工作。 允许每个系统看到一致输入的缓冲规则,也允许系统从中断处继续。
循环外计算
对于那些真正大到任何 滴答预算都无法承受的工作------一个游戏 AI 重新规划其宏观战略、一个离线机器学习模型、一个远程优化服务------模式是循环外 :工作在一个单独的进程或机器中运行,完全在模拟器的滴答之外。模拟器从不阻塞。当工作完成时,其结果像任何其他输入事件一样,通过输入队列([第 35 节](#第 35 节))进入模拟器。
python
# 循环外,在工作进程中:
def ai_planner_worker(snapshot_q, result_q):
while True:
snapshot = snapshot_q.get()
if snapshot is None:
break
strategy = compute_counter_strategy(snapshot) # 可能需要几秒钟
result_q.put(("strategy_update", strategy))
# 在模拟器的滴答内部:
def dispatch_ai(world, snapshot_q):
if world.tick % 30 == 0: # 在 30 Hz 下每秒钟一次
try:
snapshot_q.put_nowait(snapshot_of(world))
except queue.Full:
pass # 上一个快照仍在处理中
模拟器每秒派发一个快照;AI 进程处理它;策略更新稍后到达输入队列。策略可能晚三个滴答,或晚三秒------模拟器不知道也不关心。结果只是一个输入事件;队列机制是相同的。
这是[第 35 节](#第 35 节)应用于长时间计算:任何跨越边界的事物都需要自己的时间,并且队列吸收延迟。规范是不等待------永远不要在滴答上阻塞等待循环外结果。
分层调度
生产模拟器通常会结合这些模式。游戏引擎以 60 Hz(每滴答)运行物理引擎,以 5 Hz(每 12 个滴答)运行 AI,以 0.1 Hz(每 300 个滴答)保存游戏,并在一个工作进程上循环外运行一个战略规划器。工业控制回路以 1 kHz 运行内循环,以 10 Hz 运行外循环。DAG 被推广:每个系统都标注了其节奏;调度器根据其频率或触发条件运行每个系统;结果是一个系统的系统------一个架构,多种节奏。
在 Python 中,节奏调度器是一个函数:
python
def schedule_for_tick(systems: list["System"], tick: int):
return [s for s in systems if tick % s.period_ticks == 0]
结合第 32 节的通风机,这给你一个滴答,其工作形态按设计变化------运动每滴答运行,空间排序每 50 个滴答运行一次,AI 派发每 30 个滴答运行一次,快照每 1000 个滴答运行一次。DAG 数组以与工作负载异构相同的方式适应。
垂直扩展先于水平扩展
!NOTE
在"循环外计算"之后,很自然会问的下一个问题是"那跨机器呢?"------将模拟器拆分到多个节点上,一台机器运行物理引擎,另一台运行 AI,另一台运行可视化。默认答案是否定的。 机器之间的网络往返需要约 5 毫秒(数据中心)到约 100 毫秒(互联网)。对于一个 30 Hz 的滴答(33 毫秒预算),单次网络跳转在最佳情况下消耗 15% 的预算,在典型的互联网延迟下消耗整个滴答。现代单机性能强大------服务器 CPU 配备 64-128 核心、数 TB 内存、多通道 DDR5。租用一个更大的单机几乎总是比协调许多小单机更便宜。 只有在单机确实无法容纳工作负载时才进行水平扩展,并接受水平扩展会强制架构变更(最终一致性、网络故障处理、部署复杂性)而单机架构不需要这些。本章中的"循环外"模式处理的是同一台机器上的独立进程 ;这是一个与跨网络的不同机器不同的决策。参见 Tristan Hume 的"Production Twitter on One Machine",了解这一论点应用于著名的分布式工作负载的详细版本。
结束第 9 部分
本章是建设性的:它命名了三种模式,并展示了每种模式在模拟器现有结构中的适用位置。下一阶段规范 ,讨论后续问题:如何让架构在随着时间推移、人员变动、需求变更时保持有效。使其工作 是本章的内容;保持其工作是接下来的四章的内容。
练习
- 审计节奏。 对于你模拟器中的每个系统,说出它的节奏。大多数是"每滴答";那些不是的是本章模式的应用候选。注意任何目前因其工作会超出预算而被限制或跳过的工作------这些是这些模式可以满足的未满足需求。
- 随时寻路器。 为一个生物实现
plan_route(world, deadline)。该函数在截止时间前返回找到的最佳路径。在 5 毫秒的截止时间下,看看答案有多好;在 50 毫秒下,看看好多少。绘制质量与截止时间的关系图。 - 时间片空间搜索。 实现如正文所述的
SpatialSearch和step。跨多个滴答运行它,每滴答通过预算限制的max_cells推进光标。验证结果与一次性完成的单次搜索相同。 - 循环外 AI。 通过
multiprocessing.Process产生一个工作进程,该进程通过一个multiprocessing.Queue接收世界快照,并通过另一个队列返回策略更新。每秒派发一个快照;让工作进程花费 5 秒;观察模拟器的滴答率不受影响,并且策略更新在准备好时到达输入队列。 - 混合节奏。 运行你的模拟器,运动每滴答运行,为局部性排序每 50 滴答运行一次,快照每 1000 滴答运行一次,并且(模拟的)AI 进程循环外更新策略。验证确定性仍然成立:相同的种子加上相同的输入队列在 1000 次滴答后产生相同的哈希值(根据[第 16 节](#第 16 节)和[第 34 节](#第 34 节))。
- 垂直扩展的算术。 对于你的模拟器在满负荷下的预期工作负载,计算每滴答预算和工作集。它能容纳在一个现代单机(1 TB RAM、128 核、多通道 DDR5)中吗?如果是,你不需要水平扩展。如果否,你确实有理由考虑它。
- (挑战) 不同预算下的随时算法。 修改寻路器,使其调用者每次传递剩余的滴答预算。一些滴答预算充足;一些滴答预算很少。寻路器在每种情况下仍会返回一个有效的答案,并且当预算允许时,答案会改进。绘制模拟器运行时质量随时间的变化图。
接下来是什么
[第 40 节------机制与策略](#第 40 节——机制与策略) 开启了规范 :随着时间推移保持架构完整的规则。本章是关于使 系统适合不符合标准滴答的问题,而接下来的四章是关于随着系统老化保持其工作。
40 --- 机制与策略
一个系统的内核暴露动词。规则------什么允许,什么触发什么------存在于边缘。混淆两者是系统僵化的原因;一旦内核知道了某个规则,该规则就无法在不重写内核的情况下改变。
这个原则比 ECS 更古老。它出现在操作系统内核设计(Mach、X11、Plan 9 都教授此规则)、网络协议设计(TCP 是机制,拥塞控制是策略)以及文件系统设计(读/写/寻址是机制,访问控制是策略)中。相同的形态适用于 ECS 系统。
在模拟器中:
cleanup是机制 。它接收to_remove和to_insert,通过[第 22 节](#第 22 节)的批量掩码过滤和追加模式应用它们,并更新id_to_slot。它对于哪些 生物应该被移除或为什么没有意见。它只是提交其调用者请求的更改。apply_starve是策略 。它读取energy并将energy <= 0的生物的 ID 推送到to_remove。"生物在能量归零时死亡"的规则就在这里。将规则改为energy < -10或energy < threshold for 100 ticks,只有apply_starve会改变;cleanup保持不变。
这种分离在三个方面得到回报。
可替换的规则。 一个新的游戏变体------"生物不会死亡,它们会冬眠"------是在不变机制之上的一个新策略。apply_starve 变成 apply_hibernate;cleanup 仍然有效,因为 cleanup 不知道这些系统在做什么。内核是稳定的;规则是可移动的。
可组合的规则。 作用于相同内核的两个策略可以组合:一个系统标记"过期"的生物,另一个标记"被捕食"的生物。两者都推送到 to_remove。cleanup 应用两个批次,而不知道任何一个为何被设置。
可测试的规则。 一个测试夹具直接设置 to_remove 和 to_insert,单独运行 cleanup,并对结果进行断言。机制可以隔离测试。每个策略的测试夹具设置 creatures 并对策略推送到缓冲区的断言。机制测试和策略测试不需要彼此。
将策略埋藏在机制中的三种 Python 反形态
Python 使得机制与策略的纠缠变得容易触手可及。三种值得命名的模式。
在 setter 中执行验证和提交的 @property。 在其 setter 中运行业务规则的 @property 是隐藏在属性赋值中的策略:
python
# 反模式:错误的!
class Creature:
@property
def energy(self): return self._energy
@energy.setter
def energy(self, v):
if v < 0:
self._dead = True # 策略:"低于零是死亡"
self._world.dead_table.add(self.id) # 机制:活跃表修改
self._energy = v
两个角色融合在一个赋值中。替换策略("在零时冬眠")需要编辑 setter;替换机制(使用缓冲清理而不是活跃表修改)需要编辑同一个 setter。它们变成了同一个更改。
隐藏控制流的装饰器。 @lru_cache、@retry、@require_auth、@validate_input 都在它们包装的函数周围运行代码------根据定义,对调用点隐藏。当装饰器决定是否运行该函数时,它就是嵌入在机制中的策略:
python
# 反模式:错误的!
@cache_for(seconds=60)
@require_role("admin")
def remove_creature(world, cid): ...
该函数的读集合和写集合不再能从其签名中推导出来。它是否运行取决于缓存状态和角色状态------在调用点不可见。第 13 节的契约消失了。
__getattr__ / __setattr__ 覆盖。 当任意读取 creature.foo 触发数据库查找或网络调用时,模拟器的滴答就不再是纯粹的了。每次 getattr 都可能变成 I/O。第 35 节的边界在最不起眼的行被突破。
在所有三种情况下,修复方法都与第 22 节的清理模式形态相同:将决策 (策略)与提交(机制)分离。决策进入一个写集合为缓冲区的系统;提交系统读取缓冲区并应用它。两个函数,两个读集合,两个写集合------规则恰好存在于其中之一。
本书的反模式,用一行表示
一个直接修改"活跃"表的系统:
python
# 反模式:错误的!
def food_spawn(food, world):
if some_condition(world):
food.append(...) # 绕过 to_insert;现在 cleanup 是多余的
现在 food_spawn 同时做了决策 (何时生成食物)和提交 (写入 food)。两方面的更改都需要重写它:一个新的生成规则(策略更改)和一个新的清理机制(机制更改)。它们变成了同一个更改。内核与其当前的规则紧密结合。
解决方法是将数据推送到 to_insert,让 cleanup 提交。两个角色是可分离的,因为它们是通过[第 22 节](#第 22 节)的缓冲模式(本身就是一种机制与策略的分离)设计成可分离的。机制 是"在边界应用更改";策略是"应用哪些更改"。
因此,机制与策略不是一个单独的规范。它是前面所有章节都隐式遵守的规则。命名它使其可见。
练习
- 找到机制。 对于你模拟器中的每个系统(motion, food_spawn, next_event, apply_eat, apply_reproduce, apply_starve, cleanup, inspect),分类:这是机制 (提交别处请求的内容),策略(决定要请求什么),还是两者兼有?注意每个角色所在的位置。
- 替换一个策略。 将
apply_starve的规则从energy <= 0改为(energy < -10) & (age > 100)。确认:只有apply_starve更改;cleanup保持不变。 - 在同一机制上添加一个新策略。 编写一个新的系统
apply_predation,它将"被捕食"的生物的 ID(根据某个其他规则)推送到to_remove。两个策略的输出都流向cleanup,cleanup不加区分地应用它们。 - 发现反模式。 在你的模拟器中找到任何直接写入"活跃"表而不是写入
to_insert或to_remove的系统。重构。 - 审计你的装饰器。 在你的代码中搜索带有副作用 setter 的
@property、有状态函数上的@cached装饰器或__getattr__/__setattr__覆盖。每个都是策略埋藏在机制陷阱的候选。对于每个,问:能否将策略提取为一个写集合为缓冲区的系统? - (挑战) 第二个机制。 假设你想要一个"软删除"------生物被移到一个
dead表而不是被移除。实现一个新的机制(cleanup_with_archive),而不触及现有的策略。相同的to_removeID;不同的机制被应用。通过在 DAG 中交换系统来在它们之间切换,而不是通过编辑生产数据的系统。
接下来是什么
[第 41 节------面向压缩的编程](#第 41 节——面向压缩的编程) 是首先编写内核和策略的规范:在提取任何抽象之前,先编写三个具体的案例。