Skip to content

动态链接特性

在前面的章节中,我们系统地讲过定位与寻址的用法与特性,通常来说这已经够用了。但是,在实际开发中,先前提供的功能并不完全灵活:如果我们需要修改工作流的一部分,在不 "Hack" 的前提下,标准写法要求我们走完一整条链路——dump 解释器状态 → 修改源码组合(ADT)→ 创建新的解释器实例 → 变基解释器。这很麻烦,也很重量级

参考现实计算机操作系统的设计:从外部动态加载某个库并不需要重新编译整个程序,甚至可以运行时热重载。受此启发,AmritaSense 在 v0.7.0 引入了一个全新特性——动态链接

不过在介绍用法与原理之前,需要先说明一点:动态链接会带来编译产物的动态结构改变,而先前章节并没有以一种直观的方式讲清楚寻址空间的本质。因此,本章先探讨编排产物的另一种模型,再进入正题。

编排是正交的拓扑结构

你可能会觉得这与之前的说法矛盾——先前我们提到 AmritaSense 的编排是一个指令序列而不是拓扑图,那么这里为什么又引入拓扑结构?

因为要理解动态链接,我们需要一个空间结构。AmritaSense 的编排不是一段"字节码"——它就是普通的 Python 对象:NodeCompose 里既可以直接放可执行节点,也可以再放入另一个 NodeCompose,后者会在编译时递归展开,占据一格空间槽(Space Slot)。因此编译产物天然是嵌套的树状结构,而不是一维的字节流。例如:

python
# 括号里的 (b >> c) 占据一格空间槽(Space Slot),嵌在线性链的中段
workflow = a >> (b >> c) >> d

示意图

空间槽的位置有讲究:把它放在级联开头时(如 (b >> c) >> a),它会被就地展开、退化成可执行节点,嵌套结构随之消失;要形成嵌套,须让空间槽出现在链的中段或尾部——上面的示例就是中段。

按地址展开,顶层是三个槽位:[0]a[1] 是空间槽(Space Slot),[2]d;空间槽内部再展开为 [1,0]b)与 [1,1]c)。

指针会按**深度优先(DFS)**依次访问四片叶子:[0] -> [1,0] -> [1,1] -> [2](即 a -> b -> c -> d)。推进规则是:碰到 Compose 就下钻到它的 0 号子节点,同层执行完再向右移动,到底后沿原路回退寻找下一个兄弟——这正是 AddressCalculator.advance() 的推进规则。

打个形象的比方:如果把整个 Compose 看作一个"迷宫",那么指针就像贴着一面墙走——它总能走完迷宫,但走过的路径(推导的顺序与模式)由墙的走向(图的嵌套结构)决定。这面"墙"在编译后是固定的,因此指针的游走是确定性的、可预测的。

注入 = 劫持一个槽位

由此可以得到 DLL 注入的可行依据:

劫持某个空间槽节点,就能控制住该槽位下方的整个地址空间。

举例来说:沿用上面的 a >> (b >> c) >> d——若把 [1] 处的空间槽(Space Slot)整体换成 DLL 占位容器,则所有以 [1] 为前缀的地址([1][1, 0][1, 1, ...] 等)都落在 DLL 的寻址范围内,其内容可以在运行期被整体变基;图的其他槽位([0]a[2]d)完全不受影响。

加载与动态编译

说了这么多,让我们实际编写一个示例。

python
import asyncio

from amrita_sense import Node, WorkflowInterpreter
from amrita_sense.instructions import NOP
from amrita_sense.node import DLLCompose


@Node()
def a():
    print("A node")


@Node()
def b():
    print("B node")


dll = DLLCompose(b.as_compose())
comp = a >> dll >> NOP
r_comp = comp.render()

if __name__ == "__main__":
    asyncio.run(WorkflowInterpreter(r_comp).run())

控制台会输出 A nodeB node。注意这里必须持有 dll 的引用——否则无法修改它的内容,动态链接也就失去了意义。

本小节中的 DLL 写法等效于:

python
a >> b.as_compose() >> NOP

Hot-Patch:dll.apply()

槽图

现在我们要动态修改它。使用 dll.apply() 进行热补丁:

python
# 之前:DLLCompose(b.as_compose())
dll.apply(b >> a)

变基之后,r_comp 的内容等效于(注意 b >> a 作为一个整体占据原空间槽,需保留括号):

python
a >> (b >> a) >> NOP

apply() 的机制称为地址变基(rebase):渲染图的结构不变,只有 DLL 槽位之下的内容被替换,且新内容里的符号按原槽位前缀重新注册——就像把共享库重链接回原来的加载地址。

注意:动态修改并不保证线程安全。请谨慎选择修改时机,例如在解释器处于寻址前的挂起点时执行 apply()。两类误用会抛出不同的异常:如果 DLL 从未被构建就调用 apply()(没有可重链接的槽位路径),会抛出 GraphBuildError;而在运行期寻找不存在的地址(包括已被变基清理掉的旧符号),才会引发 NPENullPointerException)。

内部发生了什么

DLLCompose 是一个源容器(source composition):它持有一个待链接的 NodeCompose,并通过 get_builder() 告知渲染器——请为我构建一个占位容器(proxy)。于是渲染器不会把 DLL 的内容静态展开进图里,而是在它的槽位上放置占位容器,再由该容器在构建时把源 Compose 编译为普通的渲染图。

关于源容器 / 渲染图契约的关系,请回顾上一章 Compose 契约。DLL 并不属于契约本身——它只是给“源组合”套了一层可变基的容器:同一实例可反复 apply(),每次都在原槽位上重编译、符号按原前缀重定位。

apply() 的完整流程:

  1. 从顶层 alias2vector_map 中移除上一次由该 DLL 注册的符号(不是清空整张表)。
  2. 将传入的新组合编译进同一槽位;编译期间,新内容中的别名会以 DLL 槽位为前缀重新注册进顶层的 alias2vector_map
  3. 记录本次新增的符号,作为下一次 apply() 的清理清单。
  4. 重新执行构建期间收集到的 _post_compile 钩子。

变基前(首次渲染后),假设 DLL 占据槽位 [1],其内容里通过 ALIAS 注册了两个符号(外层另有与 DLL 无关的别名)。此时顶层符号表(alias2vector_map)为:

python
# 变基前
alias2vector_map = {
    "a": [0],          # 外层符号(位于 DLL 之前),与 DLL 无关
    "x": [1, 0],       # 前缀 [1] 表示"在 DLL 内"
    "y": [1, 1],
}

执行 dll.apply(ALIAS(y, "y") >> ALIAS(x, "x")) 变基后,旧符号被移除,新内容中的别名以相同的槽位前缀重新注册:

python
# 变基后
alias2vector_map = {
    "a": [0],          # 外层符号不受影响
    "y": [1, 0],       # 同前缀重新注册——绝对地址 [1, 0] 现在指向 y
    "x": [1, 1],
}

这里就有一个坑:DLL 内部的绝对地址是不可靠的——变基后同一绝对地址可能指向完全不同的节点,因此不要在运行时硬编码绝对地址,否则可能产生未定义后果。

反之,以下引用方式是安全的:

  • alias / tag 定位:别名随前缀机制自动重定位,变基后依然成立。
  • 同一空间槽内的相对定位(jump_near 等):它们描述的是"同一空间槽内的相对位置"而非绝对前缀,不随槽位前缀变化而失效。

使用限制

理解占位容器机制后,几条边界就顺理成章了:

  • 必须嵌入宿主槽位:占位容器需要宿主渲染图提供地址路径(current_path)与顶层作用域(top)才能完成构建。因此 DLLCompose 不能脱离宿主单独 render()——那会抛出 GraphBuildError。
  • 一次绑定:渲染器首次遇到该 DLLCompose 时会为其创建并绑定占位容器;同一个 DLLCompose 实例无法被第二次渲染(例如拼进两棵树、或在同一棵树中出现两次),此时会抛出 RuntimeError。热更新的正确姿势是:持有该实例、只 apply() 变基,而不是重新渲染它。
  • 构造参数DLLCompose(...) 只能接收 NodeCompose(即 as_compose() 的结果),不接受裸节点;apply() 变基时传入的新组合同样必须是 NodeCompose

总结

本章介绍了编排空间的拓扑表示(指针 = 贴着墙走的 DFS 游标)、DLL 注入的原理(劫持槽位即劫持其下整个地址空间),以及如何使用 dll.apply() 进行热补丁。

需要牢记:

  • 线程安全apply() 与运行中的解释器并发是不安全的,请在挂起点修改。
  • 空引用与无效地址:DLL 未构建即 apply() 会抛 GraphBuildError(此时没有已绑定的占位容器与槽位路径可重链接);而运行期寻找不存在的地址或已被变基清理的旧符号,才会导致 NPE
  • 单次绑定:一个 DLLCompose 实例只能被渲染绑定一次,热更新请对同一实例反复 apply(),而不是重新渲染它。
  • 编译时机:DLL 内的源 Compose 是在占位容器构建(render / apply)时才被编译,因此 _post_compile 钩子会在每次变基后重新执行,不要假设它只运行一次。
  • 地址稳定性:硬编码的绝对地址不推荐、不稳定;请使用 alias / 相对定位。

Apache 2.0 许可证约束