DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程35-36
2026/6/4 4:56:55 网站建设 项目流程

35 — 边界即是队列

模拟器是一个纯函数。给定滴答开始时的世界(world_t)和在滴答期间到达的输入(inputs_t),它产生滴答结束时的世界(world_{t+1})和应该离开的输出(outputs_t)。在这些端点之间,没有系统接触外部世界。没有系统读取time.perf_counter()、发送数据包、写入磁盘或打印到标准输出。在内部,模拟器是一个转换。在外部,它是一个队列。

┌─────────────────────────────┐ │ Simulator (pure) │ │ ┌──────────────────────┐ │ │ │ systems run │ │ │ │ on world_t state │ │ │ └──────────────────────┘ │ │ ↑ ↓ │ │ inputs_t outputs_t│ └─────↑──────────────────↓────┘ │ │ ┌─────────┐ ┌─────────┐ │ in queue│ │out queue│ └─────────┘ └─────────┘ ↑ ↓ environment environment

输入到达输入队列:带有时间戳的事件、来自策略的生成食物请求、多人模拟器中的网络数据包、用户输入事件。它们留在队列中,直到下一个滴答消费它们。

输出离开输出队列:日志的状态变化事件(eatenborndead)、用于可视化器的渲染数据、用于对等方的数据包、用于分布式节点的复制更新。它们在滴答产生后留在队列中,直到存储系统或传输层将它们发送出去。

边界内部发生的情况:纯转换。系统从inputs_t读取(在系统启动时,这已经是另一个表了),更新世界的表,将对to_remove/to_insert的修改排队,并写入outputs_t(也是一个表)。内部通过构造是可重现的;外部是不可预测的,而队列就是连接处。

为什么这很重要

确定性。第 16 节的规则(相同的输入 + 相同的顺序 = 相同的输出)仅在“输入”是滴答环境的完整描述时才成立。队列就是那个完整描述。任何从队列外部读取的系统都是队列无法捕获的非确定性来源。

回放。记录输入队列。从world_t开始,使用记录的队列回放滴答。得到位相同的world_{t+1}。队列就是使回放成为可能的东西。

可测试性。一个测试用合成输入填充输入队列,运行一次滴答,并对输出队列进行断言。测试不需要模拟open()socket或系统时钟;队列接口是模拟器唯一看到的东西。

分布。一个具有多个节点的分布式模拟器通过队列进行通信——每个节点的输出队列馈送到另一个节点的输入队列。队列接口在单台机器上和跨网络上是相同的。

可审计性。曾经到达模拟器的每个输入都在输入队列的历史中。每个输出都在输出队列的历史中。模拟器的完整外部接口是仅追加的两个日志。

边界禁止的 Python 反形态

Python 的标准库使得 I/O容易泄漏。边界规则禁止在模拟器滴答内部出现的五种具体泄漏:

# 反模式:错误的!print(f"creature{i}ate")# 1. 系统内部的 stdoutlogger.info("starvation event")# 2. 日志包,同样的问题now=time.perf_counter()# 3. 系统内部的挂钟读取response=requests.get(URL)# 4. 处理程序中的 HTTPthreshold=float(os.environ["BURN"])# 5. 系统内部的配置读取

每一个单独看起来都是无害的。每一次都会破坏确定性,因为同一个模拟器的两次运行对于“相同”的输入会产生不同的输出——因为输入实际上并不相同;一次运行看到了不同的时钟、不同的BURN、不同的网络响应。该错误是静默且间歇性的。

规范化的 Python 形式:每个外部读取都通过输入队列;每个外部写入都通过输出队列。日志记录成为一个将行追加到log_events列的系统(第 37 节)。时间成为一个参数,由滴答驱动程序读取一次并向下传递(第 16 节)。配置成为在其发生变化时的滴答中的inputs_t的一部分;模拟器从不直接读取它。

在 Python 中,队列实际是什么

队列本身有三种合理的形态。选择与数据匹配的一种。

对于高通量、固定模式的事件,使用 Numpy 并行列。一个eaten事件是(tick: u32, eater_id: u32, food_id: u32, energy_delta: f32)——四列,步调一致地追加。这是 simlog 的形态(第 30 节的参考实现),并且当模拟器每滴答生成许多事件时,是正确的选择。消费时进行批量 numpy 读取;生产时进行批量 numpy 写入。

对于从外部到达的低容量、混合模式事件(用户输入、稀疏网络消息),使用小型字典或命名元组的列表。容量足够小,以至于来自第 6 节的每行构造成本不会成为约束。如果模式固定,使用命名元组;如果模式变化,使用列字典方法。

当队列本身必须跨运行持久化时(审计日志、持久化请求),使用 sqlite 表。第 29/38 节的 sqlite 数字表明它在磁盘上维持约 83万-90 万次查找/秒;这对于任何每滴答队列活动来说都有足够的余量。

一个不是正确答案的 Python 选项:multiprocessing.Queue。尽管名字如此,但它是来自第 32 节的进程间协调机制,而不是模拟器的外部边界。它的输入队列是用于“主进程 → 工作进程”的任务分派,而不是用于“外部世界 → 模拟器”。将两者混为一谈意味着每个外部输入都要支付内核调用成本;更糟糕的是,队列的顺序依赖于进程调度器,并且在多次运行中不是确定性的。为模拟器的外部队列使用普通的 numpy 列或列表;仅在主进程和工作进程之间使用multiprocessing.Queue

与清理的组合

来自第 22 节的清理模式是滴答范围的边界(变更缓冲区,在滴答边界处应用)。此范围上的队列模式是运行范围的相同思想(I/O 缓冲区,在连接处应用)。两者组合:清理使滴答成为原子操作;队列使运行成为可重现的。

一个有用的测试:你能从同一个输入队列并排运行两个模拟器并获得相同的输出队列吗?如果可以,则边界成立。如果不能,则某个地方有一个系统直接读取环境。

练习

  1. 构建队列。in_events: dict[str, np.ndarray]out_events: dict[str, np.ndarray]添加到你的模拟器世界中(每个事件字段一列,每个队列一个n_active计数器)。两者都在滴答边界填满;两者都在下一个滴答开始、消费者读取它们之后重置。
  2. 重构一个读取时间的系统。找到任何直接调用time.perf_counter()的系统。重构:接受current_time: float作为参数。滴答驱动程序读取一次time.perf_counter()并将其向下传递。该系统现在是确定性的。
  3. 重构一个打印输出的系统。找到任何调用print(...)logger.info(...)的系统。重构:将消息追加到out_events["log"]。滴答驱动程序在滴答后读取队列并写入任何存在的内容。日志记录现在是确定性的;测试可以在队列上断言。
  4. 回放测试。在一次 100 次滴答的运行中保存输入队列(np.savez("in_queue.npz", **in_events))。从初始世界状态开始,使用保存的队列第二次运行模拟器。对两个世界进行哈希。它们必须匹配。
  5. 一个队列运行两个模拟器。并行(或顺序)运行两个模拟器,两者都从同一个输入队列馈送。在 100 次滴答后,对两个世界进行哈希。它们必须匹配。如果不匹配,则某个地方有一个系统从队列外部读取。
  6. 找到每个泄漏。搜索你模拟器的源代码:grep -r "time\.\|print\|logger\|requests\|os.environ\|input(" code/sim/。每个匹配都是一个候选泄漏;每个都是确定性可能失败的地方。将任何系统内部的泄漏重构为通过队列。
  7. (挑战)审计一个开源模拟器。打开任何 Python 模拟器的滴答函数(mesa、agentpy、mesa-geo)。找到它从环境(时钟、文件、网络、环境变量)读取的每个地方。每个都是确定性泄漏的地方;每个都可以被队列化。

接下来是什么

第 36 节——持久性是表序列化 采取下一步:当模拟器暂停和恢复时,持久性就是写入列并读回它们。没有转换,没有阻抗不匹配。

36 — 持久性是表序列化

模拟器暂停。世界在内存中:creatures的八列(pos_xpos_yvel_xvel_yenergybirth_tidgen)、一个food表、存在性表(hungrydead等)、索引映射(id_to_slot)和清理缓冲区。要持久地暂停,所有这些都必须写入磁盘;要恢复,所有这些都必须读回。

大多数 Python 程序员的第一反应是设计一个“持久性格式”,包括模式、编组逻辑、版本处理以及内存中对象和磁盘上记录之间的转换层。有时通过pydantic,有时通过dataclasses.asdict加上json.dumps,有时通过 SQLAlchemy ORM。这在数据导向的方面是错误的。没有转换。只有转置

一个快照就是这些列,顺序写入。恢复就是这些列,顺序读取。磁盘上的格式与内存中的形态相同。

importnumpyasnpdefsnapshot(world,path:str)->None:np.savez(path,tick=np.int64(world.tick),**world.columns)defload(path:str)->"World":withnp.load(path)asdata:tick=int(data["tick"])columns={k:data[k]forkindata.filesifk!="tick"}returnWorld(tick=tick,columns=columns)

这就是快照。恢复是逆过程。没有类型转换,没有字段映射,没有在行级别的模式区分。文件正是内存曾经的样子;内存正是文件的样子。

四种方式的成本

根据code/measurement/persistence_shapes.py,在这台机器上,1,000,000 个生物跨 8 列(内存中 34 MB),四种持久化方式:

布局文件 (MB)写入 (毫秒)读取 (毫秒)
pickle oflist[Creature](AoS)85.722,105.3938.5
pickle of dict-of-numpy-columns34.332.713.9
np.savez34.3318.862.9
np.savez_compressed25.521,004.798.5

加上一张未付的账单:为 AoS 变体构建list[Creature]在 pickle 甚至开始之前就花费了 1,314 毫秒——来自第 6 节的构造税。如果你的内存中表示已经是 AoS,那么你在每个快照上都承担着这个成本。

三种解读。

AoS 形式是灾难性的。34 MB 的数据在磁盘上占用 86 MB——pickle 增加了约 2.5 倍的每行元数据、类型标签和引用计数开销。写入 2.1 秒,读取 0.9 秒。对于相同的逻辑内容,写入速度比列 pickle 慢 778 倍。这是大多数 Python 教程演示的pickle.dump(creatures, ...)形式。这是该语言提供的持久化百万行世界最昂贵的方式。

列 pickle 真的很快。Numpy 的__reduce__协议意味着 pickle 直接写入数组字节,只在它们周围加上薄薄的包装——没有每行的工作。对于 34 MB 的数据,2.7 毫秒写入,13.9 毫秒读取是带宽受限的。在这种测量中,该格式比np.savez更小、更快

np.savez为可移植性付出了代价。它的写入速度比列 pickle 慢 7 倍(18.8 毫秒 vs 2.7 毫秒),因为它构建了一个 zip 归档文件,每个数组作为一个.npy成员。这个成本买到了 pickle 无法提供的两样东西:

  • 稳定性。.npy格式自 2007 年以来就有文档记录、有版本控制,并且以非破坏性方式未变。Pickle 协议会改变;来自一个 CPython 版本的 pickle 数据可能无法在另一个版本中加载,尤其是在跨越主要版本时。
  • 跨语言。.npy文件可以从 Rust(ndarray-npy)、Julia(NPZ.jl)和 C(六种库中的任何一种)加载。Pickle 则不行。

压缩以约 50 倍的写入时间换取了约 25% 的磁盘空间节省。np.savez_compressed是当文件通过网络传输或存储在按字节计费的存储上时的正确选择。当快照保留在同一台机器上并经常重写时,它是错误的选择。

诚实的建议:

  • 对于模拟器的每滴答快照(频繁、本地、内部):列 pickle 是最快的。当快照的唯一读取者是同一个 Python 进程或其派生进程时,可移植性问题不适用。
  • 对于跨运行、机器或语言边界的检查点/恢复:使用np.savez。7 倍的写入成本会在未来你不需要逆向工程来自不同 CPython 版本的 pickle 格式中得到摊销。
  • 对于长期归档或分布式传输:使用np.savez_compressed。50 倍的写入成本只支付一次;磁盘节省则永远持续。
  • 对于数据类列表的 AoS pickle:永远不要使用。本章的第一行就是为了劝阻它。

通过不翻译你节省了什么

无需模式设计。模式就是任何列的样子。模式文档就是列声明。

无需对象编组。没有__getstate__、没有__setstate__、没有pydantic.BaseModel、没有Marshmallow模式。numpy 数组以字节形式写入;字节以 numpy 数组形式读取。

没有翻译错误。ORM、带强制转换的 JSON 以及 pickled 类层次结构是细微正确性问题的著名来源——字段重命名、类型强制转换、边缘情况处理不当。在这里,内存中和磁盘上的形式是位相同的;加载就是np.load(path),仅此而已。

确定性恢复。在确定性模拟器中拍摄的快照可以完美往返。snapshot → load之后哈希的世界与之前哈希的世界相同。结合第 16 节的规则和第 35 节的队列,回放是结构性的。

不能让你免于什么

模式版本控制。在快照之间添加的新列会破坏加载。有三件事可能会破坏跨环境的快照:模式改变了(你添加了一列或重命名了一个类型)、字节序不同(你在一台小端机器上保存,然后在一台大端机器上加载——在 Linux/Mac/Windows 上很少见,但在某些 ARM 配置上可能发生)或者Python 版本不同(对于.npy很少见,对于 pickle 很常见)。所有三者都有相同的修复方法:为每个快照编写一个小头部——一个包含schema_version: int的列有一个元素——并且在加载时,如果该字段与当前代码不一致,则运行匹配的迁移。大多数模拟器针对单一架构,并在需要之前跳过迁移;该机制从第一天起就以一个整数的成本存在。

pickle 版本陷阱。每个添加了新 pickle 协议的 CPython 版本都有使来自旧版本的 pickle 数据失效的风险。protocol=pickle.HIGHEST_PROTOCOL让你保持在最新版本,这对速度很好,但对归档很危险。如果你为了快照速度而选择列 pickle 而不是np.savez,请将protocol设置为一个稳定的旧版本(例如,自 CPython 3.4 以来支持的protocol=4),这样新的 Python 版本就不会让你的归档文件无法使用。

该模式在任何需要考虑此规模的地方都会出现。数据库中的预写日志、游戏中的存档文件、高性能计算中的检查点文件、视频编辑中的帧快照。它们都通过直接写入列来避开 ORM 陷阱。

模拟器的快照每个方向大约五行 Python 代码(顶部的代码块)。OOP 等价物——定义一个CreatureRecordpydantic模型,一次遍历世界序列化一个生物——代码量是其十倍,运行速度慢两到三个数量级,并且容易产生列直接版本无法拥有的翻译错误。

练习

  1. 对世界进行快照。使用np.savez为你的模拟器实现snapshot(world, path)load(path)。保存到snapshot.npz。注意文件大小;对于热表,它应该匹配字节每列 × N,加上每列的一点点 zip 开销。
  2. 往返测试。保存世界;从磁盘重新加载到一个新的World;从加载的状态运行模拟器,并在同一滴答处将哈希值与原始值进行比较。它们必须匹配。
  3. 运行持久性示例。uv run code/measurement/persistence_shapes.py。注意灾难性的 AoS-pickle 行。注意np.savez不是最快的,但它是最可移植的。为你的用例决定复制哪一行。
  4. 亲身体验 OOP 比较。使用pydantic.BaseModeldataclasses.asdict加上json.dumps实现一个每行序列化器。在 1M 生物时计时。每行版本通常比np.savez慢两个数量级,并且产生的文件大几倍。
  5. 模式版本控制。向模拟器添加一个新列(hunger_buildup: float32)。使用新列保存;修改加载器以处理旧快照(在加载的.npz中没有hunger_buildup键)和新快照(键存在)。旧快照在加载时获得零填充的新列。验证两者都能干净地往返。
  6. Pickle 版本稳定性。使用pickle.dump(world.columns, f, protocol=4)保存一个快照。使用protocol=pickle.HIGHEST_PROTOCOL保存另一个快照。注意文件大小(差异很小)。现在考虑一下:哪个文件在 CPython 3.20 中仍然可以加载?protocol=4自 3.4 起支持;HIGHEST_PROTOCOL会不断变化。
  7. (挑战)内存映射快照。使用np.load(path, mmap_mode='r')直接映射快照文件。数组的字节就是文件的字节;加载是零拷贝的,直到第一次读取每列。比较 100 MB 快照的加载时间。内存映射形式在第一次读取时可能不会更快(操作系统仍然需要将页面调入),但当模拟器只需要其中一列时,快得多

接下来是什么

第 37 节——日志就是世界 使结构性的论证变得明确:事件日志和世界的表共享一种形态;一个是另一个的投影。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询