DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程39-40

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 部分

本章是建设性的:它命名了三种模式,并展示了每种模式在模拟器现有结构中的适用位置。下一阶段规范 ,讨论后续问题:如何让架构在随着时间推移、人员变动、需求变更时保持有效。使其工作 是本章的内容;保持其工作是接下来的四章的内容。

练习

  1. 审计节奏。 对于你模拟器中的每个系统,说出它的节奏。大多数是"每滴答";那些不是的是本章模式的应用候选。注意任何目前因其工作会超出预算而被限制或跳过的工作------这些是这些模式可以满足的未满足需求。
  2. 随时寻路器。 为一个生物实现 plan_route(world, deadline)。该函数在截止时间前返回找到的最佳路径。在 5 毫秒的截止时间下,看看答案有多好;在 50 毫秒下,看看好多少。绘制质量与截止时间的关系图。
  3. 时间片空间搜索。 实现如正文所述的 SpatialSearchstep。跨多个滴答运行它,每滴答通过预算限制的 max_cells 推进光标。验证结果与一次性完成的单次搜索相同。
  4. 循环外 AI。 通过 multiprocessing.Process 产生一个工作进程,该进程通过一个 multiprocessing.Queue 接收世界快照,并通过另一个队列返回策略更新。每秒派发一个快照;让工作进程花费 5 秒;观察模拟器的滴答率不受影响,并且策略更新在准备好时到达输入队列。
  5. 混合节奏。 运行你的模拟器,运动每滴答运行,为局部性排序每 50 滴答运行一次,快照每 1000 滴答运行一次,并且(模拟的)AI 进程循环外更新策略。验证确定性仍然成立:相同的种子加上相同的输入队列在 1000 次滴答后产生相同的哈希值(根据[第 16 节](#第 16 节)和[第 34 节](#第 34 节))。
  6. 垂直扩展的算术。 对于你的模拟器在满负荷下的预期工作负载,计算每滴答预算和工作集。它能容纳在一个现代单机(1 TB RAM、128 核、多通道 DDR5)中吗?如果是,你不需要水平扩展。如果否,你确实有理由考虑它。
  7. (挑战) 不同预算下的随时算法。 修改寻路器,使其调用者每次传递剩余的滴答预算。一些滴答预算充足;一些滴答预算很少。寻路器在每种情况下仍会返回一个有效的答案,并且当预算允许时,答案会改进。绘制模拟器运行时质量随时间的变化图。

接下来是什么

[第 40 节------机制与策略](#第 40 节——机制与策略) 开启了规范 :随着时间推移保持架构完整的规则。本章是关于使 系统适合不符合标准滴答的问题,而接下来的四章是关于随着系统老化保持其工作。

40 --- 机制与策略

一个系统的内核暴露动词。规则------什么允许,什么触发什么------存在于边缘。混淆两者是系统僵化的原因;一旦内核知道了某个规则,该规则就无法在不重写内核的情况下改变。

这个原则比 ECS 更古老。它出现在操作系统内核设计(Mach、X11、Plan 9 都教授此规则)、网络协议设计(TCP 是机制,拥塞控制是策略)以及文件系统设计(读/写/寻址是机制,访问控制是策略)中。相同的形态适用于 ECS 系统。

在模拟器中:

  • cleanup机制 。它接收 to_removeto_insert,通过[第 22 节](#第 22 节)的批量掩码过滤和追加模式应用它们,并更新 id_to_slot。它对于哪些 生物应该被移除或为什么没有意见。它只是提交其调用者请求的更改。
  • apply_starve策略 。它读取 energy 并将 energy <= 0 的生物的 ID 推送到 to_remove。"生物在能量归零时死亡"的规则就在这里。将规则改为 energy < -10energy < threshold for 100 ticks,只有 apply_starve 会改变;cleanup 保持不变。

这种分离在三个方面得到回报。

可替换的规则。 一个新的游戏变体------"生物不会死亡,它们会冬眠"------是在不变机制之上的一个新策略。apply_starve 变成 apply_hibernatecleanup 仍然有效,因为 cleanup 不知道这些系统在做什么。内核是稳定的;规则是可移动的。

可组合的规则。 作用于相同内核的两个策略可以组合:一个系统标记"过期"的生物,另一个标记"被捕食"的生物。两者都推送到 to_removecleanup 应用两个批次,而不知道任何一个为何被设置。

可测试的规则。 一个测试夹具直接设置 to_removeto_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 节)的缓冲模式(本身就是一种机制与策略的分离)设计成可分离的。机制 是"在边界应用更改";策略是"应用哪些更改"。

因此,机制与策略不是一个单独的规范。它是前面所有章节都隐式遵守的规则。命名它使其可见。

练习

  1. 找到机制。 对于你模拟器中的每个系统(motion, food_spawn, next_event, apply_eat, apply_reproduce, apply_starve, cleanup, inspect),分类:这是机制 (提交别处请求的内容),策略(决定要请求什么),还是两者兼有?注意每个角色所在的位置。
  2. 替换一个策略。apply_starve 的规则从 energy <= 0 改为 (energy < -10) & (age > 100)。确认:只有 apply_starve 更改;cleanup 保持不变。
  3. 在同一机制上添加一个新策略。 编写一个新的系统 apply_predation,它将"被捕食"的生物的 ID(根据某个其他规则)推送到 to_remove。两个策略的输出都流向 cleanupcleanup 不加区分地应用它们。
  4. 发现反模式。 在你的模拟器中找到任何直接写入"活跃"表而不是写入 to_insertto_remove 的系统。重构。
  5. 审计你的装饰器。 在你的代码中搜索带有副作用 setter 的 @property、有状态函数上的 @cached 装饰器或 __getattr__/__setattr__ 覆盖。每个都是策略埋藏在机制陷阱的候选。对于每个,问:能否将策略提取为一个写集合为缓冲区的系统?
  6. (挑战) 第二个机制。 假设你想要一个"软删除"------生物被移到一个 dead 表而不是被移除。实现一个新的机制(cleanup_with_archive),而不触及现有的策略。相同的 to_remove ID;不同的机制被应用。通过在 DAG 中交换系统来在它们之间切换,而不是通过编辑生产数据的系统。

接下来是什么

[第 41 节------面向压缩的编程](#第 41 节——面向压缩的编程) 是首先编写内核和策略的规范:在提取任何抽象之前,先编写三个具体的案例。

相关推荐
曾阿伦2 小时前
Python 搭建简易HTTP服务
开发语言·python·http
YG亲测源码屋2 小时前
java配置环境变量、jdk环境变量配置、java环境变量设置方法
java·开发语言
MIUMIUKK2 小时前
从语法层面,看懂 Python 的特殊处
java·开发语言·python
FlyWIHTSKY2 小时前
TS、TSX、JS、JSX 文件扩展名详解
开发语言·javascript·ecmascript
着迷不白2 小时前
第一部分:认识python
开发语言·python
hujinyuan201602 小时前
2026年3月 中国电子学会青少年软件编程(Python)三级考试试卷 真题及答案
java·python·算法
开开心心就好2 小时前
支持多显示器的Windows高效分屏工具
运维·python·科技·游戏·计算机外设·ocr·powerpoint
basketball6162 小时前
C++ 高级编程:2. 基本线程池实现
java·开发语言·c++