C#异步编程核心笔记:从原理到Unity实践
一、核心概念辨析:彻底理清异步与多线程
1. 最本质的区别
- 多线程:解决「谁来干活」的问题,用多个线程同时执行任务,充分利用CPU多核
- 异步:解决「怎么等结果」的问题,不阻塞当前线程等待任务完成,提高资源利用率
- 两者是完全正交的维度,不是包含或并列关系:单线程可以异步,多线程也可以同步
2. 执行模型层级关系
并发(宏观上同时处理多个任务)
└── 多线程(用多个线程实现并发)
└── 并行(多个CPU核心真·同时执行,是多线程的最优情况)
- 并发:一个服务员同时接待多桌客人,快速切换
- 并行:两个厨师同时炒菜
- 同步:站在厨房门口等菜做好
- 异步:告诉厨师炒菜,自己去干别的,做好了喊你
3. 任务类型与技术选型
| 任务类型 | 核心特点 | Unity最佳实践 |
|---|---|---|
| CPU密集型 | 占用大量CPU时间(A星、物理、数值计算) | Job System + Burst 或 Task.Run |
| IO密集型 | 大部分时间在等待(资源加载、网络、文件读写) | 只用async/await或协程,不要自己开线程 |
二、async/await底层原理:没有魔法,只有语法糖
1. await的本质(修正版)
await永远不会自动创建新线程,也不会自动切换线程。 它只做三件事:
- 立即检查await的
Task是否已经完成 - ✅ 如果已经完成 :什么都不做,继续同步执行后面的代码,没有任何线程切换
- ❌ 如果未完成:把方法"拦腰斩断",将后面的代码打包成一个回调函数,注册到Task上,然后立即返回,释放当前线程
核心修正 :线程切换不是await造成的,而是未完成的Task 造成的。只有当await一个未完成状态的Task时,才会发生线程切换。
2. 编译器生成的状态机
csharp
// 你写的代码
async Task LoadPathAsync()
{
var path = await Task.Run(() => AStar.Calculate(start, end));
unit.MoveAlongPath(path);
}
// 编译器生成的简化版
class LoadPathAsyncStateMachine
{
public Unit unit;
public Vector3 start, end;
private Task<Vector3[]> task;
public void MoveNext()
{
if (task == null)
{
task = Task.Run(() => AStar.Calculate(start, end));
// 只有当task未完成时,才会注册回调并返回
if (!task.IsCompleted)
{
task.ContinueWith(_ => SynchronizationContext.Current.Post(MoveNext));
return;
}
}
// 如果task已经完成,直接走到这里,同步执行
unit.MoveAlongPath(task.Result);
}
}
3. async方法的两个执行阶段(最容易踩坑,修正版)
所有async方法都严格分为两个阶段,90%的异步卡顿bug都来源于第一阶段
- 同步阶段 :从方法开始,到第一个未完成的await 为止
- 所有代码都在调用者线程上同步执行
- 不会创建任何新线程,不会有任何线程切换
- 如果这里有
Thread.Sleep、Task.Wait等阻塞代码,会直接阻塞调用者线程 - 即使中间有多个await,只要它们的Task都是已完成的,就会一直同步执行下去
- 异步阶段 :第一个未完成的await 之后的所有代码
- 代码被打包成回调,等待Task完成
- Task完成后,按同步上下文调度到对应线程执行
4. 反例1:第一个await是已完成的Task,全程同步执行
csharp
async Task FooAsync()
{
// 同步阶段开始
Thread.Sleep(2000); // 阻塞调用者线程2秒
Helper.PrintThreadId("Before"); // 线程1
// await一个已经完成的Task,不会切换线程
await Task.CompletedTask;
// 仍然在同步阶段!继续同步执行
Thread.Sleep(1000); // 继续阻塞调用者线程1秒
Helper.PrintThreadId("After"); // 还是线程1!
}
5. 反例2:只有遇到未完成的Task,才会进入异步阶段
csharp
async Task Main()
{
//初始为线程1,但是这个await并没有切换线程
await FooAsnyc();
}
async Task FooAsync()
{
// 同步阶段:线程1
Thread.Sleep(2000); // 阻塞主线程2秒
Helper.PrintThreadId("FooAsync @ Before"); // 线程1
// ✅ 第一个未完成的Task!从这里开始进入异步阶段
await Task.Delay(1000);
// 异步阶段:线程26
Helper.PrintThreadId("FooAsync @ After"); // 线程26
}
三、同步上下文:决定await后代码在哪里执行
1. 核心作用
同步上下文是一个抽象类,负责将回调函数调度到合适的线程执行。不同环境有不同的实现,这是异步行为差异的根本原因。
2. 不同环境的行为对比
| 环境 | 同步上下文实现 | await后的线程行为 |
|---|---|---|
| 控制台程序 | ThreadPoolSynchronizationContext | 随机跑到线程池的任意空闲线程 |
| Unity程序 | UnitySynchronizationContext | 一定会切回主线程 |
| 加了ConfigureAwait(false) | 忽略当前上下文 | 永远在线程池线程执行 |
3. Unity中的特殊行为
- Unity有全局唯一的自定义同步上下文,会强制将所有await后的回调调度到主线程的待执行队列
- 回调不会打断主线程正在执行的代码,只会在当前帧的固定时间点(渲染前)批量执行
- 这就是为什么Unity中await后可以安全调用Unity API的根本原因
4. ConfigureAwait(false)的正确使用
- 作用:忽略当前同步上下文,await后不切回原线程,减少一次线程切换开销
- Unity中使用场景:只有当await后的代码完全不访问任何Unity对象,只是纯数据计算时才能使用
- 绝对禁止:在主线程的await后不加区分地使用,否则会导致子线程调用Unity API崩溃
四、Unity异步开发最佳实践
1. 非主线程的铁律
所有会修改引擎状态的操作,都必须在主线程执行
- ❌ 绝对不能做:创建/修改/销毁Unity对象、UI操作、资源加载、物理操作、启动协程
- ✅ 可以做:纯数学计算、.NET标准库操作、Unity.Collections原生集合操作、Debug.Log(2022.3+)
2. 子线程安全回到主线程的两种方法
方法1:async/await自动切回(推荐)
csharp
async void CalculatePath()
{
// 子线程计算
var path = await Task.Run(() => AStar.Calculate(start, end));
// 自动切回主线程,安全调用Unity API
unit.MoveAlongPath(path);
}
方法2:通用主线程调度器
csharp
public class MainThreadDispatcher : MonoBehaviour
{
private static readonly ConcurrentQueue<Action> _actions = new ConcurrentQueue<Action>();
public static void Post(Action action) => _actions.Enqueue(action);
void Update()
{
while (_actions.TryDequeue(out var action))
action?.Invoke();
}
}
3. Unity资源加载的真相
- LoadSceneAsync/LoadAssetAsync不是单线程异步,而是混合模型
- 90%的工作(读磁盘、解压、解析)在Unity专门的后台加载线程执行
- 10%的工作(创建GameObject、上传GPU)在主线程分帧执行
- 永远不要自己开线程加载资源,Unity的内部线程池已经优化到极致
五、常见坑点与避坑指南
1. 不await异步方法(Fire and Forget)的三大致命风险
- 异常直接丢失:子线程抛出的异常会被.NET运行时吞掉,只会输出一个几乎看不见的警告
- 内存泄漏:如果异步方法持有已销毁的MonoBehaviour引用,完成后访问会导致空引用或内存泄漏
- 逻辑顺序不可控:多个后台任务修改同一数据会出现竞态条件,导致数据错乱
2. 安全的Fire and Forget写法
csharp
async void Start()
{
// 用下划线丢弃返回值,明确表示不等待
_ = SafeFireAndForgetAsync();
}
async Task SafeFireAndForgetAsync()
{
try
{
await Task.Delay(1000);
// 做一些不重要的事
}
catch (Exception e)
{
Debug.LogError($"后台任务失败:{e.Message}");
}
}
3. 其他常见坑
- ❌ 不要在async方法中使用
Thread.Sleep,用await Task.Delay代替 - ❌ 不要使用
Task.Wait()或.Result,会导致死锁和阻塞 - ❌ 不要写
async void方法,除非是Unity的事件回调(Start、Update、按钮点击) - ✅ 所有和Unity对象相关的异步任务,一定要在
OnDestroy时用CancellationToken取消
六、核心原则总结(修正版)
- async/await只是语法糖,本身不会创建新线程,也不会自动切换线程
- 只有当await一个未完成状态的Task时,才会发生线程切换
- async方法中,从开始到第一个未完成的await为止,所有代码都是同步执行的
- await后代码的执行线程由同步上下文决定,Unity中一定会切回主线程
- 除非任务结果完全不重要,否则永远用await等待异步任务
- 子线程只做纯数据计算,所有Unity对象操作全部放到主线程