从散落await到Wait单例:Godot协程等待与取消实践
上个月有人丢给我一个 Godot 工程压缩包,说 Boss 战演出在暂停菜单弹出来之后还在继续往下走。我打开玩家脚本往下翻,await get_tree().create_timer(0.12).timeout这一行出现了四十多次,中间夹着几处await get_tree().process_frame,还留着两段从旧版本迁移过来没改干净的yield写法。整段连招逻辑像一条被剪成四十多段的绳子,每段之间靠一个临时计时器接起来,谁也不知道中途被打断之后该从哪一段开始收拾。
这件事让我下决心把这套东西收进一个统一的等待机制库里。Godot 引擎的异步编程模型其实很轻,协程加await就够用了,问题从来不是语言能力不够,而是等待这个动作散落在几十个脚本里之后,语义就失控了:有的等真实时间,有的等游戏时间,有的在暂停时还在跑,有的在对象被释放之后永远醒不过来。这篇文章讲的就是我怎么把这堆东西收进一个Wait单例,从接口设计、参数取舍、协程生命周期里的几个致命坑,一直讲到拿它重写一段完整的 Boss 战演出序列。适合已经写过await但被它的边界条件咬过的人,也适合刚开始用 GDScript 写异步逻辑、还没意识到create_timer有四个参数的读者。
1. 原生await散落在项目里的代价,远不止代码难看
1.1 那行create_timer里藏着的四个参数
大部分人写等待的时候只会用第一个参数。SceneTree.create_timer()的完整签名是create_timer(time_sec, process_always = true, process_in_physics = false, ignore_time_scale = false),后面三个参数默认值凑在一起,恰好制造了一个非常反直觉的行为:游戏暂停时,这个计时器照样在走。
我第一次遇到这个问题是在做暂停菜单的时候。玩家按下 ESC,get_tree().paused = true,所有节点的_process和_physics_process都停了,但那段用create_timer拼起来的受击演出依旧按部就班地播完了动画、扣完了血、切完了状态机。因为SceneTreeTimer本身的推进逻辑挂在 SceneTree 上,而 SceneTree 在主循环里从来没有停过,process_always默认true意思就是"无视暂停,永远推进"。
这里必须分清三件事:
process_always = true:暂停时继续计时,适合做 UI 淡出、暂停菜单里的提示闪烁这类即使在暂停中也该动的效果。process_always = false:暂停时冻结,恢复后继续从剩余时间往下走。绝大多数游戏逻辑等待都该用这个。process_in_physics = true:计时器跟着物理帧推进,适合需要和_physics_process严格对齐的判定,比如无敌帧、受击硬直。ignore_time_scale = true:忽略Engine.time_scale,在做慢动作演出时,UI 计时不会被拖慢。
我的结论很直接:默认值不能信。凡是涉及游戏逻辑的等待,process_always一律显式传false,把它当成一个必须填的参数来对待。这件事我在三个不同项目里都栽过,每次都是"暂停之后怪事发生了"才回头查。
1.2 协程不是线程,它只是把函数切成几段
第二个认知偏差更隐蔽。很多人第一次用await的时候会默认它"在后台跑",于是在await后面写一个几千次的循环,然后疑惑为什么帧率掉了。
GDScript 的协程本质上是把函数在await处切开,把当前的局部变量、执行位置打包保存起来,等信号发射时再从那一刀的位置继续。整个过程始终在主线程上,没有任何并行。所谓"异步",只是"不阻塞当前调用栈",不是"不占用 CPU"。你在await之后写一段重活,它照样会在恢复的那一帧把主线程堵住。
这也解释了几个常见现象。第一,await之后的代码恢复执行的时机,是信号发射的那个调用栈里,如果你在那个位置做重活,就直接拖慢了发射信号的那一帧,而不是"下一帧再处理"。第二,多个协程同时等同一个信号,恢复顺序是不保证的,别写出依赖顺序的逻辑。第三,在_process里写await是灾难,因为每帧都会新建一个协程,几秒钟就能挂出几百个互不相干的状态机。
1.3 等待其实是需要被统一管理的资源
回到开头那份工程。真正的问题不是代码难看,而是等待这个动作失去了统一入口,于是没法统计、没法取消、没法替换语义。
我列过一张清单,问自己如果有个统一入口,我能多做哪些事:能不能知道当前场景里挂了多少个还没醒的协程;能不能在场景切换时一次性取消某个对象发起的全部等待;能不能把"等 0.3 秒"和"等 0.3 秒游戏时间"区分成两个明确的方法名而不是一个布尔参数;能不能在没有真机的情况下用假的等待实现跑单元测试。这四个问题,只要等待被收进一个单例,全部能解。
所以后文所有的设计都是围绕一件事展开的:把await后面那一串场景树访问和信号名,压缩成一个语义明确、参数可控、可取消、可统计的库调用。
2. Wait单例的骨架:把SceneTree的获取路径固定下来
2.1 静态类还是 Autoload 节点,我选了后者
Godot 4 支持static func,所以最直觉的做法是写一个class_name Wait的纯静态类,里面全是static func seconds(t)。这样不占场景树,调用也干净。
但我最后没有这么做,原因有三个。
第一,静态函数没有实例,做不了任何状态统计。我想知道"当前挂了多少个协程",这个计数器得有人持有。
第二,静态类和 Autoload 在 Godot 里同名会打架。如果你既写了class_name Wait,又在项目设置里挂了一个叫Wait的 Autoload,编辑器会给你一个命名冲突的警告,后续调用到底指向谁很容易搞混。这个坑不致命但很烦人。
第三,静态函数里拿 SceneTree 只能走Engine.get_main_loop() as SceneTree,这个转换在主循环不是标准 SceneTree 的情况下会拿到null,虽然正常项目不会遇到,但多一层不必要的假设。
最终的方案是:res://autoload/wait.gd,extends Node,不写class_name,在项目设置里注册成 Autoload,名字就叫Wait。调用方写await Wait.seconds(0.3),全局可用,编辑器补全也正常。
# res://autoload/wait.gd extends Node var _active: int = 0 func _ready() -> void: # 库本身不受暂停影响,是否响应暂停交给每个方法自己的参数决定 process_mode = Node.PROCESS_MODE_ALWAYS set_process(false) func active_count() -> int: return _activeprocess_mode设成ALWAYS这一行很关键。Autoload 节点默认继承父级的PROCESS_MODE_INHERIT,暂停时它自己的_process会停。虽然协程的恢复主要靠 SceneTree 的信号,但一旦库内部后期要加自己的_process调度,暂停就会让整个库卡死。提前定好基座,比后期排查省事得多。
2.2 三种取树路径的对比
库内所有的等待方法都要用到 SceneTree,取树方式必须统一。我把三种常见路径列成表,供你在自己项目里挑:
| 取树方式 | 写法 | 适用位置 | 主要风险 |
|---|---|---|---|
| 节点实例方法 | get_tree() | 任意挂在树上的 Node | 未入树时返回null,_init和_enter_tree早期调用会崩 |
| 引擎主循环转换 | Engine.get_main_loop() as SceneTree | 静态函数、工具类 | 自定义 MainLoop 时转换失败返回null |
| 单例内部持有 | Autoload 里的get_tree() | 库内部 | 需要在_ready之后再调用 |
在 Autoload 里,get_tree()在_ready之后一定是有效的,生命周期比任何业务节点都长,所以直接用它就行,不需要额外缓存引用。缓存反而有风险:场景切换时 SceneTree 不变,但如果你在某些特殊流程里换了根,缓存就可能过期。
2.3 第一版接口清单
接口设计上我做了一次狠的删减。原本想做得像个完整的任务框架,带调度器、优先级、并发限制,写到一半发现没人会用,全部砍掉,只留六个原语加一个组合器:
| 方法 | 语义 | 返回值 |
|---|---|---|
seconds(t) | 等游戏时间 t 秒 | void |
frames(n) | 等 n 个渲染帧 | void |
physics_frames(n) | 等 n 个物理帧 | void |
render_frame() | 等一次渲染提交完成 | void |
until(cond, timeout) | 轮询直到条件成立 | bool是否在超时前成立 |
signal_once(sig, timeout) | 等一次信号,带超时 | bool |
animation(player, name) | 等指定动画播完 | void |
tween(tw) | 等补间结束 | void |
接口保持在个位数是刻意的。等待机制这种东西,每多一个方法,调用方就多一次"我该用哪个"的犹豫,最后还是会回去写裸的create_timer。
3. 五个等待原语的实现与参数推敲
3.1seconds():零值也要等一帧
func seconds(t: float, process_always: bool = false, ignore_time_scale: bool = false) -> void: if t <= 0.0: await get_tree().process_frame return _active += 1 await get_tree().create_timer(t, process_always, false, ignore_time_scale).timeout _active -= 1那个t <= 0.0的分支看起来多余,其实是我踩过的第二大坑。
调用方经常写await Wait.seconds(duration),而duration是从配置表读的,某次策划把它填成了 0。如果没有这个分支,create_timer(0)会在下一次 SceneTree 迭代时立刻超时,但仍然插入了至少一帧的延迟,看起来好像没事。问题出在与其他等待混用的时候:seconds(0)和seconds(0.1)在时间轴上的相对顺序会因为帧率和 SceneTree 内部处理顺序而漂移,演出就错位了。统一在零值时等一帧,至少保证所有零时长等待都在同一个节拍上醒来。
_active计数器还有一个附带用途:它能当泄漏探测器。如果某个协程在await期间所属对象被释放、协程永久悬挂,那_active -= 1永远不会执行,计数器只增不减。在调试面板上把这个数显示出来,泄漏一目了然。这是我在排查一个"跑久了内存缓慢上涨"的问题时才发现的意外收获。
3.2frames()与physics_frames():什么时候必须等物理帧
func frames(n: int = 1) -> void: for _i in n: await get_tree().process_frame func physics_frames(n: int = 1) -> void: for _i in n: await get_tree().physics_frame渲染帧和物理帧的区别,很多人是靠"试出来的"而不是"想明白的"。判断标准很简单:你等的是画面还是判定。
等画面用frames():UI 布局刷新后读控件的size、await frames(2)让Container完成排版再取位置。这是最常见的使用场景,因为 Godot 的容器排版是延迟的,你在同一帧里add_child完立刻读size,拿到的是旧值,至少得等一帧,复杂嵌套要等两帧。
等判定用physics_frames():改完CollisionShape2D的shape之后立刻做射线检测,会拿到旧结果;move_and_slide()之后立刻查get_slide_collision_count(),某些情况下结果还没同步。这种时候等一个物理帧,问题就消失了。
物理帧还有一点必须记住:物理帧率是固定的 60(你可以在项目设置里改),和渲染帧率无关。渲染跑 144 帧的时候,物理仍然只走 60 次,所以physics_frames(1)在 144Hz 屏幕上意味着大约 16.7ms,而frames(1)只有约 7ms。把物理相关的等待写成frames(),在高刷屏和低端机上表现会完全不同,这种 bug 只在你换设备时才会冒出来,非常难查。
3.3until():用真实时间做超时,别用 delta 累加
func until(condition: Callable, timeout: float = -1.0) -> bool: var start_us := Time.get_ticks_usec() while not bool(condition.call()): await get_tree().process_frame if timeout >= 0.0: var elapsed := (Time.get_ticks_usec() - start_us) / 1_000_000.0 if elapsed >= timeout: return false return true超时计时我用Time.get_ticks_usec(),而不是累加get_process_delta_time()。原因有两个:delta会被Engine.time_scale缩放,玩家开了慢动作之后,你设的 3 秒超时会变成 6 秒真实时间;delta在暂停时也不推进,暂停期间的超时判断就废了。真实时间戳没有这两个问题。
条件传入用Callable而不是直接传布尔值,这是必须的,因为你需要在每次循环里重新求值:
await Wait.until(func() -> bool: return is_instance_valid(target) and target.ready_flag )这里有个 GDScript 的细节很多人不知道:lambda 捕获外部变量是按值捕获的。你在 lambda 里写count += 1是改不到外面那个count的,甚至会报错。要修改外部状态,得用引用类型当容器,Array、Dictionary或者自定义的RefCounted:
var box := {"done": false} await Wait.until(func() -> bool: return box["done"]) box["done"] = true这个写法看着怪,但从 4.0 到现在一直是这个行为,习惯了就好。
until必须有超时默认值这种设计我不推荐。我给timeout的默认值是-1.0表示不限时,但库内部所有调用点我都显式传了值。原因很直接:until最典型的 bug 就是条件永远不成立,协程挂在那里直到场景销毁。加一个 5 秒超时,最差情况也只是逻辑没按预期执行,而不是整个流程静默死掉。
3.4signal_once():给信号等待补上超时和清理
原生await some_signal有两个问题:没有超时,以及信号永远不发射时协程会一直挂着。
func signal_once(sig: Signal, timeout: float = 5.0) -> bool: var box := {"fired": false} var cb := func() -> void: box["fired"] = true sig.connect(cb, CONNECT_ONE_SHOT) var ok := await until(func() -> bool: return box["fired"], timeout) if not ok and sig.is_connected(cb): sig.disconnect(cb) return ok这里必须用CONNECT_ONE_SHOT,否则信号多次发射时回调会累积。超时之后要手动断开,不然这个 Callable 会一直挂在信号上,持有 lambda 的闭包,闭包里又持有box,形成一个不明显的小泄漏。这种泄漏单次只有几十字节,但一个长时间运行的游戏里,一次战斗失败重试十次就攒十个,慢慢就显出来了。
顺带说一个真正常见的操作失误:对已经发射过的信号用await。await只是建立连接等下一次发射,不是等待"状态"。等Tween.finished时如果补间已经跑完了,你会永远等下去。所以tween()方法的实现里必须先判断:
func tween(tw: Tween) -> void: if not is_instance_valid(tw) or not tw.is_valid(): return if not tw.is_running(): return await tw.finishedis_valid()判断补间有没有被kill()掉,is_running()判断是不是已经结束了,两个都不能省。
3.5animation()与render_frame():别被别人的信号叫醒
等动画播完,最直觉的写法是await player.animation_finished。这个写法有个隐蔽的坑:AnimationPlayer在同一帧里可能连续发多个animation_finished,尤其是你刚play()完立刻又切了一个动画,旧动画的完成信号会先到,你的await就被错误地唤醒了,后面的逻辑带着错误的假设继续跑。
稳妥的做法是校验动画名:
func animation(player: AnimationPlayer, anim_name: StringName) -> void: if not is_instance_valid(player): return if player.current_animation != anim_name: player.play(anim_name) while true: var finished_name: StringName = await player.animation_finished if finished_name == anim_name: return if not is_instance_valid(player): return循环里再做一次有效性判断是必要的,因为等待期间玩家可能被queue_free掉了。
render_frame()是个小众但好用的补充:
func render_frame() -> void: await RenderingServer.frame_post_draw在截图、读Viewport纹理、录屏这类场景里,你必须等渲染真正提交完成,否则拿到的是上一帧甚至空白。这个信号来自RenderingServer,不需要 SceneTree,所以它在暂停、在场景切换瞬间都有效,属于那种"平时用不上,用上就救命"的工具。
4. 协程悬挂与对象释放:三个必查的坑
4.1 对象先死、协程后醒
协程挂起时,它保存着对self的引用。如果这个节点在这期间被queue_free()了,等信号发射、协程试图恢复时,控制台会打出那句著名的关于instance is gone的报错。
排查这种问题有个固定的链路,我一般按这个顺序走:
第一步,看报错栈里出现的是哪个方法名,定位到具体脚本。第二步,在这个方法里找所有await,逐个检查等待的对象是不是可能先于当前节点销毁。第三步,看有没有在await之后直接访问成员变量或者get_node,这些操作在实例已释放时全部会炸。
修复方案有三层,按侵入性从低到高:
第一层,等待返回后立刻加判空。
await Wait.seconds(1.0) if not is_instance_valid(self) or not is_inside_tree(): return第二层,把长时间的等待拆成"跟随对象生命周期"的形式。如果是等自己身上的动画,可以直接用await get_tree().create_timer(...)换成await anim.animation_finished,因为动画组件被释放时连接会自动断开,协程不会恢复也就不会报错——但这时又变成了悬挂,所以还得配合取消机制。
第三层,也就是我最终采用的方案:给库加上可取消的执行上下文,让等待的发起方主动管理生命周期,而不是靠事后的判空兜底。这个放到第 5 节展开。
还有一个很隐蔽的变体:等待的信号来自一个即将被释放的对端。比如await enemy.defeated,敌人被释放时信号连接自动断开,你的协程永远醒不过来,控制台一声不响,逻辑就卡在那里。这种情况没有报错,是最难查的一类,只能靠日志和超时兜底。所以signal_once()的默认超时我设成了 5 秒而不是不限时。
4.2 暂停时的语义分裂
前面提过create_timer的process_always,这里补一个更微妙的点:SceneTree.process_frame在暂停时仍然会发射。
原因在于暂停只是让节点的_process不再执行,SceneTree 的主循环本身从来没有停过,否则暂停菜单都没法响应输入。所以await get_tree().process_frame在暂停期间会正常逐帧恢复,而create_timer(t, false)会冻结。
这两种等待在暂停时行为相反,一旦混用,就会写出"演出动画停了但状态机还在往前推"的怪现象。我给自己定了一条规矩:任何可能跨越暂停边界的时序逻辑,只能用Wait.seconds(),不能用Wait.frames()。因为秒级等待的暂停行为由参数显式控制,帧等待没有这个开关,它在暂停时永远推进。
如果你确实需要在暂停时逐帧推进(比如做暂停菜单里的逐帧动画),那就把它放在一个process_mode = PROCESS_MODE_ALWAYS的节点里,并且全程用frames()。这样意图是明确的,别人一看就知道这块逻辑是故意无视暂停的。
4.3 重入:同一个方法被连点两次
按钮连点、状态机在短时间内被触发两次,这类问题在协程里会放大。因为第一次调用还没走完,第二次调用又开了一条新的协程链,两条链同时在改同一份状态。
我遇到最典型的一次是开场演出:玩家进入战斗场景的瞬间会触发一次入场动画,如果在这之前切过一次场景导致_ready跑了两次,两条入场序列就会同时推进,Boss 的血条会被扣两次。
防重入的方案我试过三种,按复杂度递增:
- 布尔锁:进门
if _running: return,出门_running = false。简单有效,但如果协程在中途因为异常路径提前返回,锁就永远不释放了。 - 令牌计数:每次启动生成一个自增 ID,协程内部每一步都校验
if my_id != _current_id: return,新的一轮启动会直接让旧的失效。 - 可取消执行器:把整条序列包成一个对象,取消时把对象标记为失效,所有等待点自动短路。
第二种在实际项目里性价比最高,只需要几行代码。第三种最干净,但要改所有等待点的写法。我在 Boss 战那套逻辑里用的是第三种,因为它同时还解决了对象释放的问题。
5. 用Wait库重写一段Boss战演出
5.1 原始写法长什么样
先看改造前的样子,这是我从那份工程里摘出来的一段,逻辑是 Boss 血量降到 50% 时切阶段:停手、播怒吼动画、震屏、刷小怪、显示血条变化、恢复战斗。
func _on_hp_changed(new_hp: float) -> void: if new_hp <= max_hp * 0.5 and _phase == 1: _phase = 2 _attacking = false await get_tree().create_timer(0.3).timeout anim.play("roar") await get_tree().create_timer(1.2).timeout camera.shake(0.5) await get_tree().create_timer(0.5).timeout spawn_minions() await get_tree().create_timer(0.8).timeout _attacking = true四个问题全占齐了:全部用默认process_always = true,暂停时照跑;总时长靠人肉累加,改一个环节就得重算全部;没有任何取消手段,中途玩家死了这条链还在走;_phase在第一帧就改了,后面的逻辑全靠时间硬撑。
5.2 用等待原语重排
改造后我保留协程的线性叙事风格(这是 GDScript 最舒服的地方),但把每一个时间点换成语义化调用,并且把整段包进一个可取消的执行器。
# res://core/routine.gd class_name Routine extends RefCounted var _cancelled: bool = false func cancel() -> void: _cancelled = true func alive() -> bool: return not _cancelled func guard(obj: Object) -> bool: return not _cancelled and is_instance_valid(obj)Routine是一个极轻的RefCounted,只做两件事:标记失效、组合有效性判断。真正让它在协程里起作用的方式是这样的:
# 在 Wait 里加一个带取消的版本 func seconds_c(t: float, r: Routine, process_always: bool = false) -> bool: if not r.alive(): return false var start_us := Time.get_ticks_usec() while true: var elapsed := (Time.get_ticks_usec() - start_us) / 1_000_000.0 if elapsed >= t: return r.alive() if not r.alive(): return false await get_tree().process_frame这个实现用轮询代替create_timer,看起来笨,但换来了两个能力:可以在任意一帧被取消,以及取消时不会留下一个还在跑的计时器对象。轮询的开销在这里可以接受,因为一帧只有一次判断,而且until和signal_once本身就需要轮询。
如果对开销敏感,可以加一个快路径:剩余时间大于 0.2 秒时用create_timer(0.2)分段等待,最后一段再逐帧轮询。我在实际项目里没有做这个优化,因为一帧内同时活跃的带取消等待从未超过三十个,测不出差异。
改造后的 Boss 战逻辑:
extends Node @onready var anim: AnimationPlayer = $Boss/AnimationPlayer @onready var camera: Camera2D = $Camera @onready var hp_bar: ProgressBar = $UI/BossHP @onready var boss: Node2D = $Boss var _phase: int = 1 var _routine: Routine = null var _last_hp: float = -1.0 func _on_hp_changed(new_hp: float) -> void: if _phase != 1 or new_hp > boss.max_hp * 0.5: return _phase = 2 _last_hp = new_hp _start_phase_two() func _start_phase_two() -> void: if _routine and _routine.alive(): _routine.cancel() _routine = Routine.new() _run_phase_two(_routine) func _run_phase_two(r: Routine) -> void: if not r.guard(boss): return boss.attacking = false await Wait.seconds_c(0.3, r, false) if not r.guard(boss): return anim.play(&"roar") await Wait.animation(anim, &"roar") if not r.guard(boss): return camera.shake(0.5) await Wait.seconds_c(0.5, r, false) if not r.guard(boss): return spawn_minions() await Wait.seconds_c(0.8, r, false) if not r.guard(boss): return boss.attacking = true _routine = null每一步之后都写一次guard,看着啰嗦,但这是协程写法的必然成本。因为协程一旦恢复,后面所有代码都会按顺序执行下去,唯一能拦住它的就是这些检查点。我把这个写法叫"检查点模式",它的好处是所有的中断点都是显式的,读代码的人一眼能看到哪里可能被掐断。
5.3 场景切换时统一收口
有了Routine,场景切换只需要在根节点上持有一份所有活跃执行的列表,退出时统一取消:
extends Node var _routines: Array[Routine] = [] func start(callable: Callable) -> Routine: var r := Routine.new() _routines.append(r) callable.call(r) return r func cancel_all() -> void: for r in _routines: r.cancel() _routines.clear() func _exit_tree()