首页 / 资讯中心 / 文章详情

从散落await到Wait单例:Godot协程等待与取消实践

从散落await到Wait单例:Godot协程等待与取消实践 ★ FEATURED ARTICLE
上个月有人丢给我一个 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)后面三个参数默认值凑在一起恰好制造了一个非常反直觉的行为游戏暂停时这个计时器照样在走。我第一次遇到这个问题是在做暂停菜单的时候。玩家按下 ESCget_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.gdextends 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 秒voidframes(n)等 n 个渲染帧voidphysics_frames(n)等 n 个物理帧voidrender_frame()等一次渲染提交完成voiduntil(cond, timeout)轮询直到条件成立bool是否在超时前成立signal_once(sig, timeout)等一次信号带超时boolanimation(player, name)等指定动画播完voidtween(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或者自定义的RefCountedvar 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()
阅读完成 · 觉得有帮助?
咨询建站