元某人也元某人也

C# 异步、协程与多线程

协程、async/await 和原生线程的边界:什么时候用哪个,为什么不能用错。

本文目录

Unity 里“等一会儿再执行”有三种完全不同的手段:协程、async/await、原生线程。选错了不会报错,只会在某一帧卡住或者数据错乱。这篇把三者的边界讲清楚。

1. 关于C#原生多线程和异步加载

场景的异步加载

  1. SceneManager.LoadSceneAsync: 这是异步加载场景的核心入口。
    • 参数: 可以通过场景名称(string sceneName)或其在Build Settings中的索引(int sceneBuildIndex)来指定要加载的场景。
    • 模式: LoadSceneMode.Single(加载新场景并卸载当前所有场景)或 LoadSceneMode.Additive(在保留当前场景的基础上,附加加载新场景)。
    • 返回值: 该方法返回一个AsyncOperation对象,你可以通过它来跟踪加载进度(progress)并控制加载流程(如allowSceneActivation)。用于制作进度条
  2. 后台加载线程 (Application.backgroundLoadingPriority): 这是控制后台加载线程行为的关键属性。
    • Unity会在一个独立的后台线程中执行LoadSceneAsync等异步加载操作的数据读取部分。
    • 你可以通过设置Application.backgroundLoadingPriority来调整该线程的优先级,从而在加载速度和游戏性能之间取得平衡:
      • ThreadPriority.High:主线程每帧最多花费50ms处理加载数据,加载最快,但可能导致帧率下降。适合在显示加载进度条时使用。
      • ThreadPriority.Normal:主线程每帧最多花费10ms。
      • ThreadPriority.Low:主线程每帧最多花费2ms,对游戏性能影响最小,但加载最慢。适合在游戏进行中后台加载。
注意

SceneManager.LoadSceneAsync会卡90%,原因:

unity将进度明确分为两个明确阶段:

  • 阶段一(0 ~ 0.9):资源反序列化与预加载。后台线程把所有资源(纹理、网格、预制体)从存储介质载入内存,并完成所有依赖项的解析。这一步结束后,场景的所有原始数据已经“待命”在内存中。也就是场景完成了加载,只是在等待入场
  • allowSceneActivation 是 AsyncOperation 类上的一个 bool 类型属性。实现流程控制的核心思路是:将它当作一道“手动闸门”,默认关闭(false),待满足特定条件(如倒计时结束、玩家点击按钮、动画播放完毕)后,再将其打开(true)。把allowSceneActivation设为true的那一帧,主线程会立刻开始挤占大量CPU时间来完成阶段二
  • 阶段二(0.9 ~ 1.0,即最后的10%):场景对象实例化与初始化。这就是“激活”阶段,完全在主线程单帧内完成。(最消耗性能的部分)具体包括:
    1. 根据内存中的场景数据,在主线程逐帧创建所有GameObject。
    2. 构建父子层级(Transform父子关系)。
    3. 唤醒所有MonoBehaviour,并按顺序执行Awake()和OnEnable()回调。
    4. 执行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、TransformInternal C++ 对象
  • 一旦底层 C++ 物体被销毁(Destroy(go)、场景卸载),这个 ID 对应的底层内存就释放了,但你 C# 层的 go 变量不会立刻变成 null,只会变成无效伪句柄
野指针

Unity 所有引擎底层逻辑(渲染剔除、物理更新、骨骼计算、物体销毁)强制主线程执行,C++ 内部没有多线程安全锁。

场景:

  1. 主线程:正在执行渲染剔除,遍历所有 Transform,拿着 ID 查表获取 C++ 对象指针 0x123456,准备读取坐标;
  2. 子线程:同时通过同一个伪句柄 ID,查表拿到同一个 C++ 指针,直接修改 0x123456 里的位置;
竞争问题两种致命情况
  1. 内存撕裂 + 指针失效 主线程刚拿到 C++ 对象指针,子线程中途把物体 Destroy 掉,C++ 执行 delete 释放 0x123456 这块内存。 此时主线程手里的指针还保留 0x123456,但这块内存已经归还系统,变成野指针。主线程继续读写 → 直接 Crash。
  2. 多线程无锁并发读写同一 C++ 对象 主线程读 Transform 矩阵用于渲染,子线程同时写 position 修改矩阵,C++ 结构体半写半读,内存数据错乱,出现穿模、物体瞬移、骨骼乱飞,甚至内存布局破坏衍生野指针。

C#的原生多线程

应用场景

如果需要并行计算(如AI寻路、大量数值运算),可以使用C#的Task或Thread,但运算完毕后,必须通过主线程的UnityMainThreadDispatcher或StartCoroutine将结果赋给GameObject。

异步加载队列的“串行阻塞”机制与避坑

Unity的AsyncOperation底层维护着一个全局的后台请求队列(LoadQueue)。这个队列遵循严格的FIFO(先进先出) 顺序,并且是单线程串行处理(即一次只处理一个请求的解包阶段)。

为什么挂起会阻塞后续?

假设你有以下加载顺序:

  1. 请求加载“大型场景A”(allowSceneActivation = false)
  2. 请求加载“小型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暂停。
1
2
3
4
5
6
7
8
9
public interface IEnumerator
{
    // 前进到下一个元素/下一段协程逻辑,返回true代表还有后续代码要执行
    bool MoveNext();
    // 当前yield返回的等待对象(Unity专用:WaitForSeconds、null等)
    object Current { get; }
    // 重置状态(编译器生成的状态机基本不会用)
    void Reset();
}

状态机伪代码展示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private sealed class <Test>d__0 : IEnumerator
{
    public int <>1__state;	//状态码,用于精准跳转,-1为未开始第一次执行,正数为执行的步数,-2表示结束了
    public object <>2__current;	//yield return要进行的操作
    public MonoBehaviour <>4__this;	//保存外部脚本引用
    // 局部变量提升为堆字段
    public int num;

    public bool MoveNext()
    {
        switch (<>1__state)
        {
            // 初始状态:第一次执行
            case -1:
                <>1__state = 0;
                num = 1;
                // 第一个yield
                <>2__current = null;
                return true;	//返回值时true表示还有下文

            // 第一个yield恢复后,走到第二个yield
            case 0:
                <>1__state = 1;
                num++;
                <>2__current = new WaitForSeconds(1);
                return true;

            // 第二个yield恢复后,走到第三个yield
            case 1:
                <>1__state = 2;
                num *= 2;
                <>2__current = null;
                return true;

            // 第三个yield恢复后,执行收尾,协程结束
            case 2:
                <>1__state = -2;
                Debug.Log(num);
                return false;//返回值时false表示没有下文了

            default:
                return false;
        }
    }

    public object Current => <>2__current;
    public void Reset() { }
}

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 的类中调用,有两种重载:

推荐写法:传入迭代器对象
1
Coroutine coroutine = StartCoroutine(MyCoroutine(param1, param2));
  • 优点:类型安全、编译检查、支持多参数、性能更好;
  • 返回值Coroutine对象可用于后续精准停止协程。

2. 停止协程

(1)精准停止单个协程(推荐)

保存StartCoroutine返回的Coroutine对象,停止时传入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private Coroutine _myCoroutine;

void Start()
{
    _myCoroutine = StartCoroutine(MyCoroutine());
}

void Stop()
{
    if (_myCoroutine != null)
    {
        StopCoroutine(_myCoroutine);
        _myCoroutine = null;
    }
}
(2)停止所有协程
1
StopAllCoroutines();

停止当前 MonoBehaviour 上所有正在运行的协程。

(3)常见停止误区
  • 直接传入迭代器方法停止:StopCoroutine(MyCoroutine())无效,因为每次调用方法都会生成新的迭代器对象,和运行中的不是同一个。
  • 需要用Coroutine来记录开始时的写成才能够随时终止相关协程
  • 依赖 MonoBehaviour 禁用停止:enabled = false不会停止协程,协程会继续运行。
  • 以下情况协程会自动停止:
    • 挂载脚本的 GameObject 被设置为SetActive(false);
    • MonoBehaviour 或 GameObject 被销毁(Destroy;
    • 协程执行完毕或遇到yield break。

注意事项:物体销毁后协程会报空

协程不会因为引用的对象销毁而自动停止,恢复执行时访问已销毁对象会报空引用异常。

  • 解决方案:在协程内每次访问前判空,或在对象销毁前手动停止协程。

协程的上下文记录机制

核心本质:编译器吧写成函数翻译成了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 篇笔记合并整理。

仅用于交流学习,记录学习心得