【.NET并发编程 – 04】 async/await 原理与性能优化:深入理解异步编程

04. async/await 原理与性能优化:深入理解异步编程

本章 GitHub 仓库csharp-concurrency-cookbook

欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。


本章导读

本文目标:深入理解 async/await 的编译器魔法,掌握性能优化技巧,写出高效的异步代码。

在上一章中,我们系统学习了 Task API 的核心方法和属性。本章将揭开 async/await 的神秘面纱,回答以下核心问题:

  • 为什么需要 async/await:.NET Core 为何主推异步编程?async/await 解决了什么问题?
  • 编译器魔法:async/await 背后发生了什么?状态机是如何工作的?
  • 线程真相:为什么说 async/await 不等于多线程?
  • 性能优化:ValueTask 解决了什么核心问题?为什么 .NET Core 源码大量使用它?
  • 常见陷阱:async void、死锁、async 传染性

重要提示:本文将深入编译器生成的代码,建议先掌握第 03 章的 Task API 基础。


0️⃣ 为什么 .NET Core 主推异步编程?

0.1 异步编程的演进:从地狱到天堂

在 async/await 出现之前,.NET 开发者是如何写异步代码的?让我们看看那段”黑暗”的历史。

场景:下载一个网页内容,解析 JSON,保存到数据库。

方式 1:Thread 手动管理(2005 年的噩梦)

public void DownloadAndSaveData(string url)
{
    // 创建新线程
    Thread thread = new Thread(() =>
    {
        try
        {
            // 1. 下载数据
            var client = new WebClient();
            string json = client.DownloadString(url);

            // 2. 解析 JSON
            var data = JsonSerializer.Deserialize<MyData>(json);

            // 3. 保存到数据库(又要新建一个线程?)
            Thread dbThread = new Thread(() =>
            {
                try
                {
                    // 数据库操作...
                    SaveToDatabase(data);

                    // 4. 更新 UI(必须回到 UI 线程!)
                    this.Invoke(new Action(() =>
                    {
                        MessageBox.Show("保存成功");
                    }));
                }
                catch (Exception ex)
                {
                    // 错误处理...
                    this.Invoke(new Action(() =>
                    {
                        MessageBox.Show($"数据库错误: {ex.Message}");
                    }));
                }
            });
            dbThread.Start();
        }
        catch (Exception ex)
        {
            // 错误处理...
            this.Invoke(new Action(() =>
            {
                MessageBox.Show($"下载错误: {ex.Message}");
            }));
        }
    });
    thread.Start();
}

问题一大堆

  • 回调地狱:嵌套 3-4 层,根本看不懂逻辑
  • 线程切换混乱:需要手动 Invoke 回到 UI 线程
  • 异常处理困难:每一层都要 try-catch
  • 资源泄漏风险:忘记 JoinDispose
  • 无法取消:如何取消一个正在执行的线程?
  • 性能差:每个操作都创建新线程(Thread 的栈内存是 1MB!)

方式 2:APM(Begin/End 模式,2010 年的改进)

public void DownloadAndSaveData(string url)
{
    var request = WebRequest.Create(url);

    // 开始异步请求
    request.BeginGetResponse(ar1 =>
    {
        try
        {
            var response = request.EndGetResponse(ar1);
            var stream = response.GetResponseStream();

            // 读取流
            var buffer = new byte[8192];
            stream.BeginRead(buffer, 0, buffer.Length, ar2 =>
            {
                try
                {
                    int bytesRead = stream.EndRead(ar2);
                    string json = Encoding.UTF8.GetString(buffer, 0, bytesRead);

                    // 解析 JSON
                    var data = JsonSerializer.Deserialize<MyData>(json);

                    // 保存到数据库...
                    // 又是一堆 Begin/End...
                }
                catch (Exception ex)
                {
                    // 错误处理...
                }
            }, null);
        }
        catch (Exception ex)
        {
            // 错误处理...
        }
    }, null);
}

仍然很痛苦

  • 回调地狱:Begin/End 套娃
  • 状态传递困难:需要通过 AsyncState 传递上下文
  • 代码割裂:逻辑被分成多个回调函数
  • 难以理解:新手根本看不懂

方式 3:async/await(2012 年的革命)

public async Task DownloadAndSaveDataAsync(string url)
{
    // 1. 下载数据
    using var client = new HttpClient();
    string json = await client.GetStringAsync(url);

    // 2. 解析 JSON
    var data = JsonSerializer.Deserialize<MyData>(json);

    // 3. 保存到数据库
    await SaveToDatabaseAsync(data);

    // 4. 更新 UI(自动回到 UI 线程!)
    MessageBox.Show("保存成功");
}

优雅得令人感动

  • 同步写法,异步执行:代码看起来像同步,但不阻塞线程
  • 自动上下文切换await 后自动回到 UI 线程
  • 异常处理简单:用普通的 try-catch 就行
  • 可取消:支持 CancellationToken(后面章节会讲)
  • 性能好:I/O 操作不占用线程

0.2 async/await 解决了什么核心问题?

通过上面的对比,我们可以看到 async/await 解决了三个核心问题:

1. 消灭回调地狱(Callback Hell)

问题:传统异步模型(APM、EAP)使用回调函数,导致代码嵌套深、难以理解。

解决:async/await 让你用同步的写法写异步代码,编译器帮你生成状态机。

// 回调地狱
BeginOp1(() => {
    BeginOp2(() => {
        BeginOp3(() => {
            //  嵌套 N 层
        });
    });
});

// async/await
await Op1Async();
await Op2Async();
await Op3Async();
//  线性代码,清晰易懂

2. 自动上下文管理

问题:手动管理线程切换(UI 线程、同步上下文)非常容易出错。

解决await 会自动捕获当前的 SynchronizationContext,并在任务完成后恢复到原来的上下文。

注意:关于 SynchronizationContextConfigureAwait,我们会在后面的章节(第 05 章)详细讲解。这里只需要知道:async/await 帮你自动处理了线程切换的复杂性。

3. 高效的 I/O 操作

问题:传统的同步 I/O 会阻塞线程,浪费资源;手动创建线程又开销太大。

解决:async/await 配合异步 I/O API(如 HttpClient.GetStringAsync),可以在 I/O 等待期间释放线程,不阻塞、不浪费。

这就是为什么 .NET Core 主推异步编程

  • ASP.NET Core:单台服务器可以处理更多并发请求(从 1000+ 到 10000+)
  • Blazor:UI 不会卡顿
  • 后台服务:更高的吞吐量

0.3 async/await 的本质

在深入原理之前,先记住这个核心概念:

async/await 只是语法糖,编译器会把你的异步方法转换成一个状态机。

这个状态机:

  • 记住了”当前执行到哪一步”
  • await 时”暂停”执行(但不阻塞线程)
  • 在任务完成后”恢复”执行

接下来,我们就来揭开这个”编译器魔法”的神秘面纱。


1️⃣ async/await 基础回顾

1.1 最简单的异步方法

现在你已经理解了 async/await 的价值,让我们从最简单的例子开始:
现在,让我们看看 async/await 是如何优雅地解决这个问题的:

public async Task<string> DownloadContentAsync(string url)
{
    using HttpClient client = new HttpClient();
    string content = await client.GetStringAsync(url);
    return content;
}

这段代码做了什么呢?它从指定的 URL 下载内容,但不会阻塞线程。让我们拆解一下:

  1. async 关键字:告诉编译器”这是一个异步方法,请帮我生成状态机”
  2. await 关键字:在这里等待下载完成,但不阻塞线程(线程会被释放去做其他事情)
  3. Task<string> 返回类型:表示这个方法会异步返回一个字符串
  4. Async 后缀:这是约定俗成的命名规范,一眼就能看出这是异步方法

与第 03 章的对比

// 第 03 章的方式:阻塞线程
public string DownloadContent(string url)
{
    using HttpClient client = new HttpClient();
    Task<string> task = client.GetStringAsync(url);
    return task.Result; // ️ 阻塞当前线程,浪费资源
}

// 现在的方式:不阻塞线程
public async Task<string> DownloadContentAsync(string url)
{
    using HttpClient client = new HttpClient();
    string content = await client.GetStringAsync(url); //  释放线程
    return content;
}

关键要素总结

  • async 关键字:标记方法为异步方法
  • await 关键字:异步等待一个 Task
  • 返回类型:Task<T>TaskValueTask<T>
  • 方法命名:通常以 Async 结尾

1.2 async/await 的三种返回类型

async 方法可以有三种返回类型(其实是四种,但有一种很危险):

返回类型 使用场景 示例 说明
Task 无返回值的异步方法 async Task SaveDataAsync() 类似于 void,但可以 await
Task<T> 有返回值的异步方法 async Task<int> GetCountAsync() 返回一个结果
ValueTask<T> 高性能场景(.NET Core 2.1+) async ValueTask<int> GetCachedAsync() 本章后面会详细讲
void ️ 仅用于事件处理器 async void Button_Click() 危险!异常无法捕获

警告async void 无法捕获异常,一旦出错就会导致应用崩溃!除了事件处理器(如按钮点击),永远不要使用 async void


1.3 async/await 的语义规则

让我们通过几个例子来理解 async/await 的行为:

//  错误示例:没有 await 的 async 方法
public async Task Method1()
{
    Console.WriteLine("没有 await");
    // ️ 编译器警告:CS1998: 此异步方法缺少 'await' 运算符
    // 这个 async 是没有意义的,应该去掉
}

//  正确示例:多个 await
public async Task Method2()
{
    await Task.Delay(1000);
    Console.WriteLine("第一个 await 完成");

    await Task.Delay(1000);
    Console.WriteLine("第二个 await 完成");
}

核心行为await 会暂停当前方法的执行,但不会阻塞线程。

想象一下,你在餐厅点了一杯咖啡:

  • 同步阻塞task.Result):你站在柜台前死死盯着咖啡师,什么都不做,直到咖啡做好 ⏳
  • async/await:你点完咖啡后去找个座位坐下,咖啡师做好后会叫你。这期间你可以玩手机、看书
// await 可以在任何位置
public async Task<int> Method3()
{
    // 1. 启动任务(不等待)
    var task = Task.Run(() => 42);
    Console.WriteLine("任务已启动,我可以先做点别的事");

    // 2. 现在等待任务完成
    int result = await task;
    Console.WriteLine($"任务完成,结果是: {result}");

    return result;
}

核心规则总结

  • async 方法必须包含至少一个 await(否则编译器会警告)
  • await 会暂停当前方法的执行(但不阻塞线程)
  • await 之后的代码会在 awaited 任务完成后继续执行
  • await 不会阻塞线程(对于 I/O 操作)——这是最重要的!

2️⃣ 编译器魔法:状态机揭秘

现在是本章最精彩的部分!你知道吗?async/await 只是语法糖,编译器会把你的异步方法转换成一个”状态机”。

听起来很复杂?别担心,让我们一步步揭开这个魔法的面纱。


2.1 一个简单的 async 方法

先看一个最简单的例子:

public async Task<string> GetDataAsync()
{
    Console.WriteLine("开始");
    await Task.Delay(1000);
    Console.WriteLine("完成");
    return "Data";
}

这个方法做了什么?

  1. 打印”开始”
  2. 等待 1 秒(await Task.Delay(1000)
  3. 打印”完成”
  4. 返回字符串 “Data”

看起来很简单,对吧?但编译器在背后做了大量的工作!


2.2 编译器生成的状态机(简化版)

当你编译上面的代码时,C# 编译器会把它转换成一个”状态机”。什么是状态机?就是一个用 switch-case 实现的、能够记住当前执行到哪一步的对象。

让我们看看编译器生成的代码(简化版,方便理解):

// 编译器生成的状态机(简化版)
[CompilerGenerated]
struct GetDataAsyncStateMachine : IAsyncStateMachine
{
    public int State;                               // 当前状态(0 或 1)
    public AsyncTaskMethodBuilder<string> Builder;  // 用于创建和完成 Task

    private TaskAwaiter Awaiter;                    // 保存 awaiter

    public void MoveNext()
    {
        try
        {
            switch (State)
            {
                case 0: // 初始状态
                    Console.WriteLine("开始");

                    // 创建 awaiter
                    Awaiter = Task.Delay(1000).GetAwaiter();

                    if (Awaiter.IsCompleted)
                    {
                        // 同步完成(快速路径)
                        goto case 1;
                    }
                    else
                    {
                        // 异步完成(慢速路径)
                        State = 1; // 记住下次从状态 1 开始
                        Awaiter.OnCompleted(MoveNext); // 注册回调
                        return; // ⭐ 暂停执行,释放线程
                    }

                case 1: // await 完成后的状态
                    Awaiter.GetResult(); // 获取结果或抛出异常
                    Console.WriteLine("完成");

                    // 设置结果
                    Builder.SetResult("Data");
                    return;
            }
        }
        catch (Exception ex)
        {
            Builder.SetException(ex);
        }
    }
}

看懂了吗?让我们拆解一下:

  1. State 字段:记录当前执行到哪个 await(0 表示第一个 await 之前,1 表示第一个 await 之后)
  2. MoveNext 方法:状态机的核心,用 switch-case 实现状态转换
  3. Awaiter 字段:保存每个 await 的 awaiter(用于恢复执行)
  4. AsyncTaskMethodBuilder:负责创建和完成 Task

关键点:当 await 的任务还没完成时,状态机会:

  1. 记录当前状态(State = 1
  2. 注册一个回调(Awaiter.OnCompleted(MoveNext)
  3. 立即返回(释放线程)⭐

当任务完成后,回调会被调用,状态机会从上次的状态继续执行。


2.3 状态机的执行流程(可视化)

让我们用一张流程图来理解状态机的执行过程:

flowchart TD Start([调用 GetDataAsync]) –> CreateSM[创建状态机<br/>State = 0] CreateSM –> CallMoveNext[调用 MoveNext] CallMoveNext –> Case0{State = 0} Case0 –>|初始状态| Print1[Console.WriteLine开始] Print1 –> CreateAwaiter[创建 Task.Delay1000.GetAwaiter] CreateAwaiter –> CheckComplete{Awaiter.IsCompleted?} CheckComplete –>|是 同步完成| SyncPath[同步路径<br/>直接跳到 case 1] SyncPath –> Print2[Console.WriteLine完成] Print2 –> SetResult[Builder.SetResultData] SetResult –> ReturnTask[返回已完成的 Task] CheckComplete –>|否 异步完成| AsyncPath[异步路径] AsyncPath –> SetState1[State = 1] SetState1 –> RegisterCallback[Awaiter.OnCompletedMoveNext] RegisterCallback –> ReturnIncomplete[返回未完成的 Task<br/>释放线程] ReturnIncomplete -.等待.-> DelayComplete[Task.Delay 完成] DelayComplete –> CallMoveNext2[调用 MoveNext<br/>可能在不同线程] CallMoveNext2 –> Case1{State = 1} Case1 –> GetResult[Awaiter.GetResult] GetResult –> Print2 style Start fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ReturnTask fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style SyncPath fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style AsyncPath fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style ReturnIncomplete fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style DelayComplete fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px

流程说明(重点理解)

状态机有两条路径:

1. 同步路径(快速路径)

Awaiter.IsCompleted == true 时(任务已经完成了):

  • 直接跳到下一个状态,继续执行
  • 不会释放线程,不会有线程切换
  • 非常快!无需等待

什么时候会走同步路径?

  • 从缓存中获取数据(立即返回)
  • 读取已经在内存中的数据
  • 任务在 await 之前就已经完成了
// 同步路径示例
public async Task<int> GetCachedValueAsync(int key)
{
    if (_cache.TryGetValue(key, out int value))
    {
        return value; //  同步返回,无状态切换
    }

    // 缓存未命中,才走异步路径
    return await _database.GetAsync(key);
}

2. 异步路径(慢速路径)

Awaiter.IsCompleted == false 时(任务还没完成):

  1. 记录当前状态(State = 1
  2. 注册回调(Awaiter.OnCompleted(MoveNext)
  3. 立即返回,释放线程
  4. 等待任务完成…
  5. 任务完成后,回调 MoveNext(可能在不同的线程上)
  6. 从上次的状态继续执行

这就是 async/await 的魔法所在:在等待期间,线程被释放了,可以去处理其他请求!


2.4 亲自查看编译器生成的代码

想看看编译器实际生成的代码吗?用 SharpLab 这个神器!

https://sharplab.io/

步骤

  1. 打开 SharpLab
  2. 输入你的 async 方法:
using System;
using System.Threading.Tasks;

public class C {
    public async Task<string> M() {
        await Task.Delay(1000);
        return "Hello";
    }
}
  1. 选择 “C# -> C#”(查看反编译后的代码)
  2. 你会看到编译器生成的完整状态机代码!

生成的代码特点

  • 状态机结构体(值类型,避免堆分配)
  • AsyncTaskMethodBuilder(管理 Task 的生命周期)
  • MoveNext 方法(switch-case 状态转换)
  • SetStateMachine 方法(用于装箱场景)

小提示:实际生成的代码比我们简化版复杂得多,但核心思想是一样的——用状态机实现”暂停”和”恢复”。

生成的代码特点

  • 状态机结构体(值类型,避免堆分配)
  • AsyncTaskMethodBuilder(管理 Task 的生命周期)
  • MoveNext 方法(switch-case 状态转换)
  • SetStateMachine 方法(用于装箱场景)

3️⃣ 为什么 async/await 不等于多线程?

这是一个超级重要的概念!很多开发者(包括我刚开始)都以为:

“加了 async,就会创建新线程,所以就能提升性能”

大错特错!

让我们彻底搞清楚这个问题。


3.1 常见误解(90% 的人都犯过)

错误认知

  • “加了 async 就会创建新线程”
  • await 会在新线程上执行”
  • “async 方法总是并发执行的”
  • “async 就是用来提升性能的”

真相(记住这些!)

  • async/await 只是语法糖,生成状态机(前面刚讲过)
  • I/O 异步操作不占用线程(使用操作系统的 I/O 完成端口,后面会讲)
  • CPU 密集型操作仍然需要 Task.Run 创建线程
  • async/await 的目的是提高吞吐量,而不是降低延迟

回顾:在第 02 章《Thread、ThreadPool 与 Task》中,我们讲过 Task 的本质——它是一个异步操作的抽象,不等于线程。现在我们更进一步,理解 async/await 的本质。


3.2 I/O 异步:不占用线程(重点)

让我们做个实验,观察 await 前后的线程 ID:

public async Task<string> DownloadAsync(string url)
{
    Console.WriteLine($"[Before await] 线程 ID: {Thread.CurrentThread.ManagedThreadId}");

    using HttpClient client = new HttpClient();
    string content = await client.GetStringAsync(url); // I/O 操作

    Console.WriteLine($"[After await] 线程 ID: {Thread.CurrentThread.ManagedThreadId}");

    return content;
}

运行结果

[Before await] 线程 ID: 1
[After await] 线程 ID: 4  <-- 可能是不同的线程!

这说明了什么?

  1. await 之前和之后的线程 ID 可能不同(也可能相同,取决于线程池的调度)
  2. await 期间,原来的线程被释放了(回到线程池,去处理其他请求)
  3. 网络请求由操作系统的 I/O 完成端口(IOCP)处理,不需要线程傻等
  4. 任务完成后,从线程池取一个线程继续执行

关键问题:那在 await 期间,谁在等待网络响应呢?

答案:操作系统的 I/O 完成端口(IOCP)!这是操作系统级别的机制,不需要线程

想象一下,你在餐厅点了外卖:

  • 同步阻塞方式:你站在门口死死盯着外卖员,直到他到了为止 ⏳(浪费时间)
  • async/await 方式:你点完外卖后继续做其他事(工作、看书),外卖到了会收到通知 (高效)

3.3 线程使用对比(可视化)

让我们用一张时序图来对比同步阻塞和 async/await 的线程使用:

sequenceDiagram participant App as 应用代码 participant TP as ThreadPool participant T1 as 线程 #1 participant IOCP as I/O 完成端口 participant T2 as 线程 #2 participant Network as 网络 Note over App,Network: 场景 1: 同步阻塞 .Result App->>TP: 请求线程 TP->>T1: 分配线程 #1 activate T1 T1->>Network: 发送 HTTP 请求 Note right of T1: ️ 线程 #1 被阻塞<br/>什么都不做<br/>浪费资源 Network–>>T1: 返回响应 T1->>App: 返回结果 deactivate T1 Note over App,Network: 场景 2: async/await App->>TP: 请求线程 TP->>T1: 分配线程 #1 activate T1 T1->>IOCP: 注册异步 I/O<br/>提交请求 T1->>TP: 归还线程 #1<br/>⭐ 线程被释放 deactivate T1 IOCP->>Network: 发送 HTTP 请求 Note right of IOCP: 无线程等待<br/>节省资源 Network–>>IOCP: 返回响应 IOCP->>TP: I/O 完成通知 TP->>T2: 分配线程 #2<br/>可能是不同的线程 activate T2 T2->>App: 继续执行 await 后的代码 deactivate T2

对比总结(一目了然)

特性 同步阻塞(.Result) async/await
线程使用 1 个线程全程阻塞 0 个线程等待(I/O 期间)
资源消耗 高(线程 + 1MB 栈内存) 低(无线程等待)
吞吐量 低(线程池耗尽) 高(线程可处理其他请求)
适用场景 控制台应用、脚本 Web API、UI 应用 ⭐

举个实际例子

假设你的 Web API 有 100 个线程,同时收到 200 个请求:

  • 同步阻塞方式:前 100 个请求占满所有线程,后 100 个请求排队等待(用户体验差)
  • async/await 方式:100 个线程可以处理 200 个请求(在等待数据库、网络响应时释放线程)

这就是为什么 ASP.NET Core 建议所有 I/O 操作都用 async/await!


3.4 CPU 密集型:需要 Task.Run

前面说了,I/O 操作直接 await 就好。但如果是 CPU 密集型操作呢?

CPU 密集型操作:大量计算、图像处理、数据分析等(在第 01 章《并发编程全景图》中我们讲过)。

//  错误:async 不会自动创建线程
public async Task<int> CalculatePrimesAsync(int max)
{
    // ️ 这段代码仍然在当前线程上执行!
    int count = 0;
    for (int i = 2; i <= max; i++)
    {
        if (IsPrime(i)) count++;
    }
    return count; // 编译器警告:CS1998(没有 await)
}

为什么是错误的?

  • 虽然方法名叫 xxxAsync,但实际上是同步执行
  • 会阻塞当前线程(可能是 UI 线程或 Web API 的请求线程)
  • 没有任何异步的好处

正确做法

//  正确:使用 Task.Run 创建线程
public async Task<int> CalculatePrimesAsync(int max)
{
    return await Task.Run(() =>
    {
        int count = 0;
        for (int i = 2; i <= max; i++)
        {
            if (IsPrime(i)) count++;
        }
        return count;
    });
}

为什么是正确的?

  • Task.Run 会把任务放到线程池执行(在第 03 章《Task API 完全指南》中讲过)
  • 不会阻塞当前线程
  • 真正的异步执行

关键规则总结

  • I/O 操作:直接 await(如网络、文件、数据库)
  • CPU 密集型:使用 Task.Run 放到线程池执行
  • 避免await Task.Run(async () => await ...)(双重异步,没必要)
//  错误:I/O 操作不需要 Task.Run
public async Task<string> GetDataAsync()
{
    return await Task.Run(async () =>
    {
        using HttpClient client = new HttpClient();
        return await client.GetStringAsync("https://api.example.com");
    }); // 多此一举!浪费了一个线程
}

//  正确:I/O 操作直接 await
public async Task<string> GetDataAsync()
{
    using HttpClient client = new HttpClient();
    return await client.GetStringAsync("https://api.example.com");
}

4️⃣ 性能优化:ValueTask 的核心价值

现在进入性能优化的核心部分。你可能会问:既然有了 Task,为什么微软还要创造一个 ValueTask?它解决了 Task 解决不了的什么问题?


4.1 Task 的性能瓶颈

场景:高频调用的异步方法(如缓存访问、数据库查询)。

让我们看一个典型案例:

// 使用 Task<T>
public async Task<User?> GetUserAsync(int userId)
{
    // 1. 先查内存缓存
    if (_memoryCache.TryGetValue(userId, out User? cachedUser))
    {
        return cachedUser; // ️ 同步返回,但会创建 Task<User> 对象
    }

    // 2. 缓存未命中,查数据库
    return await _database.GetUserAsync(userId);
}

问题分析

即使缓存命中了(90% 的情况),编译器也会创建一个 Task<User> 对象:

// 编译器生成的代码(简化)
if (_memoryCache.TryGetValue(userId, out User? cachedUser))
{
    return Task.FromResult(cachedUser); //  堆分配!
}

为什么是问题?

  1. Task 是引用类型(class) :每次创建都在堆上分配内存
  2. 高频调用场景:假设每秒 10,000 次请求,90% 缓存命中率
    • 每秒分配 9,000 个 Task 对象
    • 假设每个 Task 对象 48 字节:9,000 × 48 = 432 KB/秒
    • 每秒约 432 KB 的垃圾
  3. GC 压力:频繁的分配导致 Gen0 GC 频繁触发

对于高性能场景(Web API、游戏服务器、金融系统),这是不可接受的。


4.2 ValueTask 的核心创新

核心思想:既然大部分情况下是同步完成的,为什么不用值类型(struct)来避免堆分配呢?

// 使用 ValueTask<T>
public async ValueTask<User?> GetUserAsync(int userId)
{
    // 1. 先查内存缓存
    if (_memoryCache.TryGetValue(userId, out User? cachedUser))
    {
        return cachedUser; //  无堆分配!值直接包装在 struct 中
    }

    // 2. 缓存未命中,查数据库
    return await _database.GetUserAsync(userId);
}

ValueTask 的魔法

// ValueTask<T> 的简化实现
public readonly struct ValueTask<T>
{
    private readonly T _result;        // 同步完成时的结果
    private readonly Task<T>? _task;   // 异步完成时的 Task

    // 同步完成:直接包装结果
    public ValueTask(T result)
    {
        _result = result;
        _task = null; // 无 Task 分配
    }

    // 异步完成:包装 Task
    public ValueTask(Task<T> task)
    {
        _result = default!;
        _task = task;
    }
}

工作原理

  • 缓存命中(同步):值直接存储在 ValueTask<T>_result 字段中,无堆分配
  • 缓存未命中(异步):内部包装一个 Task<T>,行为和 Task 一样

4.3 .NET Core 源码中的 ValueTask 实战

微软在 .NET Core 的核心库中大量使用 ValueTask。让我们看几个真实案例:

案例 1:Stream.ReadAsync(最典型的例子)

问题:在 .NET Framework 时代,Stream.ReadAsync 返回 Task<int>

// .NET Framework (旧版本)
public virtual Task<int> ReadAsync(byte[] buffer, int offset, int count)
{
    // 即使缓冲区有数据(同步完成),也要创建 Task
    return Task.FromResult(bytesRead); //  堆分配
}

优化:.NET Core 改用 ValueTask<int>

源码位置System.IO.Stream(.NET Core 3.0+)

// .NET Core 3.0+ (简化)
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
    // 1. 如果缓冲区有数据,同步返回
    if (_bufferCount > 0)
    {
        int bytesRead = Math.Min(buffer.Length, _bufferCount);
        _buffer.AsSpan(0, bytesRead).CopyTo(buffer.Span);
        _bufferCount -= bytesRead;
        return new ValueTask<int>(bytesRead); //  无分配
    }

    // 2. 缓冲区为空,异步从底层流读取
    return new ValueTask<int>(ReadFromStreamAsync(buffer, cancellationToken));
}

性能提升

  • 同步路径(缓冲区有数据):0 字节分配
  • 异步路径(需要 I/O):仍然分配 Task,但这种情况相对少

实际数据(微软的 Benchmark):

  • 使用 Task<int>:平均 45 ns,48 字节分配
  • 使用 ValueTask<int>:平均 12 ns,0 字节分配
  • 性能提升 3.7x

案例 2:ASP.NET Core 管道

源码位置Microsoft.AspNetCore.Http.HttpContext

// ASP.NET Core 管道中的 ValueTask 使用(简化)
public abstract class HttpContext
{
    // Response.WriteAsync 返回 ValueTask
    public abstract ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default);
}

// 实现(Kestrel 服务器)
internal sealed class DefaultHttpResponse : HttpResponse
{
    public override ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
    {
        // 1. 如果输出缓冲区未满,同步写入
        if (_outputBuffer.TryWrite(data))
        {
            return default; //  ValueTask.CompletedTask,无分配
        }

        // 2. 缓冲区满,异步刷新并写入
        return FlushAndWriteAsync(data, cancellationToken);
    }
}

为什么重要?

ASP.NET Core 每个请求可能调用 WriteAsync 数十次。如果用 Task,每次都分配对象;用 ValueTask,大部分情况无分配。

实际效果

  • 单个请求节省数百字节的分配
  • 高并发场景(10,000 请求/秒):节省 MB 级别的分配
  • GC 压力显著降低

案例 3:Socket.ReceiveAsync

源码位置System.Net.Sockets.Socket

// .NET 5+ (简化)
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default)
{
    // 1. 如果接收缓冲区有数据,同步返回
    if (_receiveBuffer.Available > 0)
    {
        int bytesRead = _receiveBuffer.Read(buffer.Span);
        return new ValueTask<int>(bytesRead); //  无分配
    }

    // 2. 缓冲区为空,异步等待数据
    return ReceiveAsyncCore(buffer, socketFlags, cancellationToken);
}

为什么用 ValueTask?

网络数据包通常是小块的(几十到几百字节),如果每次都分配 Task,GC 压力会非常大。


4.4 何时使用 ValueTask?(决策树)

根据 .NET 团队的指导和源码实践,我们可以总结出以下决策树:

flowchart TD Start([需要异步方法]) –> Q1{“高频调用?<br>每秒 > 1000 次”} Q1 –>|否| UseTask[使用 Task] Q1 –>|是| Q2{“同步完成率?”} Q2 –>|小于 50%| UseTask Q2 –>|大于等于 50%| Q3{“是库代码?”} Q3 –>|否 应用代码| UseTask2[“使用 Task<br>避免过度优化”] Q3 –>|是 库代码| UseValueTask[“使用 ValueTask”] UseValueTask –> Rules[遵守使用限制] style Start fill:#bbdefb style UseTask fill:#c8e6c9 style UseTask2 fill:#fff9c4 style UseValueTask fill:#a5d6a7

使用 ValueTask 的条件

  • 高频调用(每秒 > 1000 次)
  • 同步完成率高(>= 50%)
  • 库代码或高性能场景

使用 Task 的场景

  • 普通应用代码
  • 低频调用
  • 异步完成为主

4.5 ValueTask 的使用限制(️ 重要)

ValueTask 虽然高效,但有严格的使用限制:

限制 1:只能 await 一次

//  错误:多次 await
ValueTask<int> task = GetValueAsync();
int result1 = await task;
int result2 = await task; //  未定义行为!

原因:ValueTask 可能复用内部状态(如池化的 Task),第二次 await 可能拿到错误的结果。

限制 2:不能同时 await

//  错误:多个并发 await
ValueTask<int> task = GetValueAsync();
Task.Run(async () => await task);
Task.Run(async () => await task); //  竞态条件!

限制 3:不能阻塞获取结果

//  错误:同步阻塞
ValueTask<int> task = GetValueAsync();
int result = task.Result; //  可能抛出异常或死锁

正确做法

//  正确:立即 await,用完即弃
int result = await GetValueAsync();

记忆口诀:ValueTask 是”一次性”的,就像纸巾,用完就扔,不能重复使用。


4.6 性能对比(Benchmark 数据)

以下是真实的 Benchmark 数据(BenchmarkDotNet):

场景 Task ValueTask 提升
同步完成 45.2 ns 12.3 ns 3.7x
异步完成 120.5 ns 125.3 ns -4%
内存分配(同步) 48 B 0 B 100%
内存分配(异步) 48 B 48 B 0%

结论

  • 同步路径:ValueTask 有巨大优势(3.7x 速度,0 分配)
  • 异步路径:性能相当(略慢 4%,可忽略)

适用场景

  • 缓存命中率 >= 50%
  • 高频调用(每秒 > 1000 次)
  • 对 GC 压力敏感的场景

4.7 总结:ValueTask 的核心价值

核心问题:Task 在高频同步完成场景下会产生大量堆分配。

解决方案:ValueTask 用值类型(struct)避免同步路径的堆分配。

实际应用:.NET Core 核心库(Stream、Socket、ASP.NET Core)大量使用。

使用原则

  • 库代码 + 高频 + 高缓存命中率 → ValueTask
  • 应用代码 + 普通场景 → Task(避免过度优化)

记住限制

  • ️ 只能 await 一次
  • ️ 不能并发 await
  • ️ 不能阻塞获取结果

相关章节:关于 SynchronizationContextConfigureAwait 的详细内容,会在后续章节中详细讲解。


5️⃣ 常见陷阱与最佳实践

现在让我们看看 async/await 的常见陷阱,以及如何避免它们。


5.1 陷阱 1:async void(危险!)

问题async void 方法的异常无法被捕获!

//  危险:async void
public async void ProcessDataAsync()
{
    await Task.Delay(100);
    throw new Exception("Boom!"); //  无法捕获,程序崩溃!
}

// 调用方
try
{
    ProcessDataAsync(); // ️ 立即返回,不等待
}
catch (Exception ex)
{
    //  永远不会捕获到异常!
}

为什么危险?

  1. 异常会导致程序崩溃(未处理异常)
  2. 无法 await(调用方不知道何时完成)
  3. 调试困难

正确做法

//  正确:返回 Task
public async Task ProcessDataAsync()
{
    await Task.Delay(100);
    throw new Exception("Boom!");
}

// 调用方
try
{
    await ProcessDataAsync(); //  可以捕获异常
}
catch (Exception ex)
{
    Console.WriteLine($"捕获到异常: {ex.Message}");
}

唯一的例外:事件处理程序(因为事件签名要求 void

//  可以接受:事件处理程序
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessDataAsync();
    }
    catch (Exception ex)
    {
        //  在方法内部处理异常
        MessageBox.Show($"Error: {ex.Message}");
    }
}

记住

  • 永远不要在普通方法中使用 async void
  • 事件处理程序可以用 async void,但必须内部处理异常

5.2 陷阱 2:过度使用 async/await

有时候,async/await 并不是必需的,反而会增加不必要的开销。

问题:不必要的 async/await

//  错误:不必要的 async/await
public async Task<string> GetDataAsync()
{
    return await _httpClient.GetStringAsync("https://api.example.com");
    // 只有一个 await,且在方法末尾,完全不需要 async
}

//  正确:直接返回 Task
public Task<string> GetDataAsync()
{
    return _httpClient.GetStringAsync("https://api.example.com");
    // 省略了状态机生成,性能更好
}

为什么不需要 async?

如果方法只有一个 await,并且在方法末尾,直接返回 Task 即可,无需 async:

  • 省略状态机生成(节省约 200 字节)
  • 减少方法调用开销
  • 异常栈更清晰

什么时候需要 async?

只有在以下情况才需要 async:

  1. 方法中有多个 await
  2. 需要 try-catch 包装异常
  3. 需要 using 语句
  4. 需要在 await 前后执行逻辑

问题:在同步方法中不必要地使用 Task.Run

//  错误:不必要的 Task.Run
public async Task<string> GetDataAsync()
{
    return await Task.Run(async () =>
    {
        using HttpClient client = new HttpClient();
        return await client.GetStringAsync("https://api.example.com");
    }); // 多此一举!浪费了一个线程
}

//  正确:I/O 操作直接 await
public async Task<string> GetDataAsync()
{
    using HttpClient client = new HttpClient();
    return await client.GetStringAsync("https://api.example.com");
}

为什么错误?

I/O 操作(如网络请求、文件读取)本身就是异步的,不需要 Task.Run:

  • Task.Run 会占用一个线程池线程等待
  • 完全没必要,还浪费资源

什么时候用 Task.Run?

只有在计算密集型任务需要卸载到后台线程时才用 Task.Run:

//  正确:CPU 密集型任务使用 Task.Run
public async Task<int> CalculateAsync(int[] numbers)
{
    return await Task.Run(() =>
    {
        // 复杂的计算
        return numbers.Sum(n => n * n);
    });
}

5.3 陷阱 3:死锁(.Result 或 .Wait())

这是第二常见的陷阱,尤其在 UI 应用和 ASP.NET Framework 中。

死锁场景

// WinForms/WPF 应用
public void Button_Click(object sender, EventArgs e)
{
    //  死锁!
    var data = LoadDataAsync().Result; // 阻塞 UI 线程
    UpdateUI(data);
}

public async Task<string> LoadDataAsync()
{
    await Task.Delay(1000); // 等待完成后,尝试回到 UI 线程
    return "Data";
}

死锁原因

UI 线程: [等待 Task 完成] ──阻塞──┐
                               │
Task:    [等待 UI 线程] ────回调──┘
         ↑ 死锁!互相等待

解决方案 1:使用 async/await(推荐)

//  正确:使用 async/await
public async void Button_Click(object sender, EventArgs e)
{
    var data = await LoadDataAsync(); //  不阻塞
    UpdateUI(data);
}

解决方案 2:使用 ConfigureAwait(false)

public void Button_Click(object sender, EventArgs e)
{
    var data = LoadDataAsync().Result; // ️ 仍然不推荐
}

public async Task<string> LoadDataAsync()
{
    //  不捕获上下文,避免死锁
    await Task.Delay(1000).ConfigureAwait(false);
    return "Data";
}

注意:关于 ConfigureAwait 的详细内容,会在后续章节详细讲解。

最佳实践

  • 优先使用 async/await(”一路 async 到底”)
  • 避免同步等待异步方法.Result.Wait()
  • 如果实在需要:在异步方法中使用 ConfigureAwait(false)

5.4 最佳实践总结

DO(推荐做法)

  • 返回 TaskTask<T>,避免 async void(事件处理除外)
  • “一路 async 到底”(Async All the Way)
  • 方法名以 Async 结尾
  • 使用 CancellationToken 支持取消(后续章节会讲)
  • I/O 操作使用异步 API(不要用 Task.Run 包装)
  • 高频调用考虑使用 ValueTask<T>(库代码)

DON’T(避免做法)

  • 不要使用 async void(除了事件处理程序)
  • 不要使用 .Result.Wait()
  • 不要在 I/O 操作上使用 Task.Run
  • 不要过度 await(方法末尾的单个 await 可以省略)

性能优化

  • 高频调用 + 高缓存命中率 → 使用 ValueTask<T>
  • 避免不必要的 async(方法末尾的单个 await)
  • 库代码考虑使用 ConfigureAwait(false)

6️⃣ async/await 的设计问题与未来演进

async/await 虽然强大,但并非完美。让我们客观地看看它的局限性和未来的改进方向。


6.1 async 的传染性(The Async Infection)

这是 async/await 最大的设计问题之一。

问题描述

一旦你的方法变成 async,所有调用它的方法也必须变成 async,形成”传染”。

// 第 1 层:数据访问层
public async Task<User> GetUserAsync(int id)
{
    return await _database.QueryAsync<User>("SELECT * FROM Users WHERE Id = @id", new { id });
}

// 第 2 层:业务逻辑层(被迫 async)
public async Task<UserDto> GetUserDtoAsync(int id)
{
    var user = await GetUserAsync(id); // ️ 必须 await
    return MapToDto(user);
}

// 第 3 层:控制器(被迫 async)
public async Task<IActionResult> GetUser(int id)
{
    var dto = await GetUserDtoAsync(id); // ️ 必须 await
    return Ok(dto);
}

传染路径

GetUserAsync (async)
    ↓ 传染
GetUserDtoAsync (被迫 async)
    ↓ 传染
GetUser (被迫 async)
    ↓ 传染
整个调用链都是 async

为什么是问题?

  1. 无法混合同步和异步代码
//  无法在同步方法中优雅地调用异步方法
public UserDto GetUserDto(int id)
{
    // 方式 1:阻塞(死锁风险)
    var dto = GetUserDtoAsync(id).Result; //  可能死锁

    // 方式 2:转同步(丑陋)
    var dto = GetUserDtoAsync(id).GetAwaiter().GetResult(); //  丑陋

    // 方式 3:改成 async(传染)
    // 无法实现,因为调用方可能要求同步接口
}
  1. 接口兼容性问题
// 现有同步接口
public interface IUserService
{
    User GetUser(int id);
}

// 想改成异步?必须改接口(破坏性变更)
public interface IUserService
{
    Task<User> GetUserAsync(int id); // ️ 破坏现有实现
}
  1. 库设计困境

库作者必须提供两套 API:

// Json.NET 的困境
public class JsonConvert
{
    public static string SerializeObject(object value);           // 同步版本
    public static Task<string> SerializeObjectAsync(object value); // 异步版本

    // 维护两套代码,痛苦!
}

6.2 async/await 的性能开销

虽然 async/await 比手动写回调好得多,但仍有性能开销。

状态机开销

每个 async 方法都会生成一个状态机:

// 简单的 async 方法
public async Task<int> GetValueAsync()
{
    await Task.Delay(100);
    return 42;
}

// 编译器生成的状态机(简化)
struct GetValueAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public TaskAwaiter Awaiter;

    // ... 约 200+ 字节的开销
}

开销

  • 状态机结构体:约 200 字节
  • AsyncTaskMethodBuilder:额外开销
  • 装箱(如果状态机需要堆分配)

6.3 Runtime Async:下一代异步模型

微软在 2019 年提出了 async2 项目(后改名为 Runtime Async),旨在解决 async/await 的设计缺陷。

核心目标

  1. 消除 async 传染性:允许同步和异步代码无缝互操作
  2. 零开销抽象:async 方法的性能接近普通方法
  3. 向后兼容:不破坏现有代码

当前状态

截至目前,Runtime Async 仍在设计和实验阶段,可能在 .NET 11 或更高版本中引入。

官方资源


6.4 当前的应对策略

在 Runtime Async 正式发布之前,我们可以这样应对:

1. 接受 async 传染性

//  正确:一路 async 到底
public async Task<IActionResult> GetUser(int id)
{
    var user = await _userService.GetUserAsync(id);
    return Ok(user);
}

原则:”Async all the way”(一路异步到底)

2. 提供同步和异步两套 API(库代码)

// 库代码的标准做法
public class MyService
{
    // 同步版本
    public User GetUser(int id)
    {
        // 实现...
    }

    // 异步版本
    public async Task<User> GetUserAsync(int id)
    {
        // 实现...
    }
}

3. 使用 ValueTask 减少开销

//  高频调用,使用 ValueTask
public ValueTask<int> GetCachedValueAsync(int key)
{
    if (_cache.TryGetValue(key, out int value))
        return new ValueTask<int>(value); // 无分配

    return new ValueTask<int>(_database.GetAsync(key));
}

6.5 总结:async/await 的现状与未来

现状

  • async/await 是目前最好的异步模型
  • ️ 但有传染性、性能开销等问题
  • ️ 需要遵守最佳实践,避免常见陷阱

未来

  • Runtime Async 旨在解决这些问题
  • 零开销、无传染性、向后兼容
  • ⏳ 但何时发布尚不明确

建议

  • 继续使用 async/await,它仍是最佳选择
  • 遵守最佳实践(本章讲过的)
  • 关注 Runtime Async 的进展

7️⃣ 章节总结

本章回顾

在本章中,我们深入探讨了 async/await 的方方面面:

核心知识点

  1. 为什么需要 async/await(第 0 章)

    • 回调地狱的痛苦(Thread、APM)
    • async/await 的三大价值:消灭回调、自动上下文管理、高效 I/O
  2. 编译器魔法(第 2 章)

    • 状态机的生成和工作原理
    • AsyncTaskMethodBuilder 的作用
    • await 的本质(ContinueWith + 状态切换)
  3. 线程真相(第 3 章)

    • async/await ≠ 多线程
    • I/O 完成端口(IOCP)的作用
    • 线程释放和恢复机制
  4. 性能优化:ValueTask(第 4 章)

    • Task 的堆分配问题
    • ValueTask 的值类型优势
    • .NET Core 源码实战(Stream、ASP.NET Core、Socket)
    • 决策树和使用限制
  5. 常见陷阱(第 5 章)

    • async void 的危险
    • 过度使用 async/await
    • 死锁(.Result/.Wait())
    • 最佳实践
  6. 设计问题与未来(第 6 章)

    • async 的传染性
    • 性能开销
    • Runtime Async 的未来

核心要点总结

DO(推荐做法)

类别 做法 原因
返回类型 使用 Task<T>ValueTask<T> 可以 await,异常可捕获
方法命名 Async 结尾 符合约定,易于识别
I/O 操作 直接 await 异步 API 不占用线程,高效
CPU 密集 await Task.Run(...) 卸载到后台线程
异常处理 try-catch 包裹 await 优雅处理异常
高频调用 考虑 ValueTask<T> 减少 GC 压力(库代码)
一路 async Async All the Way 避免死锁

DON’T(避免做法)

陷阱 问题 后果
async void 异常无法捕获 应用崩溃
.Result / .Wait() 阻塞线程 死锁风险
不必要的 async 状态机开销 性能损失
Task.Run 包装 I/O 浪费线程 资源浪费
忘记 await 异常被吞掉 难以调试
多次 await ValueTask 未定义行为 结果错误

性能优化清单

高性能场景

如果你的代码属于以下场景,应该特别关注性能优化:

  • 高并发 Web API(每秒 > 1000 请求)
  • 实时游戏服务器(低延迟要求)
  • 金融交易系统(极致性能)
  • 库代码(被大量调用)

优化手段

优化 场景 效果
ValueTask 高频 + 高缓存命中率 减少 GC 压力(3.7x 提升)
ConfigureAwait(false) 库代码 避免上下文切换
避免不必要的 async 方法末尾单个 await 省略状态机开销
缓存 Task 常量结果 避免重复分配

性能数据回顾

ValueTask vs Task(同步完成场景):

  • 速度:12.3 ns vs 45.2 ns(3.7x 提升
  • 内存:0 B vs 48 B(零分配

适用条件

  • 高频调用(每秒 > 1000 次)
  • 同步完成率 >= 50%
  • 库代码或高性能场景

常见问题 FAQ

Q1:什么时候用 ValueTask?

A:高频调用(每秒 > 1000 次)+ 高缓存命中率(>= 50%)+ 库代码。

判断标准

  • 使用场景:库代码、高性能 API、游戏服务器
  • 调用频率:每秒 > 1000 次
  • 缓存命中率:>= 50%(同步完成)
  • 普通应用代码:用 Task 即可(避免过度优化)

记忆口诀高频库代码,缓存命中高,ValueTask 才考虑。


Q2:async void 什么时候能用?

A:只有事件处理程序可以用,且必须内部处理异常。

唯一例外

//  事件处理程序:可以用 async void
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessDataAsync();
    }
    catch (Exception ex)
    {
        //  必须内部处理异常
        MessageBox.Show($"Error: {ex.Message}");
    }
}

其他场景:一律使用 async Task

原因

  • async void 的异常无法被调用方捕获
  • 无法 await(调用方不知道何时完成)
  • 异常会导致应用崩溃

Q3:如何避免死锁?

A:最佳方案是”一路 async 到底”(Async All the Way)。

三种方案对比

方案 适用场景 优先级
一路 async 所有场景 ⭐⭐⭐⭐⭐ 首选
ConfigureAwait(false) 库代码 ⭐⭐⭐⭐ 备选
同步方法 不得已 ⭐ 避免使用

示例

//  方案 1:一路 async(推荐)
public async Task LoadDataAsync()
{
    var data = await GetDataAsync();
    UpdateUI(data);
}

//  方案 2:库代码使用 ConfigureAwait(false)
public async Task<string> GetDataAsync()
{
    return await _httpClient.GetStringAsync("...")
        .ConfigureAwait(false); // 不捕获上下文
}

永远不要

//  错误:同步等待异步方法
var data = GetDataAsync().Result; //  死锁!

Q4:I/O 操作需要 Task.Run 吗?

A:不需要!I/O 操作本身就是异步的,直接 await 即可。

正确做法

//  正确:I/O 操作直接 await
public async Task<string> ReadFileAsync(string path)
{
    return await File.ReadAllTextAsync(path);
    // I/O 操作不占用线程,使用 I/O 完成端口
}

错误做法

//  错误:I/O 操作用 Task.Run
public async Task<string> ReadFileAsync(string path)
{
    return await Task.Run(async () =>
    {
        return await File.ReadAllTextAsync(path);
        //  多此一举!浪费一个线程池线程
    });
}

Task.Run 的正确用法:只用于 CPU 密集型任务

//  正确:CPU 密集型任务使用 Task.Run
public async Task<int> CalculateSumAsync(int[] numbers)
{
    return await Task.Run(() =>
    {
        return numbers.Sum(n => n * n); // 复杂计算
    });
}

Q5:async 方法的性能开销有多大?

A:约 200 字节的状态机结构体 + AsyncTaskMethodBuilder 开销。对于 I/O 操作,这点开销可以忽略。

性能数据

  • 状态机结构体:约 200 字节
  • 异步路径开销:约 120 ns(相比同步多 100-200 ns)
  • 同步路径开销:可以优化到几乎为零(IsCompleted == true)

是否需要担心?

场景 开销是否重要 建议
I/O 操作 不重要 放心使用 async
网络请求 不重要 放心使用 async
文件读写 不重要 放心使用 async
高频同步方法 重要 考虑 ValueTask 或直接返回 Task
纯计算 重要 不要用 async

记忆口诀:I/O 操作用 async,性能开销可忽略;高频同步方法,考虑 ValueTask 或直接返回 Task。


参考资源

官方文档

  1. 异步编程模式(Microsoft Learn)

  2. Task-based Asynchronous Pattern (TAP)

  3. ValueTask 文档


.NET 官方源码

  1. AsyncTaskMethodBuilder 源码

  2. SynchronizationContext 源码

  3. Task 源码

  4. ValueTask 源码


.NET 团队博客(必读!)

  1. Stephen Toub 系列文章

  2. David Fowler 的异步指南

  3. Async/Await Best Practices


️ 工具

  1. SharpLab

    • https://sharplab.io/
    • 在线查看 C# 代码编译后的 IL 代码和状态机
    • 支持 C# to C#、C# to IL、C# to ASM
  2. BenchmarkDotNet

  3. PerfView


全文完:感谢阅读!如果有疑问,欢迎在评论区讨论。

下一步:准备好了吗?下一章节,我们将详细讲一讲SynchronizationContext 与死锁的问题。

文章摘自:https://www.cnblogs.com/diamondhusky/p/19934031