子节点调用
AmritaSense 提供了完整的子程序调用机制。与 JMP 的单向跳转不同,子程序调用会保存当前执行位置,跳转到目标节点序列执行,完毕后自动返回调用点继续推进。这套机制同时服务于两个层面:编排层面的 INVOKE 指令,以及节点内部通过解释器 API 发起的 call_sub。
本章将从解释器底层 API 出发,逐层解析调用栈管理、参数传递机制,以及在节点代码中直接调用子程序的实践。
call_sub:解释器底层的调用原语
call_sub 是 WorkflowInterpreter 提供的底层调用原语。编排层面的 INVOKE 指令、节点内部的子程序调用,最终都通过它完成。它的核心工作流程如下:
调用栈管理
- 保存返回地址:将当前执行指针
_pointer压入返回地址栈_ret_addr_stack - 设置新执行上下文:将执行指针替换为目标子程序的入口地址
- 执行子程序:调用
_call方法,在目标地址开始节点执行循环 - 恢复执行上下文:子程序执行完毕后,
finally块从返回地址栈弹出原指针并恢复,解释器继续从调用点的下一个节点推进
锁与中断模式的区分
call_sub 提供 interrupt 参数,控制是否在执行子程序时获取解释锁:
interrupt=False(默认):不获取锁。适用于节点内部发起的子程序调用——此时调用者节点本身已持有解释锁,重复获取会触发aiologic的死锁检测并直接报错interrupt=True:获取解释锁。适用于外部世界在两次迭代之间发起的“安全外部调用”——此时锁空闲,获取锁后可安全执行注入的节点序列
这种设计让同一套调用原语同时服务于内部复用和外部注入两种场景,而锁的获取策略决定了使用边界。
跳转标记的优先级
子程序执行完毕后,call_sub 会检查 JMP 位。如果子程序内部执行了跳转操作(如 JMP),该标志已被设置为 True。在这种情况下,finally 块不会恢复原来的执行指针——跳转后的新地址被保留,解释器从跳转目标继续执行。这确保了子程序内部的跳转能够正确影响主工作流控制流。
带操作数的子程序传参
call_sub 支持直接传递额外的位置参数和关键字参数。这些参数在子程序入口节点的依赖解析阶段被合并到可用参数池中,供节点的函数签名声明。
在节点代码中使用 call_sub 传参
当开发者在自定义节点中编写子程序调用逻辑时,可以直接传入操作数:
@Node()
async def caller_node(pc: WorkflowInterpreter):
# 调用子程序并传入参数
result = await pc.call_sub(
pc.get_graph().calc.resolve_alias("target_sub"),
"positional_arg",
custom_kw="value",
)
# result 是子程序最后一个节点的返回值被调用的子程序节点可以声明对应的参数签名来接收这些操作数。参数的匹配由解释器内部的依赖解析系统完成——位置参数按索引匹配,关键字参数按名称匹配。
从外部触发 trap
当 call_sub(interrupt=True) 作用在运行中的解释器上时,目标不一定是例程本体——它可以直接是一个 CALL 节点,由该 CALL 代你进入例程:
import asyncio
from amrita_sense import ALIAS, Node, WorkflowInterpreter
from amrita_sense.instructions import CALL, FN
from amrita_sense.instructions.subprogram import ARCHIVED_NODES
from amrita_sense.runtime.workflow import PC_CHECKPOINT
@Node()
async def trap_point() -> None: ...
@Node()
async def worker_a() -> None: ...
async def main() -> None:
worker = FN("worker_entry", worker_a)
traps = ARCHIVED_NODES(ALIAS(CALL("worker_entry"), "trap_entry"))
comp = trap_point >> traps >> worker
pc = WorkflowInterpreter(comp.render())
task = asyncio.create_task(pc.run())
await pc.object_io.wait_to_suspend(PC_CHECKPOINT)
await pc.call_sub(pc.get_graph().calc.resolve_alias("trap_entry"), interrupt=True)
pc.object_io.resume()
await task
asyncio.run(main())这就是一次 trap:停驻节点被 trap 那一轮消耗掉,执行从停驻地址 + 1 继续——因为 RET 恢复到保存地址再加一。完整的地址栈推演与可运行版本(demos/d10_call_trap.py)见外部中断调用。
与 Depends 的协同
子程序节点可以同时使用 call_sub 传入的操作数和 Depends 声明的依赖。两者来自不同的注入源,在依赖解析阶段统一处理。如果操作数和依赖之间存在名称冲突,操作数具有更高的匹配优先级。
关于 Depends 返回 None
如果子程序入口节点通过 Depends 声明了某个依赖,而该依赖的工厂函数返回了 None,工作流会直接抛出异常并终止。这与事件系统的“返回 None 则跳过处理器”行为不同——节点是原子执行单元,依赖解析失败意味着节点无法运行,这不是可以“跳过”的场景。
调用栈与返回地址恢复
调用栈是 AmritaSense 子程序调用机制的核心数据结构,保证了多层嵌套调用的正确返回。
调用栈的结构
调用栈基于 Stack[PointerVector] 类型实现。它的几个关键特性:
- LIFO 后进先出:最近压入的地址最先被弹出,天然匹配嵌套调用的返回顺序
- 溢出保护:栈有最大容量限制,防止无限递归耗尽内存
- 线程安全:使用内部锁保护压栈和弹栈操作
正常返回与异常安全
子程序执行完毕后,call_sub 的 finally 块执行弹栈恢复。这意味着即使子程序内部抛出异常,返回地址栈也会被正确清理。异常会继续向上传播,但调用栈不会因此损坏——后续的节点调用仍然可以正常进行。
嵌套调用的栈行为
在多层嵌套调用场景中,调用栈的状态变化如下:
初始状态: []
调用 level1: [addr_0]
调用 level2: [addr_0, addr_1]
调用 level3: [addr_0, addr_1, addr_2]
level3 返回: [addr_0, addr_1]
level2 返回: [addr_0]
level1 返回: []每一次 call_sub 压入当前地址,每一次返回弹出栈顶地址。这种严格的后进先出机制保证了无论嵌套多深,每一层子程序都能准确返回到调用它的那一层。
跳转覆盖与返回抑制
如果子程序内部执行了 JMP 或其他跳转操作,JMP 位会被置起。此时 call_sub 的 finally 块依然会弹出保存的地址,但会跳过指针恢复——跳转目标被保留,解释器从那里继续。这意味着子程序通过跳转“覆盖”了正常的返回行为:call_sub 压入的那个地址会被丢弃,而不是被恢复。开发者需要意识到,子程序内的 JMP 会取消该次调用的自动返回。
小结
call_sub 和调用栈构成了 AmritaSense 子程序调用体系的底层基础。编排层面的 INVOKE 指令是对这一原语的声明式封装,而节点内部的 call_sub 调用则为开发者提供了在代码中动态构造子程序调用链的完整自由度。在下一章中,我们将探讨如何将这些能力与外部中断机制结合,构建可被外部世界安全注入的子程序节点库。
