来源:https://root-11.codeberg.page/intro-book-python/
12 --- 事件时间与滴答时间是分离的
大多数初学者认为循环的频率设定了模型的时间分辨率。如果循环以 30 Hz 运行,那么模型当然只能以 1/30 秒 = 33 毫秒的分辨率处理事件?这是错误的,这种混淆使许多模拟失去了精度。
滴答率是循环运行的频率。它并没有说明循环在一个滴答内做了什么。在一个滴答内,循环可以处理任意时间戳的事件------微秒、皮秒,无论数据携带什么。时钟存在于事件上,而不是循环上。
具体来说:一个 30 Hz 的循环,每个滴答接收 1000 个事件,每个事件都带有微秒精度的时间戳,它们会按时间戳顺序处理------以时间戳暗示的精度应用每个事件的效果。对外部世界(渲染、日志记录、网络)的输出以 30 Hz 发生,但内部物理 以微秒分辨率运行。滴答是一个采样率;事件才是实际现象。
这是以下项目使用的模型:
- 离散事件模拟器(排队网络、交通、供应链):事件在精确时间触发。
- 游戏回放系统(回滚网络代码、多人游戏):事件延迟到达,但带有原始时间戳。
- 交易执行引擎:订单带有纳秒时间戳;循环按顺序处理它们。
- 芯片设计中的逻辑模拟器:门级转换具有皮秒分辨率;模拟器一次推进一个转换。
在每种情况下,主机循环的滴答率与模拟的分辨率无关。数据携带时间。
时间希望如何被存储
当一章提到"时间戳"时,Python 的本能反应是使用 datetime。这是显而易见的选择------标准库提供了它,每个教程都使用它,比较操作可以使用 < 和 >,减法返回一个可读的 timedelta。它也是大规模存储时间最昂贵的方式之一。
根据 code/measurement/event_time_storage.py,在这台机器上,一百万条覆盖一小时、微秒分辨率的事件:
| 布局 | 数据大小 | 构建 | 排序 | 计数 < T |
|---|---|---|---|---|
list[datetime] |
53.6 MB | 406 毫秒 | 8.5 毫秒 | 22.1 毫秒 |
np.array(dtype="datetime64[us]") |
7.6 MB | 209 毫秒 | 6.1 毫秒 | 1.3 毫秒 |
np.array(dtype=np.float64) (秒) |
7.6 MB | 86 毫秒 | 36.7 毫秒 | 1.3 毫秒 |
两种方式的主要数字:
- 从
datetime列表迁移到任一类型化的 numpy 列,体积缩小 7 倍 。每个datetime实例约 56 字节(头部、引用计数、八个整数字段、指针);每个 numpy 元素是 8 字节(在datetime64[us]下是自纪元起的微秒int64,或者对于f8表示是自基准秒起的float64)。 - 计算"在时间 T 之前发生了多少事件?"的速度快 17 倍 ------这是决定本滴答处理哪些内容的每滴答查询。numpy 版本将比较作为一个带宽受限的批量操作进行评估;datetime 版本为每个元素支付解释器分派和
<方法调用的成本。 - 排序时间是混合的,并且依赖于 dtype------测量你的具体情况。在此次运行中,numpy 的 float64 排序比其 datetime64 排序慢,后者比 Python 的 Timsort 在已经排序的 datetime 列表上稍快。排序成本对摄取很重要;计数成本对每个滴答很重要。滴答是约束预算。
simlog 参考实现(vendored 于 .archive/simlog/logger.py)将时间存储为 f8------float64 秒。对于事件日志来说,这是一个规范化的选择:体积小、可排序、适合批量 numpy 操作,并且与列存储中的其他所有内容宽度相同。当你需要将时间戳作为挂钟日期读取而无需转换时,datetime64[us] 是一个合理的替代方案。仅在边界处使用 datetime 对象------为日志行格式化字符串,与来自请求的用户提供的时间戳进行比较------绝不要在模拟规模下作为你的内存存储。
代码中的解耦
陷阱在于将滴答间隔硬编码为模拟的时钟粒度。如下所示的代码:
python
# 反模式:错误的!
creature.energy -= 1.0 / 30.0 # "一个滴答的燃料量"
混淆了两个时钟。正确的形态是:
python
energy[mask] -= elapsed_event_seconds * burn_rate[mask]
使用实际经过的事件时间,而不是滴答间隔。numpy 形式也是面向列的------mask 是一个布尔过滤器,选择受影响的生物,burn_rate 是每个生物的。相同的计算适用于影响一个生物的一个事件和影响一千个生物的一千个事件,因为事件时间和滴答时间是解耦的。相同的模型可以以应用程序需要的任何滴答率进行采样------30 Hz 的可视化、60 Hz 的记录、1 kHz 的快进回放------而无需改变模型的意义。
这种分离使得模拟器的 pending_event 表成为可能。每个滴答,循环构建一个应触发的事件列表------碰撞、进食、繁殖------每个事件都带有其预测的时间戳(作为 f8)。事件按其时间戳顺序触发,无论它们是在哪个滴答中被预测的。一个"本应在滴答开始后 2 微秒进食"的生物,其进食事件会在那个精确时刻被应用,而不是在滴答开始或结束时。
练习
这些练习扩展了第 11 节练习 7 中的离散事件循环。
- 一个微小的事件队列。 使用
numpy数组:时间戳的times = np.array([...], dtype=np.float64)和字符串消息的messages = np.array([...], dtype=object)。推送 10 个时间戳在[0, 10]秒内的随机事件。使用order = np.argsort(times)按时间顺序弹出它们。打印每个事件,格式为[t=<sec>] <message>。验证输出是按时间戳排序的。 - 错误的方式:滴答率时钟。 运行一个 30 Hz 的循环。在每个滴答中,将计数器增加
1.0 / 30.0。使用此计数器作为你的"模拟时间"。尝试在t = 0.005 秒(5 毫秒)触发一个事件。会发生什么?事件何时触发?(提示:5 毫秒 < 33 毫秒;事件等待下一个滴答边界,损失了 28 毫秒的分辨率。) - 正确的方式:事件上的时间戳。 运行相同的 30 Hz 循环,但每个滴答弹出所有 时间戳 ≤ 当前实时时间的事件,并按时间戳顺序应用。在
t = 0.005 秒触发一个事件。证明事件恰好在该时间应用,而不是在下一个滴答边界。 - 以不同速率采样。 在 30 Hz 循环、60 Hz 循环和 1 Hz 循环下运行相同的模型。在所有三次运行中,事件应该在相同的模拟时间触发(达到循环允许的精度)。
- 浮点数与时间。 对于
t ≈ 1 小时的事件,np.float32能表示的最小时间步长是多少?对于t ≈ 1 天?对于t ≈ 1 年?你什么时候需要np.float64?(参见[第 2 节](#第 2 节)。提示:np.spacing(np.float32(3600))是找到一小时答案的快速方法。) - 运行存储示例。
uv run code/measurement/event_time_storage.py。注意 count-time 行------这是三种布局中每滴答查询的成本。注意datetime列表所在的位置和 numpy 列所在的位置。 - (挑战) 一个预算感知循环。 修改你的 30 Hz 循环:在每个滴答开始时,弹出事件,直到 (a) 队列为空,或 (b) 你已使用 33 毫秒预算中的 25 毫秒。将剩余事件推迟到下一个滴答。这是交互式模拟器中使用的软实时模式。
接下来是什么
[第 13 节------系统是表上的函数](#第 13 节——系统是表上的函数) 介绍了每个滴答的构建块:系统。读入集合,写出集合,没有隐藏状态,没有意外。
13 --- 系统是表上的函数
一个系统 是一个从一个或多个表读取并向一个或多个表写入的函数。它声明其输入(读集合 )和输出(写集合)。它没有隐藏状态,没有全局副作用,在一个滴答内不与外部世界交互。签名就是契约。
python
def motion(pos_x: np.ndarray, pos_y: np.ndarray,
vel_x: np.ndarray, vel_y: np.ndarray,
dt: float) -> None:
pos_x += vel_x * dt
pos_y += vel_y * dt
读集合:vel_x、vel_y、dt。写集合:pos_x、pos_y。这就是整个契约。只要这四个列和 dt 可用,并且没有其他东西在写 pos_x 或 pos_y,这个系统就可以在任何时候运行。它每个滴答在整个种群上运行一次------函数体中没有每个生物的循环。for 循环消失在 numpy 中。
三种形态
每个系统都采取三种形态之一。
一个操作 是 1→1:每个输入行恰好产生一个输出行。motion 是一个操作------每个生物的位置更新到它的新位置。大多数更新函数都是操作。
一个过滤器 是 1→{0, 1}:每个输入行产生零个或一个输出行。apply_starve(来自 code/sim/SPEC.md)是一个过滤器------每个 energy ≤ 0 的生物在 to_remove 中产生一个条目;energy > 0 的生物不产生任何东西。numpy 形式是一行:
python
def starving(energy: np.ndarray) -> np.ndarray:
return np.where(energy <= 0)[0] # 返回要移除的索引
一个发射 是 1→N:每个输入行产生零个或多个输出行。apply_reproduce 是一个发射------能量高于阈值的父本产生两个后代(一个 1→2 的发射)。
这三种形态与数据库查询采取的形态相同。SELECT * FROM t WHERE p 是一个过滤器,SELECT a + b FROM t 是一个操作,SELECT explode(arr) FROM t 是一个发射。一个系统是用 Python 针对 numpy 列而不是用 SQL 针对表编写的数据库操作。如果你写过 SQL,你已经知道这些词汇了;工作是用这些术语来认识你的模拟。
返回类型是契约的一半
这三种形态也固定了返回类型。一个操作就地修改其写集合并返回 None------当调用返回时,工作已经完成。一个过滤器返回一个新的索引数组。一个发射返回一个或多个新数组。模式是:修改器返回 None;生产者返回它们生产的东西。
这种不对称的原因是,另一种方式------让修改器返回它自己的写集合------是一个悄悄出现的别名错误。world = step(world) 读起来像是它产生了一个新世界,但如果 step 修改并返回同一个对象,那么两个名字指向同一个状态,调用者无法从调用点判断哪个是真的。Python 的标准库准确地编码了这条规则:list.sort() 返回 None,这样 xs = xs.sort() 会大声失败;sorted() 返回一个新列表。系统的约定是应用于列的相同规则。
有一个命名的例外:一个从无到有 构建世界的函数------从一个种子、一个文件或一个日志------返回新世界。它的签名表明了这一点:它不接受一个现有的 world 来修改。build_world(seed)、load(path)、replay(initial, log) 是构造函数,而不是系统。返回值是新状态唯一可以去的地方。
OOP 方法是反形态
现在是时候指出大多数 Python 教程所教的内容了。对象上的方法形态------class Creature: def tick(self, dt): self.pos += self.vel * dt------是相同的原理,只是通过 self 进行了旋转,而这种旋转让你付出了一切重要的代价。签名 def tick(self, dt) 没有告诉你该方法读取或写入什么。方法体告诉了,但只有在阅读之后。契约不再能在调用点表达;它隐含在方法体中,这意味着你不能在不内联每个方法的情况下推理组合。
它还让你付出了循环的代价。Creature.tick 的自然调用者是 for c in creatures: c.tick(dt)------一个 Python 级别的循环,每个元素一次方法分派,解释器受限于第 1 节中每个元素约 5 ns 的下限,再加上每个属性约 50-100 ns 的 getattr 和方法调用开销。根据 code/measurement/tick_budget.py,对于 1,000,000 个生物的一个运动系统,成本是每滴答 27.9 毫秒 ,而函数加列的形式是 0.6 毫秒。系统的形态不仅更清晰------而且是在规模上唯一能适应 30 Hz 预算的形态。
更广泛的规则是:一个接受 self 的函数没有声明的读集合或写集合。 一个接受列的函数则有。这是"OOP 与数据导向"不是风格选择的少数几个地方之一------它关乎你的系统是否有你可读的契约。
日志记录是一个单独的系统
Python 鼓励的另一个本能是从循环内部写入 stdout。print(f"creature {i} starved")、logger.info(...)、traceback.print_exc()------所有这些副作用 都违反了系统无隐藏输出的契约。解决方法和本书中其他所有内容一样:有一个 log_events 表,一个日志记录系统 写入它,一个单独的刷新系统将表写入磁盘或 stdout。
本书将在[第 37 节------日志就是世界](#第 37 节——日志就是世界)中构建这一规范。目前,规则是:如果一个系统需要与外部通信,它通过其写集合中声明的一列来实现。没有意外的打印。
可观测性和测试也是系统
一个调试检查器是一个系统,其读集合是"所有相关列",写集合是"没有可观测的东西"------它收集数据供检查,对世界不产生副作用。在生产环境中它是不存在的,不受标志门控------程序根本不包含它。
一个测试也是一个系统。assert pos.shape == vel.shape and not np.any(np.isnan(pos)) 是一个系统,其读集合是 pos 和 vel,写集合是空,其效果是如果前一个系统的契约被违反,则大声失败 。测试即系统是[第 43 节](#第 43 节)的主题,但自从第 5 节练习 1 以来你一直在编写它们。
一个系统声明其输入,声明其输出,并且不多做。这就是使本书中所有其他规范都能工作的形态。
需要注意的几个模式
一个在同一个调用中读取一列、写入它并再次读取它的函数不是 一个系统------它在函数体内有隐式排序。要么将其拆分为具有显式排序的两个系统,要么将写入缓冲到函数退出。一个接受一个 world 对象并随意修改它的函数不是一个系统------它没有声明的写集合,你不能从它的签名推理它。
系统没有隐藏状态 的契约是使系统可组合的原因。具有不相交写集合的两个系统可以无需协调并行运行([第 31 节](#第 31 节))。其读集合和写集合形成一个链的两个系统必须按顺序运行([第 14 节](#第 14 节))。契约是所有这一切的基础。
练习
使用第 5 节的牌堆、第 11 节的 tick_lab 或第 0 节的模拟器框架;任何一个都提供足够的表。
- 识别形态。 将每个分类为操作、过滤器或发射:
- 对一个
float32的np.ndarray中的每个条目进行平方。 - 从一个
int32的np.ndarray中过滤出偶数整数。 - 将一个
list[str]中的每个字符串拆分为单词,返回所有单词。 - 计算一个
int32的np.ndarray的总和。
- 对一个
- 将运动编写为一个系统。 使用长度为 100 的 numpy
float32列pos_x, pos_y, vel_x, vel_y,按照正文中的定义编写motion(pos_x, pos_y, vel_x, vel_y, dt)。将其应用于 100 个具有随机初始位置和速度的生物。在 10 个滴答中打印一个生物的位置。函数体有两行。 - 声明契约。 为
motion添加一个文档字符串,明确列出其读集合和写集合。签名加上文档字符串就是系统的契约。 - 编写一个过滤器。 使用
energy: np.ndarray,编写starving(energy),返回一个 numpy 数组,其中包含energy[i] <= 0的索引。这是apply_starve的只读前半部分。 - 编写一个发射。 使用
parent_energy: np.ndarray、阈值threshold: float,编写reproduce(parent_energy, threshold),返回两个并行的数组------parent_indices和offspring_energies------对于每个高于阈值的父本,每个对应两个条目。这是一个 1→2 的发射。(提示:mask = parent_energy > threshold; idx = np.where(mask)[0]; np.repeat(idx, 2)。) - 观察非系统。 在你之前的工作(或任何 Python 教程)中找到一个函数,它接受
self并随意修改,或者写入全局变量,或者从函数体内部调用print。注意是什么使它不是一个系统。尝试仅从签名表达其读集合和写集合------确认你不能。 - 亲身体验 OOP 成本。 运行
uv run code/measurement/tick_budget.py。阅读表格。注意,在 1,000,000 个生物时,你已经看到了当循环在一个方法内部而不是在 numpy 中时会发生什么。对于 Python dataclass 版本,30 Hz 那一行是超支的。系统形态的版本使用了预算的 1.8%。 - (挑战) 作为系统的测试。 编写
def no_creature_moved_too_far(prev_pos_x, prev_pos_y, cur_pos_x, cur_pos_y, max_step),返回任何在两个滴答之间移动超过max_step的生物的索引。"测试"只是一个读取世界的检查系统。提示:dx = cur_pos_x - prev_pos_x; dy = cur_pos_y - prev_pos_y; np.where(dx*dx + dy*dy > max_step*max_step)[0]。
接下来是什么
[第 14 节------系统组合成一个 DAG](#第 14 节——系统组合成一个 DAG) 采取下一步:当许多系统一起运行时,它们如何适应?
14 --- 系统组合成一个 DAG
只有一个系统的程序是无趣的;有许多系统的程序必须说明什么以什么顺序运行 。顺序由数据依赖关系给出:一个读取表的系统必须在同一滴答内写入该表的每个之后 运行。没有凭直觉固定的顺序;一切都由[第 13 节](#第 13 节)刚刚让你声明的读集合和写集合给出。
画出依赖图。每个系统是一个节点。对于每个读取表 T 的系统和每个写入 T 的系统,画一条边 writer → reader。结果是一个有向无环图------DAG。一个拓扑排序给出了一个有效的执行顺序:任何尊重边的排序都是正确的。程序执行其中一个排序。
来自 code/sim/SPEC.md 的模拟器滴答:
#mermaid-svg-HwkBrwQY1d8NrATU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HwkBrwQY1d8NrATU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HwkBrwQY1d8NrATU .error-icon{fill:#552222;}#mermaid-svg-HwkBrwQY1d8NrATU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HwkBrwQY1d8NrATU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HwkBrwQY1d8NrATU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HwkBrwQY1d8NrATU .marker.cross{stroke:#333333;}#mermaid-svg-HwkBrwQY1d8NrATU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HwkBrwQY1d8NrATU p{margin:0;}#mermaid-svg-HwkBrwQY1d8NrATU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HwkBrwQY1d8NrATU .cluster-label text{fill:#333;}#mermaid-svg-HwkBrwQY1d8NrATU .cluster-label span{color:#333;}#mermaid-svg-HwkBrwQY1d8NrATU .cluster-label span p{background-color:transparent;}#mermaid-svg-HwkBrwQY1d8NrATU .label text,#mermaid-svg-HwkBrwQY1d8NrATU span{fill:#333;color:#333;}#mermaid-svg-HwkBrwQY1d8NrATU .node rect,#mermaid-svg-HwkBrwQY1d8NrATU .node circle,#mermaid-svg-HwkBrwQY1d8NrATU .node ellipse,#mermaid-svg-HwkBrwQY1d8NrATU .node polygon,#mermaid-svg-HwkBrwQY1d8NrATU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HwkBrwQY1d8NrATU .rough-node .label text,#mermaid-svg-HwkBrwQY1d8NrATU .node .label text,#mermaid-svg-HwkBrwQY1d8NrATU .image-shape .label,#mermaid-svg-HwkBrwQY1d8NrATU .icon-shape .label{text-anchor:middle;}#mermaid-svg-HwkBrwQY1d8NrATU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HwkBrwQY1d8NrATU .rough-node .label,#mermaid-svg-HwkBrwQY1d8NrATU .node .label,#mermaid-svg-HwkBrwQY1d8NrATU .image-shape .label,#mermaid-svg-HwkBrwQY1d8NrATU .icon-shape .label{text-align:center;}#mermaid-svg-HwkBrwQY1d8NrATU .node.clickable{cursor:pointer;}#mermaid-svg-HwkBrwQY1d8NrATU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HwkBrwQY1d8NrATU .arrowheadPath{fill:#333333;}#mermaid-svg-HwkBrwQY1d8NrATU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HwkBrwQY1d8NrATU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HwkBrwQY1d8NrATU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HwkBrwQY1d8NrATU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HwkBrwQY1d8NrATU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HwkBrwQY1d8NrATU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HwkBrwQY1d8NrATU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HwkBrwQY1d8NrATU .cluster text{fill:#333;}#mermaid-svg-HwkBrwQY1d8NrATU .cluster span{color:#333;}#mermaid-svg-HwkBrwQY1d8NrATU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HwkBrwQY1d8NrATU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HwkBrwQY1d8NrATU rect.text{fill:none;stroke-width:0;}#mermaid-svg-HwkBrwQY1d8NrATU .icon-shape,#mermaid-svg-HwkBrwQY1d8NrATU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HwkBrwQY1d8NrATU .icon-shape p,#mermaid-svg-HwkBrwQY1d8NrATU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HwkBrwQY1d8NrATU .icon-shape .label rect,#mermaid-svg-HwkBrwQY1d8NrATU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HwkBrwQY1d8NrATU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HwkBrwQY1d8NrATU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HwkBrwQY1d8NrATU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} food_spawn
motion
next_event
apply_eat
apply_reproduce
apply_starve
cleanup
inspect
food_spawn 首先运行,因为它的输出是 food,而 motion 和 next_event 读取它。next_event 产生 pending_event,三个应用程序并行消费它(它们的写集合是不相交的)。cleanup 在它们所有之后运行,因为它的读集合包括它们的写入。inspect 最后运行,因为它读取所有内容并且不写入任何内容。
这与数据库中的查询计划形状相同。查询优化器接收一个 SQL 语句,构建一个关系操作图(每个操作都是一个系统!),并将它们拓扑排序成一个执行计划。一个模拟器是每个滴答运行的查询计划。遵循这条线索的学生最终会不知不觉地编写他们自己的最小查询引擎。
不是回调。不是信号。不是发布/订阅。
这就是 Python 式的"松耦合"惯用语出现的地方,正确的答案是拒绝它们。需要命名和排除的三种模式:
观察者 / 事件总线。 一个系统订阅一个事件("一个生物诞生了")并运行某个处理程序。处理程序触发的顺序是谁先订阅,或者框架选择什么,或者------最常见的是------设计上未指定。这与本章所要求的相反。DAG 固定顺序;事件总线故意不固定。
Django/Flask 风格的信号。 框架教 signal.connect(handler),以便任何模块可以将其自身连接到任何生命周期点。结果是一个滴答,其执行顺序取决于哪些模块被导入、以什么顺序导入,以及哪些 connect 调用运行。DAG 依赖于声明的数据依赖关系;信号依赖于导入顺序。
回调。 一个系统在其函数体的某个点"回调"用户代码。现在用户代码是滴答的一部分,但它没有声明的读集合,没有声明的写集合,并且在由调用系统的实现决定的时刻运行。第 13 节的契约消失了。
在所有三种情况下,问题都是相同的:顺序没有被声明;它是由运行时意外事件涌现出来的。 一个在其写入器之前运行的读取器读取陈旧数据------本应已更新的表的昨天快照。一个在其消费者之后运行的读取器读取垃圾------一个更新了一半的表。DAG 是防止两者的契约。上述三种模式中的每一种都用一个希望取代了契约。
一个模拟器的滴答是一个拓扑排序的调用列表:
python
def tick(world: World, dt: float) -> None:
food_spawn(world.food, dt)
motion(world.pos_x, world.pos_y, world.vel_x, world.vel_y, dt)
next_event(world.pending_event, world.pos_x, world.pos_y, world.food, ...)
apply_eat(world.energy, world.food, world.pending_event)
apply_reproduce(world.to_insert, world.energy, world.pending_event)
apply_starve(world.to_remove, world.energy, world.pending_event)
cleanup(world.to_remove, world.to_insert, ...)
inspect(world) # 只读,写集合为空
八个函数调用,按拓扑顺序。添加一个系统意味着添加一行,并根据新系统的读集合和写集合重新推导顺序。没有 register(),没有 subscribe(),没有 signal.connect()。序列就是程序;程序就是序列。
为什么需要无环
一个循环是一个矛盾。假设系统 A 写入表 T,系统 B 读取 T 并写入 U,系统 A 读取 U。现在 A 既产生 T(B 读取它)又消费 U(B 写入它)。A 和 B 不能在同一个滴答内都在彼此之前运行。
系统图中的一个循环是一个设计错误;它必须被打破------通常是通过缓冲一个系统的写入,使其在下一 个滴答而不是本 个滴答被消费。这种缓冲正是[第 15 节------滴答之间的状态变化](#第 15 节——滴答之间的状态变化)命名的。当你编写模拟时,循环不会消失;它们得到一个名称和一个规范。
免费获得并行性
一旦 DAG 是显式的,并行性就变得微不足道。在同一 DAG 级别的任意两个系统------没有一个系统是另一个的传递依赖------可以在不同的进程上运行。在上面的模拟器中,apply_eat、apply_reproduce 和 apply_starve 都消费 pending_event 并产生不相交 的输出表(energy / food、to_insert、to_remove);它们可以无需协调并行运行。调度由图表隐含。[第 31 节](#第 31 节)将在 GIL 下讨论这一点。
观察者模式的替代方案无法提供这一点。没有显式的 DAG,框架无法判断哪些处理程序是独立的,哪些不是------因此它要么串行运行所有内容,要么依赖用户添加手动同步。DAG 优先的设计在读集合和写集合准确的那一刻免费获得并行性;观察者优先的设计必须事后发明它。
练习
-
画出 DAG。 取八个模拟器系统(motion, food_spawn, next_event, apply_eat, apply_reproduce, apply_starve, cleanup, inspect),并根据
code/sim/SPEC.md中每个系统的读集合和写集合自己画出依赖图。与上面的图进行比较。 -
发现循环。 假设
apply_starve写入food(当生物死亡时将燃料返回给世界)。现在apply_starve写入food,而food_spawn读取它。food_spawn写入food,而next_event读取它。next_event写入pending_event,而apply_starve读取它。循环在哪里?你将如何打破它?(提示:第 15 节。) -
手工进行拓扑排序。 给定:
- A 写入 X
- B 读取 X,写入 Y
- C 读取 X,写入 Z
- D 读取 Y 和 Z,写入 W
哪些系统可以并行运行?什么是有效的执行顺序?是否存在多个有效顺序?
-
在 Python 中进行拓扑排序。 实现
def topo_sort(systems: list[tuple[str, set[str], set[str]]]) -> list[str],接受(name, read_set, write_set)三元组,返回一个有效的执行顺序。使用 Kahn 算法。将其应用于你对练习 1 的答案------它应该产生相同的顺序(或其中一个有效的替代顺序)。 -
组合两个系统。 编写
motion(操作,写入pos_x, pos_y)和next_event(操作,写入pending_event)。将它们连接成一个按顺序调用它们的tick(world, dt)函数。在滴答后检查pending_event。 -
添加
cleanup。 添加一个cleanup系统,处理to_remove和to_insert(两者最初都是空数组)。将其连接在next_event之后。确认调用列表从上到下按依赖顺序读取。 -
错误的方式:一个观察者。 使用事件总线模式实现相同的三系统滴答:
bus.subscribe("tick", motion); bus.subscribe("tick", next_event); bus.subscribe("tick", cleanup); bus.fire("tick", world)。运行它。注意现在顺序隐含在注册顺序中,并且在运行时插入的任何新订阅者都可能静默地改变顺序。比较阅读结果代码与阅读函数调用形式。哪一个告诉你什么在何时运行? -
(挑战) 一个查询规划器。 取五个手写的 SQL 查询(每个都是一个系统形态),并为每个画出关系代数计划。与
motion → next_event → apply_*分解模拟器的方式进行比较。形状是相同的。
接下来是什么
[第 15 节------滴答之间的状态变化](#第 15 节——滴答之间的状态变化) 是使 DAG 真正起作用的规则:变更被缓冲;世界原子地转换。