技术博客

signal 语法糖的展开:从 connect 到 emit,编译器帮你写了什么

第 85 / 149 篇 · 2025-03-25 · godot · GDScript信号系统源码分析

GDScript 的信号写起来像语言内建机制——signal health_changed(amount) 一行声明,然后 health_changed.emit(50) 发射、health_changed.connect(_on_changed) 连接,类型安全还带参数检查。但如果你去 gdscript_vm.cpp 的 opcode 列表里找"信号专用指令",会发现几乎没有:信号系统的重量全在 Object 层,GDScript 提供的只是三种写法的语法糖。这篇逐层展开,看每句糖脱壳之后调用了谁。

声明:进脚本的信号表,不进 ClassDB

signal health_changed(amount) 在 analyzer 阶段被登记到 GDScript 对象的信号列表里,附带参数名和类型标注。它和 class_name、函数一样属于脚本级元数据——脚本是信号的第一所有权人,Object 只是运行时宿主。实例化脚本对象时,引擎把这些脚本信号合并进对象的信号查询路径,所以编辑器里连自定义信号都能出现在连接对话框里。

值得注意的设计:信号声明带参数名(amount),这些名字不是装饰。编辑器连接向导、文档生成、以及 emit 时的参数提示都靠它们;运行期不校验名字(校验的是个数和可转换性),但写错参数名会在编辑器里直接被 analyzer 拦下——类型检查那篇讲过,信号参数类型是 analyzer 双遍检查的一部分。

emit:一次普通的方法调用

health_changed.emit(50) 的展开结果会让很多人意外:analyzer 认出 emit 是 Signal 类型的内建方法,这行编译成一次普通的方法调用——目标是个 Signal Variant(内部就是 {Object*, StringName} 二元组),方法是 emit。没有任何 "EMIT_SIGNAL" 级别的专用机制贯穿 VM。

真正的发射逻辑在 core/object/object.cpp 的 Object::emit_signal:

// core/object/object.cpp, 4.2(结构性删减)
Error Object::emit_signal(const StringName &p_name, const Variant **p_args, int p_argcount) {
    // ...
    SignalData *s = signal_map.getptr(p_name);      // ① 哈希查表
    if (!s) { return ERR_UNAVAILABLE; }

    // ② 快照当前连接列表,避免回调里改连接破坏遍历
    LocalVector<Callable> c;
    for (const KeyValue<Callable, Connection> &E : s->slot_map) {
        c.push_back(E.value.callable);
    }

    // ③ 逐个调用
    for (const Callable &callable : c) {
        // 按 connection.flags 决定直接调还是走消息队列
        callable.callp(p_args, p_argcount, r_ret, ce);
    }
    return OK;
}

三个实现细节,每个都有来历:

① StringName 哈希查表。信号名不是字符串比较而是 StringName 比较——StringName 的哈希缓存机制(见引擎架构那篇)让这一步是常数时间,但常数不小:一次哈希加一次 HashMap 探测。高频信号(每帧发射的)这笔账在累计,这是"信号风暴"警告的微观基础。

② 先快照再遍历。回调里可能 disconnect 甚至 connect 新回调——如果边遍历边执行,迭代器会在回调修改容器时失效。快照一份 LocalVector 再执行,牺牲一次小分配换遍历安全。反过来也带来一个语义事实:本次发射看不到自己发射中途新连上的回调,它在快照之后。这个时序细节在"发射信号时连新回调"的代码里会咬人。

③ flags 决定调用路径。CONNECT_DEFERRED 的连接不走直接 callable.callp,而是把参数打包进 MessageQueue,等当前阶段结束 flush 时才真正调用——和 call_deferred 同一个队列(场景树那篇的主角)。所以 deferred 信号回调的执行时机是"本帧消息队列 flush 点",不是"信号发射点",写时序敏感逻辑时这个一帧差是常见 bug 源。

connect:Callable 与 flags 的组合

health_changed.connect(_on_changed) 展开后是一次 Signal.connect(Callable, flags) 调用,最终落到 Object::_connect:

  • 连接的本体是 Callable——{Object* + 方法指针} 或自定义 callable。信号系统不存"方法名字符串",连接建立时方法名已经解析成地址了。3.x 时代的 connect("health_changed", self, "_on_changed") 字符串 API 在 4.x 被废,就是因为每次连接/发射都要走字符串查找;换成 Callable 后发射路径零字符串操作。
  • flags 是位掩码:CONNECT_DEFERRED(走消息队列)、CONNECT_PERSIST(场景保存时写入 .tscn 的连接列表)、CONNECT_ONE_SHOT(回调执行前自动断开)、CONNECT_REFERENCE_COUNTED(同一个 callable 连多次只存一条,计数增加;disconnect 一次减一,归零才真断)。最后这个解决了 3.x 的大坑:重复 connect 同一个方法会注册多个回调,发射一次回调跑 N 遍,REFERENCE_COUNTED 让重复连接变成幂等操作。
  • bind 的参数存在 Callable 自己身体里。connect(func.bind(10)) 时 bind 已经发生了——返回的是个新的 BoundCustomCallable,把 10 存在自己的捕获区。信号表只管存这个 callable,不管参数哪来的。Callable 那篇(引擎架构分类)讲过 bind 的存储结构,这里刚好接上:信号系统对 bind 完全无感知,这正是组合式设计的收益。

发射时参数如何对齐:emit(50) 的 50 是位置参数,Callable::callp 时 BoundCustomCallable 把它存的 10 追加在后面,最终 _on_changed(50, 10)。顺序是"发射参数在前,bind 参数在后",记反了这个顺序是新手最常见的信号参数错位来源。

为什么 Signal 是一等 Variant

整个设计里最"反常"的决定是 Signal 本身是个 Variant 类型,可以存进变量、穿进函数、塞数组:

var hooks: Array[Signal] = []
func register(h: Signal): hooks.append(h)

这让信号从"对象身上的事件名"升级成可传递的事件句柄,解耦代码不必知道信号挂在谁身上。代价是 Signal Variant 持有 Object 裸指针——目标对象销毁后这个 Variant 变悬空,取它做任何事都会报 "previously freed"。Object 侧的清理动作(析构时断开所有连接)只保护"对象作为信号源"的方向,"对象作为 Callable 目标"的方向要靠调用前的有效性检查兜。

成本总结与替代方案

一次 emit 的完整成本:Signal 方法调用一次 → emit_signal 哈希查表 → 快照分配 → 逐 Callable callp(每个回调一次完整的 Callable 调用协议,含参数 Variant 数组装箱)。单个回调、低频信号:纳秒到微秒级,忽略。每帧发射、几十上百个监听者:哈希加装箱的常数开始乘出毫秒级——这就是大纲里"信号风暴"一篇要展开的替代方案(轮询缓存、集中派发、改走直接调用)存在的理由。

语法糖的设计取舍也值得记住:Godot 本可以把信号做成 VM 内建指令(像 OPCODE_AWAIT 那样),但实际选择是"Signal 对象 + 内建方法"——换来的是信号成为普通值、connect 是普通方法调用、C# 和 GDExtension 和 GDScript 共用同一套 Object 层协议。语言特性让位于对象模型,这是 Godot 架构里反复出现的取舍方向。

health_changed.emit(50) 的完整调用链(4.2) GDScript: sig.emit(50) 编译为普通方法调用 Signal::emit 解包 Object* + StringName Object::emit_signal signal_map 哈希查表 SignalData(object.h) slot_map:Callable → Connection 哈希表,键即连接本体 Connection 持有 flags(DEFERRED / ONE_SHOT / REFERENCE_COUNTED) 发射前快照 连接列表复制进临时数组 回调内改连接不影响本次遍历 逐回调执行 直接连接:callable.callp(参数装箱) → 目标方法 DEFERRED 连接:参数进 MessageQueue,flush 时才调用;bind 参数由 Callable 自身追加

本文代码引自 Godot 4.2.2-stable 的 core/object/object.cpp 与 core/object/object.h,均有删减;CONNECT_ 标志位定义见 core/object/object.h 的 ConnectFlags 枚举。*