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
- 资源泄漏风险:忘记
Join或Dispose - 无法取消:如何取消一个正在执行的线程?
- 性能差:每个操作都创建新线程(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,并在任务完成后恢复到原来的上下文。
注意:关于
SynchronizationContext和ConfigureAwait,我们会在后面的章节(第 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 下载内容,但不会阻塞线程。让我们拆解一下:
async关键字:告诉编译器”这是一个异步方法,请帮我生成状态机”await关键字:在这里等待下载完成,但不阻塞线程(线程会被释放去做其他事情)Task<string>返回类型:表示这个方法会异步返回一个字符串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>或Task或ValueTask<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 秒(
await Task.Delay(1000)) - 打印”完成”
- 返回字符串 “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);
}
}
}
看懂了吗?让我们拆解一下:
- State 字段:记录当前执行到哪个
await(0 表示第一个 await 之前,1 表示第一个 await 之后) - MoveNext 方法:状态机的核心,用
switch-case实现状态转换 - Awaiter 字段:保存每个
await的 awaiter(用于恢复执行) - AsyncTaskMethodBuilder:负责创建和完成 Task
关键点:当 await 的任务还没完成时,状态机会:
- 记录当前状态(
State = 1) - 注册一个回调(
Awaiter.OnCompleted(MoveNext)) - 立即返回(释放线程)⭐
当任务完成后,回调会被调用,状态机会从上次的状态继续执行。
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 时(任务还没完成):
- 记录当前状态(
State = 1) - 注册回调(
Awaiter.OnCompleted(MoveNext)) - 立即返回,释放线程 ⭐
- 等待任务完成…
- 任务完成后,回调
MoveNext(可能在不同的线程上) - 从上次的状态继续执行
这就是 async/await 的魔法所在:在等待期间,线程被释放了,可以去处理其他请求!
2.4 亲自查看编译器生成的代码
想看看编译器实际生成的代码吗?用 SharpLab 这个神器!
步骤:
- 打开 SharpLab
- 输入你的 async 方法:
using System;
using System.Threading.Tasks;
public class C {
public async Task<string> M() {
await Task.Delay(1000);
return "Hello";
}
}
- 选择 “C# -> C#”(查看反编译后的代码)
- 你会看到编译器生成的完整状态机代码!
生成的代码特点:
- 状态机结构体(值类型,避免堆分配)
-
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 <-- 可能是不同的线程!
这说明了什么?
await之前和之后的线程 ID 可能不同(也可能相同,取决于线程池的调度)- 在
await期间,原来的线程被释放了(回到线程池,去处理其他请求) - 网络请求由操作系统的 I/O 完成端口(IOCP)处理,不需要线程傻等
- 任务完成后,从线程池取一个线程继续执行
关键问题:那在 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); // 堆分配!
}
为什么是问题?
- Task 是引用类型(class) :每次创建都在堆上分配内存
- 高频调用场景:假设每秒 10,000 次请求,90% 缓存命中率
- 每秒分配 9,000 个 Task 对象
- 假设每个 Task 对象 48 字节:9,000 × 48 = 432 KB/秒
- 每秒约 432 KB 的垃圾!
- 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
- ️ 不能阻塞获取结果
相关章节:关于
SynchronizationContext和ConfigureAwait的详细内容,会在后续章节中详细讲解。
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)
{
// 永远不会捕获到异常!
}
为什么危险?
- 异常会导致程序崩溃(未处理异常)
- 无法 await(调用方不知道何时完成)
- 调试困难
正确做法:
// 正确:返回 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:
- 方法中有多个 await
- 需要 try-catch 包装异常
- 需要 using 语句
- 需要在 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(推荐做法):
- 返回
Task或Task<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
为什么是问题?
- 无法混合同步和异步代码
// 无法在同步方法中优雅地调用异步方法
public UserDto GetUserDto(int id)
{
// 方式 1:阻塞(死锁风险)
var dto = GetUserDtoAsync(id).Result; // 可能死锁
// 方式 2:转同步(丑陋)
var dto = GetUserDtoAsync(id).GetAwaiter().GetResult(); // 丑陋
// 方式 3:改成 async(传染)
// 无法实现,因为调用方可能要求同步接口
}
- 接口兼容性问题
// 现有同步接口
public interface IUserService
{
User GetUser(int id);
}
// 想改成异步?必须改接口(破坏性变更)
public interface IUserService
{
Task<User> GetUserAsync(int id); // ️ 破坏现有实现
}
- 库设计困境
库作者必须提供两套 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 的设计缺陷。
核心目标
- 消除 async 传染性:允许同步和异步代码无缝互操作
- 零开销抽象:async 方法的性能接近普通方法
- 向后兼容:不破坏现有代码
当前状态
截至目前,Runtime Async 仍在设计和实验阶段,可能在 .NET 11 或更高版本中引入。
官方资源:
- [.NET Runtime-Async Feature](.NET Runtime-Async Feature · Issue #109632 · dotnet/runtime)
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 的方方面面:
核心知识点
-
为什么需要 async/await(第 0 章)
- 回调地狱的痛苦(Thread、APM)
- async/await 的三大价值:消灭回调、自动上下文管理、高效 I/O
-
编译器魔法(第 2 章)
- 状态机的生成和工作原理
AsyncTaskMethodBuilder的作用- await 的本质(ContinueWith + 状态切换)
-
线程真相(第 3 章)
- async/await ≠ 多线程
- I/O 完成端口(IOCP)的作用
- 线程释放和恢复机制
-
性能优化:ValueTask(第 4 章)
- Task 的堆分配问题
- ValueTask 的值类型优势
- .NET Core 源码实战(Stream、ASP.NET Core、Socket)
- 决策树和使用限制
-
常见陷阱(第 5 章)
- async void 的危险
- 过度使用 async/await
- 死锁(.Result/.Wait())
- 最佳实践
-
设计问题与未来(第 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。
参考资源
官方文档
-
异步编程模式(Microsoft Learn)
- https://learn.microsoft.com/zh-cn/dotnet/csharp/asynchronous-programming/
- Microsoft 官方的 async/await 完整指南
- 包括基础概念、最佳实践、性能优化
-
Task-based Asynchronous Pattern (TAP)
- https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap
- TAP 的详细规范
- 包括命名约定、返回类型、取消和进度报告
-
ValueTask 文档
- https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.valuetask-1
- 官方 API 文档
- 包括使用限制和最佳实践
.NET 官方源码
-
AsyncTaskMethodBuilder 源码
-
SynchronizationContext 源码
-
Task 源码
-
ValueTask 源码
.NET 团队博客(必读!)
-
Stephen Toub 系列文章
-
David Fowler 的异步指南
- https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
- ASP.NET Core 架构师的最佳实践
- 包括常见陷阱、性能优化、诊断技巧
-
Async/Await Best Practices
- Async/Await – Best Practices in Asynchronous Programming
- Stephen Cleary(异步编程专家)的经典文章
- MSDN Magazine 2013 年 3 月刊
️ 工具
-
SharpLab
- https://sharplab.io/
- 在线查看 C# 代码编译后的 IL 代码和状态机
- 支持 C# to C#、C# to IL、C# to ASM
-
BenchmarkDotNet
- https://benchmarkdotnet.org/
- 用于性能测试的专业工具
- 支持内存分配、GC、CPU 指令等多维度测量
-
PerfView
- https://github.com/microsoft/perfview
- Microsoft 的性能分析工具
- 支持 CPU、内存、GC、异步操作的分析
全文完:感谢阅读!如果有疑问,欢迎在评论区讨论。
下一步:准备好了吗?下一章节,我们将详细讲一讲SynchronizationContext 与死锁的问题。
文章摘自:https://www.cnblogs.com/diamondhusky/p/19934031
