Unity 里“等一会儿再执行”有三种完全不同的手段:协程、async/await、原生线程。选错了不会报错,只会在某一帧卡住或者数据错乱。这篇把三者的边界讲清楚。
1. 关于C#原生多线程和异步加载
场景的异步加载
SceneManager.LoadSceneAsync: 这是异步加载场景的核心入口。- 参数: 可以通过场景名称(
string sceneName)或其在Build Settings中的索引(int sceneBuildIndex)来指定要加载的场景。 - 模式:
LoadSceneMode.Single(加载新场景并卸载当前所有场景)或LoadSceneMode.Additive(在保留当前场景的基础上,附加加载新场景)。 - 返回值: 该方法返回一个
AsyncOperation对象,你可以通过它来跟踪加载进度(progress)并控制加载流程(如allowSceneActivation)。用于制作进度条
- 参数: 可以通过场景名称(
- 后台加载线程 (
Application.backgroundLoadingPriority): 这是控制后台加载线程行为的关键属性。- Unity会在一个独立的后台线程中执行
LoadSceneAsync等异步加载操作的数据读取部分。 - 你可以通过设置
Application.backgroundLoadingPriority来调整该线程的优先级,从而在加载速度和游戏性能之间取得平衡:ThreadPriority.High:主线程每帧最多花费50ms处理加载数据,加载最快,但可能导致帧率下降。适合在显示加载进度条时使用。ThreadPriority.Normal:主线程每帧最多花费10ms。ThreadPriority.Low:主线程每帧最多花费2ms,对游戏性能影响最小,但加载最慢。适合在游戏进行中后台加载。
- Unity会在一个独立的后台线程中执行
注意
SceneManager.LoadSceneAsync会卡90%,原因:
unity将进度明确分为两个明确阶段:
- 阶段一(0 ~ 0.9):资源反序列化与预加载。后台线程把所有资源(纹理、网格、预制体)从存储介质载入内存,并完成所有依赖项的解析。这一步结束后,场景的所有原始数据已经“待命”在内存中。也就是场景完成了加载,只是在等待入场
allowSceneActivation是AsyncOperation类上的一个 bool 类型属性。实现流程控制的核心思路是:将它当作一道“手动闸门”,默认关闭(false),待满足特定条件(如倒计时结束、玩家点击按钮、动画播放完毕)后,再将其打开(true)。把allowSceneActivation设为true的那一帧,主线程会立刻开始挤占大量CPU时间来完成阶段二- 阶段二(0.9 ~ 1.0,即最后的10%):场景对象实例化与初始化。这就是“激活”阶段,完全在主线程单帧内完成。(最消耗性能的部分)具体包括:
- 根据内存中的场景数据,在主线程逐帧创建所有
GameObject。 - 构建父子层级(
Transform父子关系)。 - 唤醒所有
MonoBehaviour,并按顺序执行Awake()和OnEnable()回调。 - 执行
Start()(仅在下一帧Update之前)。
- 根据内存中的场景数据,在主线程逐帧创建所有
最佳时机
卡在0.9后,先播放一个转场遮罩动画(Fade Out),等屏幕完全变黑(大约0.5秒后),再把allowSceneActivation设为true。这样即使主线程卡顿,玩家看到的也是黑屏,视觉上非常平滑。
UnityAPI是非线程安全的
- 不能再C#Thread中调用Unity API,其根本原因在于,unity的底层架构(C++引擎核心)和上层脚本(C#托管层)的内存管理机制。
- 对象引用是“伪句柄”:你在C#中拿到的
GameObject、Transform引用,,实际上是只想C++非托管内存中真实对象的句柄。如果子线程去修改这个对象,而主线程同时 也在做渲染提出或者物理运算,会导致C++侧的内存访问冲突(野指针),直接引发崩溃 - unity的引擎内核是C++非托管代码 (引擎本体、GameObject、Transform、Mesh、渲染、物理全都存在 C++ 堆内存); 游戏逻辑是 C# 托管代码(Mono/.NET),两者内存完全隔离,不能直接指针互访,
关联
Unity 伪句柄:存 ID,中间多一层查表,本来是用来规避裸指针风险;
但多线程同时查表操作底层 C++ 对象时,查表后拿到的临时 C++ 裸指针依然会产生野指针问题
Wrapper 只隔离了 C# 层,管不住 C++ 层多线程竞争。
伪句柄
- 当我们在Unity中去控制GameObject时,会得到一个伪句柄,并不是得到的真实的物体,只是一个轻量化的包装类,会提供在C++堆内存中的ID编号,
- 并不是直接指针,C#想要操作物体时,引擎内部 会拿着这个ID去查表,找到在C++堆里面 真正的
GameObjectInternal、TransformInternalC++ 对象 - 一旦底层 C++ 物体被销毁(
Destroy(go)、场景卸载),这个 ID 对应的底层内存就释放了,但你 C# 层的go变量不会立刻变成null,只会变成无效伪句柄
野指针
Unity 所有引擎底层逻辑(渲染剔除、物理更新、骨骼计算、物体销毁)强制主线程执行,C++ 内部没有多线程安全锁。
场景:
- 主线程:正在执行渲染剔除,遍历所有 Transform,拿着 ID 查表获取 C++ 对象指针
0x123456,准备读取坐标; - 子线程:同时通过同一个伪句柄 ID,查表拿到同一个 C++ 指针,直接修改
0x123456里的位置;
竞争问题两种致命情况
- 内存撕裂 + 指针失效 主线程刚拿到 C++ 对象指针,子线程中途把物体
Destroy掉,C++ 执行delete释放0x123456这块内存。 此时主线程手里的指针还保留0x123456,但这块内存已经归还系统,变成野指针。主线程继续读写 → 直接 Crash。 - 多线程无锁并发读写同一 C++ 对象 主线程读 Transform 矩阵用于渲染,子线程同时写 position 修改矩阵,C++ 结构体半写半读,内存数据错乱,出现穿模、物体瞬移、骨骼乱飞,甚至内存布局破坏衍生野指针。
C#的原生多线程
应用场景
如果需要并行计算(如AI寻路、大量数值运算),可以使用C#的Task或Thread,但运算完毕后,必须通过主线程的UnityMainThreadDispatcher或StartCoroutine将结果赋给GameObject。
异步加载队列的“串行阻塞”机制与避坑
Unity的AsyncOperation底层维护着一个全局的后台请求队列(LoadQueue)。这个队列遵循严格的FIFO(先进先出) 顺序,并且是单线程串行处理(即一次只处理一个请求的解包阶段)。
为什么挂起会阻塞后续?
假设你有以下加载顺序:
- 请求加载“大型场景A”(
allowSceneActivation = false) - 请求加载“小型UI预制体B”(
allowSceneActivation = true)
实际执行流程:
- 第1个请求进入队列,后台线程开始全速解包“场景A”。当“场景A”解包到0.9后,后台线程闲置(等待主线程允许激活)。
- 此时,虽然第2个请求已经在队列里排队了,但因为第1个请求还没结束(没有执行完毕,isDone=false),队列指针被卡在第1个请求上,第2个请求的后台解包工作根本不会被启动。
- 直到你将“场景A”的
allowSceneActivation设为true,(isDone=true)让它的整个生命周期结束,第2个请求才开始进行硬盘IO读取。
实战中的“隐形死锁”
很多开发者会在加载界面同时做两件事:加载场景 + 加载配套的UI图集。如果先把场景设为不激活,再去请求UI,UI会一直加载不出来,导致界面空白,玩家误以为游戏卡死。
推荐的应对策略:
| 场景需求 | 最佳做法 |
|---|---|
| 需要提前加载资源供后台使用 | 不要使用LoadSceneAsync 挂起。应改用 Resources.LoadAsync 或 Addressables.LoadAssetAsync 。这些资源加载API与场景加载API共享同一个底层队列,但资源加载通常较小,建议先加载完所有必须的资源,再发起场景异步加载。 |
| 必须挂起场景加载 | 在挂起场景加载(allowSceneActivation=false )期间,务必不要发起任何新的、重要的异步加载请求(如角色模型、UI图集),因为新请求会被饿死。所有预加载资源的工作,必须在调用LoadSceneAsync 之前完成。 |
| 需要在加载时显示动画 | 由于后续请求被阻塞,加载界面的进度条动画不能依赖新的异步资源加载请求来驱动。应该使用纯CPU计算的旋转Loading图标、纯TextMeshPro的帧率变化,或者在启动LoadSceneAsync 之前就预先加载好Loading界面的所有素材。 |
C#原生多线程和Unity协程对比
是两种完全不同的一部处理机制,最主要的区别在于:协程运行在主线程上,原生多线程是真正的并行的
C#原生多线程(重计算或者I/O操作首选)
- 向操作系统申请一个独立的执行流,与Unity主线程并行运行。
- 真正的并行,设备有多核CPU,这些线程可以被分配到不同核心上真正地同时工作
- 由于unity的API大部分不支持线程安全,所以和UnityAPI交互时必须铜通过Unity主线程调用
- 主要用来处理CPU密集型耗时任务,不阻塞主线程,AI计算,网络通信,文件读写,路径寻优,创建和销毁线程本身是开销较大的操作
- 在子线程中完成纯C#的数据计算,然后将结果缓存起来。再通过主线程的
Update或协程,去读取这个结果并应用到游戏对象上
Unity协程(等待或者分帧逻辑首选)
- 并发在Unity的主线程上
- 在同一个时间点只有一个协程在执行,无法真正利用多核加速计算
- 编写安全,可以直接访问GameObject
- 主要时编写简单,安全,适合帧率驱动的任务如,动画序列,延时操作,分帧处理逻辑
- 如果在协程中执行一个耗时计算(如大循环),在它
yield返回之前,整个游戏依然会卡住
2. 协程详解
协程是Unity中常见的异步编程手段之一,核心是在主线程上实现代码的分段暂停与恢复执行,本质是基于C#迭代器的协作式多任务,并非真正的多线程,只有当协程部分主动让出来即yield return时才能够出协程,回到主线程,入欧哦协程执行的需要计算较多则可能产生卡死,详见
一、基础概念与本质
协程是一种可以主动暂停执行,并在指定时机自动恢复执行的函数,。运行在unity主线程上,通过yield return声明暂停点,由Unity引擎每帧调度去顶,适合处理需要分步执行。带等待逻辑的流程
常见误区:
- 协程全程运行在主线程,所有代码执行都在主线程的帧更新流程中;
- 如果协程内写了大量 CPU 密集计算,依然会卡住主线程;
- 它的 “异步” 是逻辑上的分段执行,而非线程层面的并行。
二、底层实现:C#迭代器+Unity调度器
协程的实现分为两层:C#语言成面的迭代器状态机,以及Unity引擎层面的帧调度器
C#迭代器
unity协程的返回值固定为IEnumerator,他是C#的迭代器接口,包含三个核心成员:
- Current:当前迭代返回对象(即yield return后面的返回的值)
- MoveNext():推进到下一个迭代位置,返回bool表示是否还会有后续的逻辑
- Rest():重置迭代器(几乎不用)
当在方法中写yield return时,C#编译器会自动生成一个状态机类
- 把方法内的diamagnetic按照yield return拆分成多个片段:
- 用状态号记录当前执行到哪个片段:
- 每次调用MoveNext()就从上次暂停的位置继续执行,直到遇到下一个yiled return 或者方法结束。
- 关键特性:调用协程方法时,方法体不会立刻执行,只会生成迭代器对象;第一次调用
MoveNext()才会真正开始执行代码,直到第一个yield return暂停。
| |
状态机伪代码展示
| |
Unity协程调度引擎
- unity引擎在主线程的每帧更新流程中,维护了所有的活跃写成的队列:
- 每帧到对应的时机(Update后、物理帧之后),unity遍历对应队列的协程,调用MoveNext();
- 执行后读取Current值,判断下一次恢复的时机:
- 如果是
null,则加入 “下一帧 Update 后恢复” 的队列; - 如果是
WaitForSeconds,则加入计时队列,时间到了再放回可执行队列; - 如果是另一个
Coroutine,则等待子协程执行完毕再恢复;
- 如果是
三、协程的生命周期与执行时机
WaitForFixedUpdate: 所有 FixedUpdate 执行完毕后恢复
yield return null :任意非指令对象Update 执行完后,立刻恢复协程
WaitForEndOfFrame:所有渲染、GUI 处理完成后,帧结束前恢复
四、常用 Yield 指令大全
yield return后接不同对象,代表不同的暂停恢复规则,是协程的核心语法。
1. 基础帧等待
yield return null;等待一帧之后执行
2.时间等待
yield return new WaitForSeconds(float seconds);等待指定游戏时间(秒),受Time.timeScale影响,游戏暂停(timeScale=0)时计时停止。yield return new WaitForSecondsRealtime(float seconds);等待真实时间,不受timeScale影响,适合暂停界面的倒计时等
3.条件等待
yield return new WaitUntil(Func<bool> predicate);一直等待,直到委托返回true才继续执行。``yield return new WaitWhile(Func<bool> predicate);一直等待,直到委托返回false才继续执行,和 WaitUntil 逻辑相反。
4. 嵌套协程等待
yield return StartCoroutine(OtherCoroutine());等待另一个协程完全执行完毕后,再恢复当前协程,用于拆分复杂流程。
5. 异步操作等待
yield return asyncOperation;等待 Unity 异步操作完成,常见场景:- 资源加载:
Resources.LoadAsync()、AssetBundle.LoadAssetAsync() - 场景加载:
SceneManager.LoadSceneAsync() - 网络请求:
UnityWebRequest.SendWebRequest()
- 资源加载:
6. 终止协程
yield break;手动终止协程,相当于普通函数的return,执行后协程直接结束,MoveNext()返回false。
五、协程的控制:启动与停止
1. 启动协程
StartCoroutine是MonoBehaviour的成员方法,只能在继承 MonoBehaviour 的类中调用,有两种重载:
推荐写法:传入迭代器对象
| |
- 优点:类型安全、编译检查、支持多参数、性能更好;
- 返回值
Coroutine对象可用于后续精准停止协程。
2. 停止协程
(1)精准停止单个协程(推荐)
保存StartCoroutine返回的Coroutine对象,停止时传入:
| |
(2)停止所有协程
| |
停止当前 MonoBehaviour 上所有正在运行的协程。
(3)常见停止误区
- 直接传入迭代器方法停止:
StopCoroutine(MyCoroutine())无效,因为每次调用方法都会生成新的迭代器对象,和运行中的不是同一个。 - 需要用Coroutine来记录开始时的写成才能够随时终止相关协程
- 依赖 MonoBehaviour 禁用停止:
enabled = false不会停止协程,协程会继续运行。 - 以下情况协程会自动停止:
- 挂载脚本的 GameObject 被设置为
SetActive(false); - MonoBehaviour 或 GameObject 被销毁(Destroy;
- 协程执行完毕或遇到
yield break。
- 挂载脚本的 GameObject 被设置为
注意事项:物体销毁后协程会报空
协程不会因为引用的对象销毁而自动停止,恢复执行时访问已销毁对象会报空引用异常。
- 解决方案:在协程内每次访问前判空,或在对象销毁前手动停止协程。
协程的上下文记录机制
核心本质:编译器吧写成函数翻译成了IEnumerator的状态机
编译器不会直接编译协程方法,而是会自动生成一个嵌套状态机类,所有的上下文全部都会存到这个类的字段中(内存开辟在堆内存中)
状态机详细机制:
(1)State
1.每一个yield return都会分配唯一的设置状态码state
- 初始调用前:state=-1
- 执行完第一个yield暂停:state=0;
- 执行完第二个yield暂停;state=1;
- 以此类推
- 全部执行完成之后:state=-2;
2.每次调用MoveNext(),内部用switch(state)直接跳转到上次暂停的代码
3.执行到下一个yield时更新state,MoveNext返回false函数逻辑临时中断,详见上文状态机伪代码关系
(2)协程字段
原方法的参数、局部变量、甚至循环迭代变量,全部被 “提升” 为状态机类的成员字段,存储在堆内存中。
普通方法:栈帧销毁, 变量消失。协程:状态机对象在堆上,只要对象不被 GC,变量值就一直存在
(3)外部引用
状态机里有一个 __this 字段,保存了原 MonoBehaviour 实例的引用。所以协程里可以直接访问类的字段、调用其他方法
本文由 2 篇笔记合并整理。