Godot 信号不是线程安全的:我是怎么在后台线程里翻车的

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 不共享事件循环,子线程想通知主线程只能 postMessageMessageChannel,不存在「跨线程 emit 一个 EventEmitter」这种事。第一次写 worker 的人大多以为能直接调主线程的回调,醒过来都靠报错。

后端更干脆:Redis、Kafka 这种消息队列,本质就是「跨进程的信号」,而且它们把异步、顺序、投递保证全写在脸上,反而没人会误会它们是同步调用。

规律就一句:观察者或者事件系统都有线程亲和性。解耦了调用方,不等于把活搬离了线程。信号能跨模块,跨不了线程。

我的判断:什么时候该用信号

主线程内、同一个上下文、一对多通知,而且发送方不该知道接收方是谁,信号是最合适的。比如游戏逻辑通知界面「血量变了」,系统通知多个物体「回合开始」。这时候它比直接持有引用干净太多。

一旦生产者可能跑在别的线程或别的进程,信号就不是接口了,队列或者消息才是。信号退到边界的主线程那一侧去发。

还有个衍生坑:别在节点即将释放时(_exit_tree)emit 信号去通知一个可能已经半死的对象,顺序不对就会调到空引用。这类问题和线程问题同源,都是「你以为对方还活着」。

收尾

解耦和并发是两件事。信号把前者解决得很好,但它在后者面前什么都不是。我那个工具最后跑得稳,不是因为信号用得多巧妙,而是因为我终于承认线程边界必须被显式画出来,不能指望一个解耦机制顺手把它也办了。这件事在 Godot 里是 call_deferredMutex,在前端是 postMessage,在 Node 是 worker_threads 的通信通道。名字不一样,墙都在那。

相关推荐
禁止摆烂_才浅1 小时前
React Hooks 高频面试题
前端·react.js·面试
禁止摆烂_才浅1 小时前
React 高频面试题
前端·react.js·面试
禁止摆烂_才浅1 小时前
JavaScript 高级面试题
前端·javascript·面试
禁止摆烂_才浅1 小时前
JavaScript 基础 高频面试题
前端·javascript·面试
吃饱了得干活1 小时前
为什么你的Service越写越臃肿?三层架构的“业务逻辑层”是个黑盒
java·后端·架构
颜进强1 小时前
11 - 从需求拆解到 OpenSpec:为什么不要直接敲 /opsx:explore
前端·后端·ai编程
禁止摆烂_才浅1 小时前
HTML 高频面试题
前端·面试·html
show4331 小时前
2026微信小程序批量处理视频文件架构方案:免费批量实测
微信小程序·小程序·架构
吃饱了得干活1 小时前
从经典的三层架构到DDD:一次对“业务逻辑层”的解剖与重构
java·后端·架构