C#异步编程

C#异步编程核心笔记:从原理到Unity实践

一、核心概念辨析:彻底理清异步与多线程

1. 最本质的区别

  • 多线程:解决「谁来干活」的问题,用多个线程同时执行任务,充分利用CPU多核
  • 异步:解决「怎么等结果」的问题,不阻塞当前线程等待任务完成,提高资源利用率
  • 两者是完全正交的维度,不是包含或并列关系:单线程可以异步,多线程也可以同步

2. 执行模型层级关系

复制代码
并发(宏观上同时处理多个任务)
└── 多线程(用多个线程实现并发)
    └── 并行(多个CPU核心真·同时执行,是多线程的最优情况)
  • 并发:一个服务员同时接待多桌客人,快速切换
  • 并行:两个厨师同时炒菜
  • 同步:站在厨房门口等菜做好
  • 异步:告诉厨师炒菜,自己去干别的,做好了喊你

3. 任务类型与技术选型

任务类型 核心特点 Unity最佳实践
CPU密集型 占用大量CPU时间(A星、物理、数值计算) Job System + Burst 或 Task.Run
IO密集型 大部分时间在等待(资源加载、网络、文件读写) 只用async/await或协程,不要自己开线程

二、async/await底层原理:没有魔法,只有语法糖

1. await的本质(修正版)

await永远不会自动创建新线程,也不会自动切换线程。 它只做三件事:

  1. 立即检查await的Task是否已经完成
  2. 如果已经完成 :什么都不做,继续同步执行后面的代码,没有任何线程切换
  3. 如果未完成:把方法"拦腰斩断",将后面的代码打包成一个回调函数,注册到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都来源于第一阶段

  1. 同步阶段 :从方法开始,到第一个未完成的await 为止
    • 所有代码都在调用者线程上同步执行
    • 不会创建任何新线程,不会有任何线程切换
    • 如果这里有Thread.SleepTask.Wait等阻塞代码,会直接阻塞调用者线程
    • 即使中间有多个await,只要它们的Task都是已完成的,就会一直同步执行下去
  2. 异步阶段 :第一个未完成的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取消

六、核心原则总结(修正版)

  1. async/await只是语法糖,本身不会创建新线程,也不会自动切换线程
  2. 只有当await一个未完成状态的Task时,才会发生线程切换
  3. async方法中,从开始到第一个未完成的await为止,所有代码都是同步执行的
  4. await后代码的执行线程由同步上下文决定,Unity中一定会切回主线程
  5. 除非任务结果完全不重要,否则永远用await等待异步任务
  6. 子线程只做纯数据计算,所有Unity对象操作全部放到主线程
相关推荐
Xin_ye100861 小时前
C# 零基础到精通教程 - WPF 深度专题:自定义布局与性能优化
开发语言·c#·wpf
焚 城2 小时前
Winform双语实现
c#·winform
雪豹阿伟2 小时前
16.C# —— 委托,委托实例,多播委托,内置委托,泛型委托
c#·上位机
小满Autumn2 小时前
WPF 依赖属性速查手册
笔记·c#·wpf·上位机·mvvm
JaydenAI2 小时前
[MAF预定义ChatClient中间件-09]MessageInjectingChatClient-赋予工具消息注入的能力
ai·c#·agent·maf·ichatclient
Xin_ye100862 小时前
C# 零基础到精通教程 - WPF 深度专题:3D 图形与视觉增强
开发语言·c#·wpf
Chris _data4 小时前
并发单词频率统计器 - 从零到完整实现(C# 实战)
开发语言·c#
iCxhust14 小时前
C# 命令行指令 查看二进制文件
开发语言·单片机·嵌入式硬件·c#·proteus·微机原理·8088单板机