Skip to content

异步与协程

Unity 协程是什么?本质是线程吗?

unity-coroutine-not-thread

Unity 协程不是线程,它本质上是一个 IEnumerator 状态机,由 Unity 在主线程的 PlayerLoop 中调度执行。

c
IEnumerator DoSomething()
{
    Debug.Log("开始");

    yield return new WaitForSeconds(1f);

    Debug.Log("1 秒后继续");
}

StartCoroutine(DoSomething());

协程遇到 yield return 会暂停,把执行权还给 Unity。

等下一帧、指定时间、条件满足或异步操作完成后,Unity 再继续调用它的 MoveNext(),从上次暂停的位置往下执行。

所以它更像是:

分帧执行的函数

而不是:

c
后台线程

重点区别:

  • 协程默认仍然跑在 Unity 主线程
  • 协程里可以访问大部分 Unity API
  • 协程不会自动让 CPU 密集计算变快
  • 协程里写死循环或大量计算,照样会卡主线程
  • 真正多线程要用 ThreadTaskJob System

比如这个仍然会卡:

c
IEnumerator HeavyWork()
{
    while (true)
    {
        DoVeryHeavyCalculation(); // 仍在主线程执行
        yield return null;
    }
}

协程适合做:

  • 延迟执行
  • 分帧处理
  • 等待动画、加载、网络请求
  • 控制流程,比如技能释放、剧情步骤、淡入淡出

面试收尾可以这样说:Unity 协程本质是基于 IEnumerator 的状态机调度机制,不是线程。它解决的是“流程拆分和等待”的问题,不解决“并行计算”的问题。

IEnumerator 在协程中怎么工作?

ienumerator-in-coroutine

在 Unity 协程里,IEnumerator 本质是一个“可暂停、可恢复”的状态机;Unity 通过反复调用它的 MoveNext() 来推进协程。

c
IEnumerator Foo()
{
    Debug.Log("A");

    yield return null;

    Debug.Log("B");

    yield return new WaitForSeconds(1f);

    Debug.Log("C");
}

大概执行过程是:

c
var e = Foo();

e.MoveNext(); // 执行到 yield return null,打印 A
              // e.Current = null

// 下一帧
e.MoveNext(); // 从上次位置继续,打印 B
              // e.Current = new WaitForSeconds(1f)

// 1 秒后
e.MoveNext(); // 打印 C,协程结束,返回 false

核心机制:

  • yield return 会把函数暂停
  • 编译器会把协程方法转换成状态机对象
  • 状态机内部保存“执行到哪了”和局部变量
  • MoveNext() 推进到下一个 yield
  • Current 保存这次 yield return 出来的等待对象
  • Unity 根据 Current 决定什么时候再次调用 MoveNext()

常见 yield return 含义:

c
yield return null;                    // 下一帧继续
yield return new WaitForSeconds(1f);   // 等 1 秒继续
yield return new WaitUntil(condition); // 条件满足继续
yield return asyncOperation;           // 异步操作完成继续
yield return StartCoroutine(Other());  // 等另一个协程结束

面试高分说法:IEnumerator 负责保存协程执行进度,yield return 负责把控制权交还给 Unity,Unity 的协程调度器再根据 Current 判断何时继续调用 MoveNext()。所以协程不是线程,而是主线程上的状态机调度。

yield return null 表示什么?

yield-return-null

yield return null 表示:当前协程暂停,等到下一帧再从这里后面继续执行。

c
IEnumerator Test()
{
    Debug.Log("A");

    yield return null;

    Debug.Log("B");
}

执行效果大概是:

c
当前帧:打印 A,然后暂停
下一帧:从 yield 后面继续,打印 B

底层一点说,协程方法会被编译器改写成 IEnumerator 状态机。执行到:

c
yield return null;

状态机会做类似这样的事:

c
current = null;   // Current 返回 null
state = next;     // 记录下次从哪里继续
return true;      // 告诉 Unity:协程还没结束

Unity 的协程调度器看到 Current == null,就理解为:这一帧先停,下一帧再继续调用 MoveNext()

它常用于分帧处理:

c
IEnumerator LoadStepByStep()
{
    InitPartA();
    yield return null;

    InitPartB();
    yield return null;

    InitPartC();
}

注意:yield return null 不是开新线程,也不是让后面的重计算自动变快。如果 yield return null 之前已经做了大量计算,那当前帧还是会卡。

面试高分说法:yield return null 本质上是把协程状态机的 Current 设为 null,让 Unity 在下一帧再次推进 MoveNext();它的作用是“让一帧”,不是“开启线程”。

WaitForSecondsWaitForSecondsRealtime 区别是什么?

waitforseconds-vs-realtime

WaitForSeconds 等的是Time.timeScale 影响的游戏时间WaitForSecondsRealtime 等的是不受 timeScale 影响的真实时间

c
yield return new WaitForSeconds(1f);         // 等 1 个游戏秒
yield return new WaitForSecondsRealtime(1f); // 等 1 个真实秒

底层理解:协程执行到 yield return waitObj 时,状态机的 Current 会变成这个等待对象,MoveNext() 返回 true。Unity 的协程调度器每帧检查这个等待条件,满足后再继续调用 MoveNext()

区别在于检查条件用的时间来源不同:

c
WaitForSeconds
// 看 scaled time
// 受 Time.timeScale 影响

WaitForSecondsRealtime
// 看 unscaled / real time
// 不受 Time.timeScale 影响

举例:

c
Time.timeScale = 0.5f;

yield return new WaitForSeconds(1f);
// 真实世界大概要等 2 秒

yield return new WaitForSecondsRealtime(1f);
// 真实世界还是大约等 1 秒

如果暂停游戏:

c
Time.timeScale = 0f;

那么:

  • WaitForSeconds 通常不会继续倒计时
  • WaitForSecondsRealtime 仍然会按真实时间继续

使用场景:

c
// 游戏内技能 CD、怪物攻击间隔、动画流程
yield return new WaitForSeconds(2f);

// 暂停菜单倒计时、真实时间提示、加载界面等待
yield return new WaitForSecondsRealtime(2f);

面试高分说法:它们都不是线程 sleep,而是协程状态机返回不同的等待指令给 Unity 调度器。WaitForSeconds 依赖缩放时间,所以受 timeScale 影响;WaitForSecondsRealtime 依赖真实时间,所以暂停游戏时也能继续等待。

参考:Unity 官方文档说明 WaitForSeconds 使用 scaled time,真实等待时间会受 Time.timeScale 影响;WaitForSecondsRealtime 使用 real/unscaled time。 来源:WaitForSecondsWaitForSecondsRealtime

协程什么时候停止?

when-coroutine-stops

协程停止的本质是:Unity 不再继续调度这个 IEnumerator,或者它的 MoveNext() 返回 false

常见停止时机:

  1. 自然执行结束
c
IEnumerator Foo()
{
    yield return null;
    Debug.Log("结束");
}
// 方法走到末尾,MoveNext() 返回 false,协程结束

也可以主动在协程内部结束:

c
IEnumerator Foo()
{
    if (hp <= 0)
        yield break;

    yield return null;
}
  1. 外部主动停止
c
Coroutine co = StartCoroutine(Foo());

StopCoroutine(co);

或者:

c
StopAllCoroutines();

注意:StopAllCoroutines() 只停止当前这个 MonoBehaviour 上启动的协程,不是全局停止所有协程。

  1. 宿主对象失效

Unity 官方文档里明确提到,协程也会在这些情况下停止:

c
gameObject.SetActive(false); // 脚本挂载的 GameObject 变 inactive

Destroy(this);               // MonoBehaviour 被销毁
Destroy(gameObject);         // GameObject 被销毁,组件也销毁

但这个点很容易答错:

c
this.enabled = false;

禁用 MonoBehaviour 组件本身,不会停止已经启动的协程。

底层原理可以这样说:

StartCoroutine 注册的是一个 IEnumerator 状态机。Unity 每次在合适时机调用它的 MoveNext()

c
bool hasNext = enumerator.MoveNext();

if (!hasNext)
{
    // 从协程调度列表中移除
}

如果你调用 StopCoroutine,本质就是 Unity 把这个协程从调度列表里移除,后面的协程代码不会继续执行。

面试高分表达:

协程不是线程,所以停止协程不是杀线程。它本质是停止推进一个 IEnumerator 状态机。自然结束时 MoveNext() 返回 false;手动 StopCoroutineStopAllCoroutines 会把它从 Unity 的调度队列移除;挂载的 GameObject inactive 或 MonoBehaviour 被 Destroy 也会停止,但单纯把组件 enabled = false 不会停止。

参考:Unity 官方协程手册和 StopCoroutine API 文档。 来源:Unity Coroutines ManualMonoBehaviour.StopCoroutine

async/await 和协程区别是什么?

async-await-vs-coroutine

协程 是 Unity 调度 IEnumerator 状态机;async/await 是 C# 调度异步状态机和 awaiter 的 continuation。两者都能“暂停/恢复”,但底层调度体系不同。

协程底层像这样:

c
IEnumerator e = Foo();

if (e.MoveNext())
{
    object waitObj = e.Current;
    // Unity 根据 waitObj 决定下一次什么时候 MoveNext()
}

async/await 底层更像这样:

c
var awaiter = task.GetAwaiter();

if (!awaiter.IsCompleted)
{
    awaiter.OnCompleted(MoveNext); // 注册后续逻辑
    return;
}

awaiter.GetResult(); // 完成后继续执行

核心区别:

对比点Unity 协程async/await
底层形态IEnumerator 状态机IAsyncStateMachine 异步状态机
推进者Unity PlayerLoopawaiter / Task / SynchronizationContext
等待对象yield return null / WaitForSeconds / AsyncOperationTask / Task<T> / ValueTask / Awaitable
返回值不方便直接返回天然支持 Task<T> / Awaitable<T>
异常不像 Task 那样自然传递给调用者await 时可重新抛出异常
适合场景分帧、延时、动画、游戏流程网络、文件、异步加载、需要返回值和组合任务
是否线程默认主线程也不等于开线程,Task.Run 才可能切后台

面试高分说法:

协程和 async/await 都是状态机,但协程的状态机由 Unity 的 PlayerLoop 根据 Current 推进;async/await 的状态机由 awaiter 在异步操作完成时恢复 continuation。协程更偏 Unity 帧流程控制,async/await 更偏通用异步编程、结果返回、异常传播和任务组合。

Unity 里要补一句:async/await 不代表一定能碰 Unity API。如果代码切到后台线程,就不能随便调用 Unity API;CPU 密集型并行计算更推荐 Job System。

参考:Microsoft async/awaitUnity CoroutinesUnity Awaitable

Unity 主线程限制是什么?

unity-main-thread-limit

Unity 主线程限制是:大多数 UnityEngine 对象和 API 只能在 Unity 主线程访问,后台线程不能直接操作 GameObject、Transform、Component、场景、资源加载回调里的 Unity 对象等。

底层原因不是 C# 不支持多线程,而是 Unity 引擎内部很多对象背后是 Native 对象,和场景树、渲染、物理、生命周期系统绑定在主线程的 PlayerLoop 里。它们不是普遍线程安全的,如果后台线程同时读写,可能出现竞态、崩溃或状态不一致。

典型错误:

c
Task.Run(() =>
{
    transform.position = Vector3.zero; // 错:后台线程访问 Unity API
    gameObject.SetActive(false);       // 错
});

正确思路是:后台线程只做纯数据工作,然后回到主线程应用结果。

c
var result = await Task.Run(() =>
{
    // 可以做纯 C# 计算、网络解析、文件处理
    return CalculatePathData();
});

// 回到主线程后再操作 Unity 对象
transform.position = result.TargetPosition;

在 Unity 里,后台线程适合做:

c
纯 C# 计算
JSON/二进制解析
网络请求后的数据处理
文件 IO
不碰 UnityEngine.Object 的算法逻辑

主线程才能安全做:

c
Instantiate / Destroy
GetComponent
transform.position
gameObject.SetActive
访问 Renderer / Animator / Rigidbody
修改 UI
调用大多数 UnityEngine API

面试高分说法:

Unity 的主线程限制本质是引擎对象线程安全问题。C# 的 Task 或 Thread 可以开后台线程,但后台线程只适合处理纯数据;一旦要读写 UnityEngine.Object,就要切回 Unity 主线程。协程默认在主线程,所以能访问 Unity API;Task.Run 默认不在主线程,所以不能直接访问。

补一句很加分:真正要做多核并行计算,Unity 推荐用 Job System + Burst + NativeContainer,因为它是 Unity 设计过线程安全边界的并行模型。

参考:Unity 官方文档说明大多数 Unity API 不是线程安全的,只能从主线程调用;Unity 通过 UnitySynchronizationContext 让 .NET Task continuation 默认回到主线程。 来源:Unity Awaitable continuations

子线程能不能操作 Unity API?

unity-subthread-api-limit

子线程不能直接操作大多数 Unity API,尤其是 GameObjectTransformComponentMonoBehaviourInstantiateDestroySetActive 这类和引擎对象相关的 API。

错误示例:

c
Task.Run(() =>
{
    transform.position = Vector3.zero; // 错:子线程操作 Unity 对象
    gameObject.SetActive(false);       // 错
});

底层原因:很多 Unity 对象在 C# 层只是一个托管包装,真正的数据在 Unity Native 引擎层。场景树、组件生命周期、渲染、物理等系统都在主线程的 PlayerLoop 中统一更新,不是普遍线程安全的。

子线程可以做的是纯数据工作

c
var result = await Task.Run(() =>
{
    // 可以:纯 C# 计算、寻路、排序、网络数据解析、文件处理
    return CalculatePathData();
});

// 回到主线程后再操作 Unity 对象
transform.position = result.TargetPosition;

注意不是 UnityEngine 命名空间里所有东西都绝对不能碰。像 Vector3Quaternion 这种普通值类型做数学计算通常没问题;真正要避开的是访问 UnityEngine.Object 背后的引擎对象。

面试高分说法:

子线程不能直接操作大多数 Unity API,本质是 Unity 引擎对象线程安全问题。后台线程可以处理纯数据,算完以后通过主线程队列、SynchronizationContextAwaitable.MainThreadAsync() 或其他调度方式切回主线程,再把结果应用到 GameObject 或组件上。

参考:Unity 官方说明大多数 Unity API 只能主线程调用,Unity 的 Task continuation 默认通过 UnitySynchronizationContext 回主线程。 来源:Unity Awaitable continuations

异步加载资源怎么做?

unity-async-resource-loading

Unity 异步加载资源一般是:发起 LoadAsync 请求,拿到 AsyncOperationAsyncOperationHandle,用协程、回调或 await 等待完成,完成后在主线程使用资源。

简单版可以用 Resources.LoadAsync

c
IEnumerator LoadEnemy()
{
    ResourceRequest request = Resources.LoadAsync<GameObject>("Prefabs/Enemy");

    yield return request;

    GameObject prefab = request.asset as GameObject;
    Instantiate(prefab);
}

正式项目更常见的是 Addressables:

c
private AsyncOperationHandle<GameObject> handle;

IEnumerator LoadEnemy()
{
    handle = Addressables.LoadAssetAsync<GameObject>("Enemy");

    yield return handle;

    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        GameObject prefab = handle.Result;
        Instantiate(prefab);
    }
}

void OnDestroy()
{
    if (handle.IsValid())
    {
        Addressables.Release(handle);
    }
}

底层理解:

c
LoadAsync()
    -> 返回一个加载请求句柄
    -> Unity 后续帧推进加载
    -> isDone / progress / completed 表示进度
    -> 完成后拿 asset / Result
    -> 回主线程 Instantiate 或赋值

要注意:异步加载不等于完全不卡。 它能减少同步加载时主线程长时间阻塞,但资源反序列化、依赖加载、实例化、Awake/OnEnable、贴图上传 GPU、Shader 首次使用等仍然可能造成卡顿。

面试高分说法:

我会优先用 Addressables 管正式项目资源,因为它能处理依赖、远程资源、引用计数和释放;底层上它也是返回异步操作句柄,加载完成后再在主线程使用资源。异步加载解决的是加载过程不要同步阻塞主线程,但还要配合预加载、分帧实例化、对象池和正确释放,才能真正减少卡顿和内存问题。

参考: Resources.LoadAsyncResourceRequestAddressables LoadAssetAsyncSceneManager.LoadSceneAsync

如何取消一个异步任务?

cancel-async-task

取消异步任务一般用 CancellationTokenSourceCancellationToken,本质是协作式取消Cancel() 只是发出取消信号,任务要主动检查 token 后自己退出。

c
private CancellationTokenSource _cts;

void Start()
{
    _cts = new CancellationTokenSource();
    _ = RunAsync(_cts.Token);
}

async Task RunAsync(CancellationToken token)
{
    try
    {
        await Task.Delay(3000, token); // 支持取消

        token.ThrowIfCancellationRequested();

        // 后续逻辑
    }
    catch (OperationCanceledException)
    {
        Debug.Log("任务被取消");
    }
}

void OnDestroy()
{
    _cts?.Cancel();
    _cts?.Dispose();
}

底层理解:

c
CancellationTokenSource.Cancel()

token.IsCancellationRequested = true

异步任务在 await / 循环中检查 token

主动 returnThrowIfCancellationRequested()

任务结束,状态可能是 Canceled

关键点:取消不是强杀线程。

c
_cts.Cancel();

这行代码不会把正在执行的线程粗暴杀掉,只是把 token 标记为“请求取消”。如果任务内部完全不检查 token,它可能继续跑完。

CPU 密集循环要自己检查:

c
async Task CalculateAsync(CancellationToken token)
{
    await Task.Run(() =>
    {
        for (int i = 0; i < 1000000; i++)
        {
            token.ThrowIfCancellationRequested();

            // 重计算
        }
    }, token);
}

超时取消可以用:

c
using var cts = new CancellationTokenSource();
cts.CancelAfter(3000); // 3 秒后自动请求取消

await SomeAsync(cts.Token);

Unity 里也可以用生命周期 token。较新的 Unity 有:

c
CancellationToken token = destroyCancellationToken;

它会在 MonoBehaviour 被销毁时触发取消。注意官方文档提醒:销毁前要先缓存这个 token。

面试高分说法:

C# 异步任务取消是 cooperative cancellation。调用方通过 CancellationTokenSource.Cancel() 发信号,任务通过 CancellationToken 观察这个信号,并在安全点清理资源后退出。真正让 Task 进入 Canceled 状态的常见方式是抛出带对应 token 的 OperationCanceledException,比如 token.ThrowIfCancellationRequested()

参考: Microsoft Cancellation in Managed ThreadsMicrosoft Task cancellationUnity destroyCancellationToken

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0