Appearance
异步与协程
Unity 协程是什么?本质是线程吗?
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 密集计算变快
- 协程里写死循环或大量计算,照样会卡主线程
- 真正多线程要用
Thread、Task、Job System等
比如这个仍然会卡:
c
IEnumerator HeavyWork()
{
while (true)
{
DoVeryHeavyCalculation(); // 仍在主线程执行
yield return null;
}
}协程适合做:
- 延迟执行
- 分帧处理
- 等待动画、加载、网络请求
- 控制流程,比如技能释放、剧情步骤、淡入淡出
面试收尾可以这样说:Unity 协程本质是基于 IEnumerator 的状态机调度机制,不是线程。它解决的是“流程拆分和等待”的问题,不解决“并行计算”的问题。
IEnumerator 在协程中怎么工作?
在 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()推进到下一个yieldCurrent保存这次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 表示:当前协程暂停,等到下一帧再从这里后面继续执行。
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();它的作用是“让一帧”,不是“开启线程”。
WaitForSeconds 和 WaitForSecondsRealtime 区别是什么?
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。 来源:WaitForSeconds、WaitForSecondsRealtime
协程什么时候停止?
协程停止的本质是:Unity 不再继续调度这个 IEnumerator,或者它的 MoveNext() 返回 false。
常见停止时机:
- 自然执行结束
c
IEnumerator Foo()
{
yield return null;
Debug.Log("结束");
}
// 方法走到末尾,MoveNext() 返回 false,协程结束也可以主动在协程内部结束:
c
IEnumerator Foo()
{
if (hp <= 0)
yield break;
yield return null;
}- 外部主动停止
c
Coroutine co = StartCoroutine(Foo());
StopCoroutine(co);或者:
c
StopAllCoroutines();注意:StopAllCoroutines() 只停止当前这个 MonoBehaviour 上启动的协程,不是全局停止所有协程。
- 宿主对象失效
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;手动 StopCoroutine 或 StopAllCoroutines 会把它从 Unity 的调度队列移除;挂载的 GameObject inactive 或 MonoBehaviour 被 Destroy 也会停止,但单纯把组件 enabled = false 不会停止。
参考:Unity 官方协程手册和 StopCoroutine API 文档。 来源:Unity Coroutines Manual、MonoBehaviour.StopCoroutine
async/await 和协程区别是什么?
协程 是 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 PlayerLoop | awaiter / Task / SynchronizationContext |
| 等待对象 | yield return null / WaitForSeconds / AsyncOperation | Task / 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/await、Unity Coroutines、Unity Awaitable
Unity 主线程限制是什么?
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 API,尤其是 GameObject、Transform、Component、MonoBehaviour、Instantiate、Destroy、SetActive 这类和引擎对象相关的 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 命名空间里所有东西都绝对不能碰。像 Vector3、Quaternion 这种普通值类型做数学计算通常没问题;真正要避开的是访问 UnityEngine.Object 背后的引擎对象。
面试高分说法:
子线程不能直接操作大多数 Unity API,本质是 Unity 引擎对象线程安全问题。后台线程可以处理纯数据,算完以后通过主线程队列、SynchronizationContext、Awaitable.MainThreadAsync() 或其他调度方式切回主线程,再把结果应用到 GameObject 或组件上。
参考:Unity 官方说明大多数 Unity API 只能主线程调用,Unity 的 Task continuation 默认通过 UnitySynchronizationContext 回主线程。 来源:Unity Awaitable continuations
异步加载资源怎么做?
Unity 异步加载资源一般是:发起 LoadAsync 请求,拿到 AsyncOperation 或 AsyncOperationHandle,用协程、回调或 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.LoadAsync、ResourceRequest、Addressables LoadAssetAsync、SceneManager.LoadSceneAsync
如何取消一个异步任务?
取消异步任务一般用 CancellationTokenSource 和 CancellationToken,本质是协作式取消: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
↓
主动 return 或 ThrowIfCancellationRequested()
↓
任务结束,状态可能是 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 Threads、Microsoft Task cancellation、Unity destroyCancellationToken