Godot 的信号(Signal)常被当成解耦银弹,但有一件事很少被讲清楚:它是在「调用它的那个线程」上同步执行的,不是什么跨线程的消息总线。把重活丢进后台线程之后直接 emit,Godot 会当场报错。我那个美术资源批处理工具就撞上过这一幕,主线程 FPS 从 12 回到 60 正高兴,一点「开始」就崩了。
信号解耦的是调用关系,不是线程
很多工程师对信号的理解停在「发送方不认识接收方,架构就干净了」。这话没错,但只说了一半。信号只是把模块之间的调用关系断开,它一点没动线程。你在哪个线程 emit,那段回调就在哪个线程跑完。
我当初的错觉是:既然逻辑层和界面层已经用信号断开,这套东西「在任何地方都能跑」。结果把 200MB 素材目录的扫描搬进 Thread,线程干完活 emit 一个 scan_finished 去改界面的 Label,Godot 直接在门口拦了,因为 SceneTree 和节点基本只能在主线程碰。换成不报错的情况更阴险:某个信号回调只改了个共享状态、没直接碰界面,你以为没事,其实已经在两个线程之间踩了同一块内存。
一个真实的翻车现场
报错那行字大意是「不能在非主线程里发信号」。我盯着它看了挺久,因为它戳破了一个想当然:我一直在用信号解决「模块耦合」,却从没想过「线程耦合」是另一回事。信号把前者解决得很好,但在后者面前什么都不是。
后来我给自己立了条规矩:只要一个生产者可能跑在非主线程,它对外暴露的接口就不该是信号,而该是一个明确的跨线程边界。信号退到这个边界的主线程那一侧去发。
修法其实很朴素
Godot 从后台线程回主线程,标准手段是 call_deferred,它把调用排到主线程的空闲帧执行。所以我没改架构,只是把「发信号」挪了位置:线程只往一个 Mutex 保护的队列塞结果,主线程在 _process 里捞出来再 emit。
gdscript
var mutex := Mutex.new()
var inbox := []
func _thread_run():
var result = do_heavy_scan()
mutex.lock()
inbox.append(result) # 只往线程安全队列塞,不碰界面
mutex.unlock()
func _process(_delta):
mutex.lock()
var batch = inbox.duplicate()
inbox.clear()
mutex.unlock()
for r in batch:
scan_finished.emit(r) # 信号只在主线程发
信号还是那个信号,只是现在保证跑在主线程。场景简单到只有一个完成事件,直接在工人线程里 call_deferred("on_scan_done", result)、让 on_scan_done 去发信号也行。
这堵墙在 Godot 之外一模一样
前端:你没法在 Web Worker 里直接 setState。Worker 算完只能 postMessage,主线程收到再改状态。那堵墙就是 worker 和 main 之间的消息边界。
Node.js:worker_threads 不共享事件循环,子线程想通知主线程只能 postMessage 或 MessageChannel,不存在「跨线程 emit 一个 EventEmitter」这种事。第一次写 worker 的人大多以为能直接调主线程的回调,醒过来都靠报错。
后端更干脆:Redis、Kafka 这种消息队列,本质就是「跨进程的信号」,而且它们把异步、顺序、投递保证全写在脸上,反而没人会误会它们是同步调用。
规律就一句:观察者或者事件系统都有线程亲和性。解耦了调用方,不等于把活搬离了线程。信号能跨模块,跨不了线程。
我的判断:什么时候该用信号
主线程内、同一个上下文、一对多通知,而且发送方不该知道接收方是谁,信号是最合适的。比如游戏逻辑通知界面「血量变了」,系统通知多个物体「回合开始」。这时候它比直接持有引用干净太多。
一旦生产者可能跑在别的线程或别的进程,信号就不是接口了,队列或者消息才是。信号退到边界的主线程那一侧去发。
还有个衍生坑:别在节点即将释放时(_exit_tree)emit 信号去通知一个可能已经半死的对象,顺序不对就会调到空引用。这类问题和线程问题同源,都是「你以为对方还活着」。
收尾
解耦和并发是两件事。信号把前者解决得很好,但它在后者面前什么都不是。我那个工具最后跑得稳,不是因为信号用得多巧妙,而是因为我终于承认线程边界必须被显式画出来,不能指望一个解耦机制顺手把它也办了。这件事在 Godot 里是 call_deferred 和 Mutex,在前端是 postMessage,在 Node 是 worker_threads 的通信通道。名字不一样,墙都在那。