Skip to content

4.3 子节点调用

AmritaSense 提供了完整的子程序调用机制。与 GOTO 的单向跳转不同,子程序调用会保存当前执行位置,跳转到目标节点序列执行,完毕后自动返回调用点继续推进。这套机制同时服务于两个层面:编排层面的 CALL 指令,以及节点内部通过解释器 API 发起的 call_sub

本章将从解释器底层 API 出发,逐层解析调用栈管理、参数传递机制,以及在节点代码中直接调用子程序的实践。


4.3.1 call_sub:解释器底层的调用原语

call_subWorkflowInterpreter 提供的底层调用原语。编排层面的 CALL 指令、节点内部的子程序调用,最终都通过它完成。它的核心工作流程如下:

调用栈管理

  1. 保存返回地址:将当前执行指针 _pointer 压入返回地址栈 _ret_addr_stack
  2. 设置新执行上下文:将执行指针替换为目标子程序的入口地址
  3. 执行子程序:调用 _call 方法,在目标地址开始节点执行循环
  4. 恢复执行上下文:子程序执行完毕后,finally 块从返回地址栈弹出原指针并恢复,解释器继续从调用点的下一个节点推进

锁与中断模式的区分

call_sub 提供 interrupt 参数,控制是否在执行子程序时获取解释锁:

  • interrupt=False(默认):不获取锁。适用于节点内部发起的子程序调用——此时调用者节点本身已持有解释锁,重复获取会触发 aiologic 的死锁检测并直接报错
  • interrupt=True获取解释锁。适用于外部世界在两次迭代之间发起的“安全外部调用”——此时锁空闲,获取锁后可安全执行注入的节点序列

这种设计让同一套调用原语同时服务于内部复用和外部注入两种场景,而锁的获取策略决定了使用边界。

跳转标记的优先级

子程序执行完毕后,call_sub 会检查 _jump_marked 标志。如果子程序内部执行了跳转操作(如 GOTO),该标志已被设置为 True。在这种情况下,finally不会恢复原来的执行指针——跳转后的新地址被保留,解释器从跳转目标继续执行。这确保了子程序内部的跳转能够正确影响主工作流控制流。


4.3.2 带操作数的子程序传参

call_sub 支持直接传递额外的位置参数和关键字参数。这些参数在子程序入口节点的依赖解析阶段被合并到可用参数池中,供节点的函数签名声明。

在节点代码中使用 call_sub 传参

当开发者在自定义节点中编写子程序调用逻辑时,可以直接传入操作数:

python
@Node()
async def caller_node(pc: WorkflowInterpreter):
    # 调用子程序并传入参数
    result = await pc.call_sub(
        pc.find_addr_alias("target_sub"),
        "positional_arg",
        custom_kw="value"
    )
    # result 是子程序最后一个节点的返回值

被调用的子程序节点可以声明对应的参数签名来接收这些操作数。参数的匹配由解释器内部的依赖解析系统完成——位置参数按索引匹配,关键字参数按名称匹配。

与 Depends 的协同

子程序节点可以同时使用 call_sub 传入的操作数和 Depends 声明的依赖。两者来自不同的注入源,在依赖解析阶段统一处理。如果操作数和依赖之间存在名称冲突,操作数具有更高的匹配优先级。

关于 Depends 返回 None

如果子程序入口节点通过 Depends 声明了某个依赖,而该依赖的工厂函数返回了 None,工作流会直接抛出异常并终止。这与事件系统的“返回 None 则跳过处理器”行为不同——节点是原子执行单元,依赖解析失败意味着节点无法运行,这不是可以“跳过”的场景。


4.3.3 调用栈与返回地址恢复

调用栈是 AmritaSense 子程序调用机制的核心数据结构,保证了多层嵌套调用的正确返回。

调用栈的结构

调用栈基于 Stack[PointerVector] 类型实现。它的几个关键特性:

  • LIFO 后进先出:最近压入的地址最先被弹出,天然匹配嵌套调用的返回顺序
  • 溢出保护:栈有最大容量限制,防止无限递归耗尽内存
  • 线程安全:使用内部锁保护压栈和弹栈操作

正常返回与异常安全

子程序执行完毕后,call_subfinally 块执行弹栈恢复。这意味着即使子程序内部抛出异常,返回地址栈也会被正确清理。异常会继续向上传播,但调用栈不会因此损坏——后续的节点调用仍然可以正常进行。

嵌套调用的栈行为

在多层嵌套调用场景中,调用栈的状态变化如下:

text
初始状态:    []
调用 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 压入当前地址,每一次返回弹出栈顶地址。这种严格的后进先出机制保证了无论嵌套多深,每一层子程序都能准确返回到调用它的那一层。

跳转覆盖与返回抑制

如果子程序内部执行了 GOTO 或其他跳转操作,_jump_marked 被置为 True。此时 call_subfinally 块会跳过弹栈恢复,返回地址被留在栈上。这意味着子程序通过跳转“覆盖”了正常的返回行为——开发者在子程序中使用 GOTO 时,需要意识到调用栈不会自动清理,可能需要手动管理栈状态。


小结

call_sub 和调用栈构成了 AmritaSense 子程序调用体系的底层基础。编排层面的 CALL 指令是对这一原语的声明式封装,而节点内部的 call_sub 调用则为开发者提供了在代码中动态构造子程序调用链的完整自由度。在下一章中,我们将探讨如何将这些能力与外部中断机制结合,构建可被外部世界安全注入的子程序节点库。

LGPL V2 许可证约束