[深入解析C#] 第 5 章:异步
第 5 章——异步
5.1 异步函数简介
5.1.1 异步问题初体验
-
核心概念:
通过一个真实的 Windows Forms 示例,演示
async/await如何在不阻塞 UI 线程的前提下异步获取网络资源,同时保持代码简洁、可读。 -
关键点:
- 示例背景:点击按钮,从网站主页获取文本,计算长度并显示在
Label上。 - 异步实现:
- 使用
HttpClient(仅提供异步 API,如GetStringAsync)。 - 事件处理器标记为
async void,内部await异步操作。 await期间 UI 线程不会被阻塞,用户可以移动窗口等操作。
- 使用
- 对比同步版本:
- 若去掉
async/await并用WebClient.DownloadString,UI 会在下载期间冻结(线程阻塞)。
- 若去掉
- Windows Forms(及多数 GUI 框架)两条黄金法则:
- 不要在 UI 线程执行任何长耗时操作(否则界面卡死)。
- 不要从 UI 线程以外的线程访问 UI 控件(跨线程异常)。
- 遗留异步方式的缺陷:
- 可用
WebClient.DownloadStringAsync(事件驱动),但当逻辑变复杂(错误处理、等待多个页面)时,代码难以维护。 async/await让复杂异步逻辑也能按自然顺序编写。
- 可用
- 示例背景:点击按钮,从网站主页获取文本,计算长度并显示在
-
代码示例:
public class AsyncIntro : Form
{
private static readonly HttpClient client = new HttpClient();
private readonly Label label;
private readonly Button button;
public AsyncIntro()
{
label = new Label { Location = new Point(10, 20), Text = "Length" };
button = new Button { Location = new Point(10, 50), Text = "Click" };
button.Click += DisplayWebsiteLength; // 关联异步事件处理器
AutoSize = true;
Controls.Add(label);
Controls.Add(button);
}
async void DisplayWebsiteLength(object sender, EventArgs e)
{
label.Text = "Fetching...";
string text = await client.GetStringAsync("<http://csharpindepth.com>");
label.Text = text.Length.ToString(); // UI 更新在 await 之后自动回到 UI 线程
}
static void Main() => Application.Run(new AsyncIntro());
}
- 注意事项:
async void仅适用于事件处理器(因为事件签名要求返回void);其他情况应使用async Task。HttpClient的GetStringAsync返回Task<string>,await后得到string。Task虽实现IDisposable,但通常不需要手动释放(框架会处理)。
5.1.2 拆分第一个例子(await 与 Task 的关系及续延机制)
-
核心概念:
await可作用于Task<TResult>,其返回值是拆封后的TResult。async方法执行到await时会立即返回调用方,待异步操作完成后通过续延(continuation) 恢复执行,且默认会回到原始上下文(如 UI 线程),期间不阻塞线程。 -
关键点:
-
拆分写法:
Task<string> task = client.GetStringAsync(url); string text = await task; // 拆封 Task<string> 得到 string -
await的主要作用:避免线程阻塞,而非仅用于拆封。 -
执行流程:
- 同步执行
async方法直到遇到await表达式。 - 若异步操作未完成,方法立即返回(调用方继续运行)。
- 异步操作完成后,续延(本质是回调)被调度执行,恢复方法剩余部分。
- 续延默认捕获原始上下文(如 UI 线程),以便安全访问 UI 控件。
- 同步执行
-
调试视角:
await之前:调用栈包含按钮点击事件链(如Button.OnClick)。await之后(断点在续延中):调用栈只剩下 UI 事件循环和异步基础设施,原始调用方早已返回。
-
底层实现:编译器生成复杂状态机(第 6 章详解),
Task.ContinueWith是类似机制的低级 API。
-
-
代码示例:
async void DisplayWebsiteLength(object sender, EventArgs e)
{
label.Text = "Fetching..."; // 同步执行(UI 线程)
Task<string> task = client.GetStringAsync("<http://csharpindepth.com>");
string text = await task; // 遇到 await,方法返回
label.Text = text.Length.ToString(); // 续延(仍回到 UI 线程)
}
5.2 对异步模式的思考
5.2.1 关于异步执行本质的思考(续延、令牌与 await 的非阻塞模型)
-
核心概念:
异步模式改变了传统“语句顺序执行”的模型,引入续延(continuation)——即操作完成后要执行的回调。C# 的
await本质上是请求编译器自动创建续延,避免手动管理回调与错误处理的复杂性。 -
关键点:
1. 同步 vs 异步执行流
- 同步:第 2 行必须等待第 1 行完成。
- 异步:执行遇到
await时,方法立即返回,异步操作完成后通过续延恢复执行。
2. 续延(continuation)
- 是一个可以保存程序执行状态的回调(通常是
Action委托)。 - 在 C# 5 之前,需手动为成功/失败编写多个事件处理(如
WebClient),复杂逻辑极易出错。 await让编译器替我们生成续延,大幅提升可读性和错误处理的可靠性。
3. 令牌(Task/Task
) - 异步操作返回一个令牌(代表正在进行的操作)。
- 该令牌可用于:
- 检查操作是否已完成
- 阻塞等待(如
task.Wait(),但会浪费线程) - 附加续延(如
task.ContinueWith或await)
- 令牌不同于取消令牌(
CancellationToken),但理念类似——不关心背后实现,只关注允许的行为。
4.
await的非阻塞等待- 遇到
await时,若操作未完成,方法立即返回(不阻塞线程)。 - 待操作完成后,续延被调度,恢复方法执行。
- 类比:点比萨外卖后继续做其他事,而不是站在门口等。
5. 异步调用链
- 异步方法通常返回
Task/Task<T>,调用方可选择阻塞或继续await。 - 典型模式是形成一条异步调用链,每个方法都表现出与异步操作一致的行为。
-
典型执行流程:
- 执行某些同步操作
- 启动异步操作,获取令牌(
Task) - (可选)执行其他不依赖结果的操作
- 等待令牌完成(
await)——此时方法返回,不阻塞 - 继续执行依赖结果的操作
- 方法完成(可能返回令牌给调用方)
5.2.2 同步上下文
-
核心概念:
SynchronizationContext是 .NET 2.0 引入的类,用于在正确的线程中执行委托(如 UI 线程)。异步方法(async)利用它确保await之后的续延能回到原始上下文(如 UI 线程),从而安全访问 UI 控件或满足特定环境要求(如 ASP.NET 请求上下文)。 -
关键点:
- 上下文的作用:
- 提供
Post(异步)和Send(同步)方法,类似Control.BeginInvoke/Control.Invoke。 - 不同环境有不同的同步上下文实现(如 Windows Forms、WPF、ASP.NET、线程池等)。
- 提供
- UI 线程安全:
await之后默认捕获当前SynchronizationContext,续延会通过该上下文调度,确保不违反“UI 控件仅在 UI 线程访问”的黄金法则。
- **ASP.NET 特殊性**:
- 危险操作:
Task.Wait()和Task.Result:同步阻塞等待任务完成。- 若在需要原始上下文续延的线程(如 UI 线程或 ASP.NET 请求线程)中调用它们,可能导致死锁:
- 原线程阻塞等待任务完成 → 任务完成需要续延在该线程上执行 → 该线程已被阻塞 → 互相等待。
- 控制台应用通常安全(无同步上下文,续延在线程池执行),但仍不推荐使用阻塞操作。
- 上下文的作用:
-
代码示例(死锁场景):
// 错误示例:在 UI 或 ASP.NET 上下文中同步阻塞异步方法
public void ButtonClick(object sender, EventArgs e)
{
string result = GetStringAsync().Result; // 死锁!
}
private async Task<string> GetStringAsync()
{
await Task.Delay(100); // 需要回到原上下文
return "OK";
}
- 最佳实践:
- 始终使用
await而非.Wait()/.Result。 - 若必须阻塞(如控制台
Main),可考虑GetAwaiter().GetResult()(仍会阻塞但异常处理更干净),或使用ConfigureAwait(false)减少死锁风险(但需理解后果)。
- 始终使用
5.2.3 异步方法模型
-
核心概念:
异步方法可抽象为三个代码块(调用方、
async方法体、底层异步操作)和两个边界类型(async方法的返回类型、异步操作返回的类型)。async修饰符标记方法,await运算符消费异步操作。 -
关键点:
- 模型结构(如图5-1/5-2所示):
- 调用方法(如
PrintPageLength)→ 调用async方法,获得Task<int>。 async方法(如GetPageLengthAsync)→ 内部调用异步操作(如GetStringAsync),获得Task<string>,await后得到string并计算长度。- 异步操作(如
HttpClient.GetStringAsync)→ 返回Task<string>。
- 边界类型:
async方法返回:Task、Task<T>、void(仅事件处理器),或 C# 7+ 自定义可等待类型。- 异步操作返回:
Task<TResult>或其他可等待模式实现。
- 控制台示例(代码清单5-2):
GetPageLengthAsync返回Task<int>。PrintPageLength同步调用.Result阻塞获取结果(不推荐,仅示例)。


- 代码示例:
static readonly HttpClient client = new HttpClient();
static async Task<int> GetPageLengthAsync(string url)
{
Task<string> fetchTextTask = client.GetStringAsync(url);
int length = (await fetchTextTask).Length; // 拆封 Task<string> 得到 string
return length;
}
static void PrintPageLength()
{
Task<int> lengthTask = GetPageLengthAsync("<http://csharpindepth.com>");
Console.WriteLine(lengthTask.Result); // 同步阻塞(危险,仅示例)
}
-
异步方法的三阶段学习路径(图5-3):
- 5.3 节:声明
async方法(语法、返回类型、参数)。 - 5.4 节:使用
await运算符等待异步操作。 - 5.5 节:方法执行完成后的返回值与续延行为。

- 5.3 节:声明
-
注意事项:
async和await是仅有的两个新语法,但理解控制流、上下文和错误处理需要系统学习。- 示例中的
.Result用于演示类型映射,实际开发应使用await避免死锁。
5.3 async 方法声明
-
核心概念:
async关键字用于标记异步方法,是方法实现细节,不影响方法签名。编译器在 IL 中省略该修饰符,因此调用方无需关心方法是否为async,只关注返回的Task或Task<T>。 -
关键点:
-
语法位置:
async可放在返回类型前的任意位置(返回类型前、static前后等),但团队应统一风格(推荐紧邻返回类型)。public static async Task<int> FooAsync() { } public async static Task<int> FooAsync() { } async public Task<int> FooAsync() { } public async virtual Task<int> FooAsync() { } -
设计意图:
- 技术上编译器可通过检测
await自动进入异步模式(类似迭代器中的yield),但强制要求async关键字提高了代码可读性:看到async即暗示方法内可能出现await,提醒开发者寻找阻塞调用的替代。
- 技术上编译器可通过检测
-
二进制兼容性:
async修饰符不包含在 IL 中,因此将普通方法改为async方法(或反之)是源码级和二进制级兼容的,只要签名(返回类型、参数)不变。- 抽象方法或接口方法不能标记为
async,因为async是实现细节,但它们可以声明返回Task/Task<T>,具体实现类可选择用async或普通返回Task的方式。
-
-
代码示例(兼容性演示):
// 接口声明(无 async)
interface IMyAsync
{
Task<int> GetValueAsync(); // 返回 Task<int>,但不标记 async
}
// 实现方式1:使用 async/await
class AsyncImpl : IMyAsync
{
public async Task<int> GetValueAsync()
{
await Task.Delay(100);
return 42;
}
}
// 实现方式2:普通方法直接返回 Task(无 async)
class TaskImpl : IMyAsync
{
public Task<int> GetValueAsync()
{
return Task.FromResult(42); // 或复杂逻辑但未用 await
}
}
- 注意事项:
async不能修饰程序的入口点(Main方法),但 C# 7.1 开始允许async Task Main。- 异步方法命名惯例建议以
Async后缀结尾,便于区分。
5.3.1 async 方法的返回类型(void、Task、Task<TResult>)
-
核心概念:
async方法通过返回值与调用方交互。C# 5 只允许三种返回类型:void、Task、Task<TResult>。C# 7 增加了新的选项(后续讨论)。返回Task或Task<TResult>让调用方能等待、检测完成状态或附加续延。 -
关键点:
-
三种类型的作用:
void:仅用于事件处理器(与现有委托签名兼容)。调用方不关心完成状态。Task:代表无返回值的异步操作。可视为Task<void>(但void不能作类型实参)。Task<TResult>:代表返回TResult的异步操作。
-
最佳实践警告:
- 除非是事件订阅,否则永远不要返回
void。 - 若方法没有返回值,应返回
Task,这样调用方可以await、正确处理异常、检测完成等。 - 返回
void的异步方法异常会直接抛到当前同步上下文,可能崩溃应用。
- 除非是事件订阅,否则永远不要返回
-
与事件处理器的兼容性:
// 异步事件处理器(合法且常用) private async void LoadStockPrice(object sender, EventArgs e) { string ticker = tickerInput.Text; decimal price = await stockPriceService.FetchPriceAsync(ticker); priceDisplay.Text = price.ToString("c"); } // 订阅 loadStockPriceButton.Click += LoadStockPrice; -
其他灵活性:
async方法可以是泛型、静态/实例、任意访问修饰符。- 参数仍有限制(如不能使用
ref/out,因为异步状态机无法处理这些参数)。
-
-
代码示例(返回
Task与void对比):
// 正确:无返回值的异步方法返回 Task
public async Task LogAsync(string message)
{
await Task.Delay(100);
Console.WriteLine(message);
}
// 错误示例(非事件场景):返回 void 导致调用方无法等待异常
public async void BadLogAsync(string message)
{
await Task.Delay(100);
Console.WriteLine(message);
}
// 调用方体验差异
await LogAsync("OK"); // 可以等待
BadLogAsync("Bad"); // 无法等待,异常可能丢失
- 设计意图:
async返回Task/Task<TResult>使得异步方法可组合、可测试、可取消。void仅保留给事件处理器,因为事件触发者不关心方法的执行结果。
5.3.2 async 方法的参数
-
核心概念:
async方法的参数受到特定限制,主要是为了适应异步方法的执行模型——方法可能在异步操作完成前就返回,导致引用参数无法正常赋值或交互。 -
关键点:
- 不允许使用
out或ref参数:- 原因:
async方法遇到await时会立即返回调用方,此时异步操作可能尚未开始或未完成,out/ref参数无法保证被正确赋值。 - 更严重的场景:调用方可能已执行完毕,而引用参数仍未赋值,造成未定义行为。
- 原因:
- 不允许使用指针参数(与
unsafe上下文相关)。 - 除上述限制外,其他常规参数(值类型、引用类型、
params、可选参数等)均可正常使用。
- 不允许使用
-
代码示例(非法声明):
// 错误:async 方法不能有 out 或 ref 参数
public async Task<int> ComputeAsync(out int result) { } // 编译错误
public async Task<string> FetchAsync(ref string id) { } // 编译错误
// 正确:普通参数可以
public async Task<int> GetLengthAsync(string url, int timeout = 1000) { }
- 设计意图:
- 异步方法的控制流是非线性的(可能多次暂停和恢复),
out/ref需要稳定的内存位置,而状态机无法有效追踪它们。 - 如需返回多个值,可考虑返回
Task<(T1, T2)>(元组)或自定义类型。
- 异步方法的控制流是非线性的(可能多次暂停和恢复),
5.4 await 表达式
5.4.1 可等待模式
-
核心概念:
await不是基于特定接口,而是基于模式(类似于foreach要求GetEnumerator)。编译器检查目标类型是否满足“可等待模式”的成员要求,满足即可用于await。 -
关键点:
- 编译器检查步骤(假设等待表达式类型为
T):T必须具备可访问的GetAwaiter()方法(实例或扩展方法),返回awaiter类型。awaiter必须实现System.Runtime.INotifyCompletion接口(含void OnCompleted(Action))。awaiter必须有bool IsCompleted属性(可读)。awaiter必须有void GetResult()或TResult GetResult()实例方法(无参数)。
await表达式的类型:- 若
GetResult()返回void,则await表达式无返回值(如await Task.Yield())。 - 若返回
TResult,则await表达式返回TResult(如await task得到string)。
- 若
- 成员的可访问性:
GetAwaiter、IsCompleted、GetResult等不一定需要public,只要能被async方法访问即可(罕见)。 - 扩展方法的作用(历史):
- C# 5 发布时 .NET 4.0 的
Task没有GetAwaiter实例方法。 - 通过扩展方法(由 NuGet 包提供)使得 C# 5 编译器能在 .NET 4.0 上使用
await Task。 - 现在 .NET 4.5+ 已原生包含,很少需要自定义扩展。
- C# 5 发布时 .NET 4.0 的
- 编译器检查步骤(假设等待表达式类型为
-
代码示例(
Task.Yield展示无返回值await):
// Task.Yield 返回 YieldAwaitable,其 GetResult() 返回 void
await Task.Yield(); // 合法,无返回值
// var x = await Task.Yield(); // 非法:不能赋值
// 模拟可等待模式的关键结构
public class CustomAwaitable
{
public Awaiter GetAwaiter() => new Awaiter();
public struct Awaiter : System.Runtime.INotifyCompletion
{
public bool IsCompleted => false;
public void OnCompleted(Action continuation) { }
public int GetResult() => 42; // 返回 int,所以 await 表达式得到 int
}
}
// 使用
CustomAwaitable custom = new CustomAwaitable();
int result = await custom; // 合法,result = 42
- 注意事项:
GetAwaiter是实例或扩展方法,但不能是ref或out参数。IsCompleted为true时,await可能同步执行(不挂起)。- 可等待模式让自定义类型(如
ValueTask、Task兼容类型)能无缝接入async/await。
5.4.2 await 表达式的限制条件
-
核心概念:
await只能在特定上下文中使用,主要限制包括:必须在async方法/匿名函数中、不能在lock语句内、不能在 C# 5 的catch/finally块中等。C# 6 解除了部分限制。 -
关键点:
- 基本限制:
- 仅能用于
async方法或异步匿名函数(5.7 节)。 - 不能在 不安全上下文(
unsafe块)中直接使用await,但async方法可以包含unsafe块,只是await不能出现在该块内部。
- 仅能用于
- 锁限制:
- 不能在
lock语句中使用await(因为Monitor要求释放锁的线程与获取锁的线程相同,而await可能导致线程切换)。 - 替代方案:使用
SemaphoreSlim.WaitAsync()实现异步锁。
- 不能在
- C# 5 的限制(C# 6 已解除):
- 不允许在
catch块、finally块或带有catch的try块中使用await。 - C# 5 中只允许在
try/finally(无catch)中使用(例如using内部)。 - C# 6 通过改进状态机生成,解除了上述限制。
- 不允许在
- 基本限制:
-
代码示例(合法与非法用法):
// 合法:unsafe 块中存在,但 await 在块外
static async Task DelayWithUnsafeCode(string text)
{
int total = 0;
unsafe // unsafe 块内无 await
{
fixed (char* p = text) { /* 指针操作 */ }
}
await Task.Delay(total); // ✅ 合法,不在 unsafe 块内
}
// 非法:await 在 lock 内部
lock (syncObj)
{
await Task.Delay(100); // ❌ 编译错误
}
// C# 5 非法 / C# 6 合法:catch 块中使用 await
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
await LogAsync(ex); // C# 5 ❌ / C# 6 ✅
}
- 替代方案:
-
异步锁:
private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); await semaphore.WaitAsync(); try { /* 异步操作 */ } finally { semaphore.Release(); }
-
5.5 返回值的封装(async 方法如何将 return 转换为 Task<T>)
-
核心概念:
async方法中使用return语句返回普通值(如int),但方法签名返回Task<int>。编译器自动生成代码将返回值封装(wrap)进Task<TResult>,调用方收到的始终是Task。这种机制使得异步方法可以像同步方法一样组合调用。 -
关键点:
-
返回值转换:
static async Task<int> GetPageLengthAsync(string url) { int length = (await client.GetStringAsync(url)).Length; return length; // 返回 int,但方法返回 Task<int> }- 编译器生成的代码负责创建
Task<int>,并在异步操作完成后将length设置为结果。 - 调用方通过
await或.Result获取拆封后的int值。
- 编译器生成的代码负责创建
-
Task返回类型(无返回值):- 类似
void方法:可以无return,或只写return;(不跟表达式)。 - 生成的
Task用于捕获完成状态和异常。
- 类似
-
异常封装:
- 若
async方法内部抛出异常,该异常会被捕获并存入返回的Task中(而不是直接抛出)。调用方await时会重新抛出该异常(5.6.5 节详述)。
- 若
-
try/finally中的return:return语句中的表达式立即计算,但Task的结果值只有在finally块执行完成后才会被最终设定。- 如果
finally块抛出异常,则整个Task失败(不会出现“半成功”状态)。 - 对
using也适用(编译为try/finally)。
-
与 LINQ 的类比:
- LINQ 对序列元素的操作通过封装(序列)和拆封(迭代)实现组合。
- 异步编程中,
await拆封Task,return封装值进Task,使得多个小异步方法能轻松组合成复杂逻辑。
-
-
代码示例(封装行为演示):
// 编译器生成的逻辑(简化示意)
static Task<int> GetPageLengthAsync(string url)
{
var stateMachine = new StateMachine();
stateMachine.url = url;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.MoveNext(); // 启动状态机
return stateMachine.builder.Task; // 立即返回 Task<int>
}
// try/finally 对返回值的影响
static async Task<string> ReadFileAsync(string path)
{
var stream = new FileStream(path, FileMode.Open);
try
{
byte[] buffer = new byte[100];
int bytesRead = await stream.ReadAsync(buffer, 0, 100);
return Encoding.UTF8.GetString(buffer, 0, bytesRead);
}
finally
{
await stream.DisposeAsync(); // finally 在 return 表达式计算后、Task 完成前执行
}
}
- 注意事项:
- 开发时几乎不需要关心封装细节,只需牢记:
async方法返回Task<T>,内部return T;返回Task,内部return;或无return。 - 避免在
finally中抛出异常,否则会覆盖原本的返回值或成功状态。
- 开发时几乎不需要关心封装细节,只需牢记:
5.6 异步方法执行流程
5.6.1 await的操作对象与时机
-
核心概念:
await只能作用于单一值(通常是Task或Task<T>),不能直接“穿透”表达式链。理解await的展开方式有助于把握异步操作的执行时机——await表达式会等待异步操作完成,然后继续执行后续代码。 -
关键点:
-
await作用于单一值:- 错误观念:
await new HttpClient().GetStringAsync(url)看起来像是await了整个表达式。 - 实际等价于:先调用方法得到
Task<string>,再await该任务。
// 简写形式 string pageText = await new HttpClient().GetStringAsync(url); // 等价于 Task<string> task = new HttpClient().GetStringAsync(url); string pageText = await task; - 错误观念:
-
复杂表达式中的
await求值顺序:-
示例:
AddPayment(await GetHourlyRateAsync() * await GetHoursWorkedAsync(...)); -
按 C# 表达式规则,先对
*左侧操作数求值(await左侧),再对右侧求值(await右侧),两个await串行执行。 -
展开后等价于:
Task<decimal> rateTask = GetHourlyRateAsync(); decimal rate = await rateTask; // 等待完成 Task<int> hoursTask = GetHoursWorkedAsync(...); int hours = await hoursTask; // 等待完成 AddPayment(rate * hours);
-
-
更好的写法(并发启动 + 串行等待):
Task<decimal> rateTask = GetHourlyRateAsync(); Task<int> hoursTask = GetHoursWorkedAsync(...); AddPayment(await rateTask * await hoursTask); // 两个任务已并行启动,等待时串行- 优点:两个异步操作同时发起(不等待彼此),提升性能;等待时仍按顺序获取结果。
- 5.10.2 节会深入讨论。
-
-
核心原则:
- 识别
await操作的对象(即await后面的表达式必须求值为一个可等待实例)。 - 执行时机:
await会挂起方法直到操作完成,然后继续执行同一语句的后续部分(如乘法、方法实参求值)。
- 识别
-
代码示例(三种写法对比):
// 写法1:一行紧凑(可读性差)
AddPayment(await employee.GetHourlyRateAsync() * await timeSheet.GetHoursWorkedAsync(employee.Id));
// 写法2:完全展开(清晰但代码冗长)
Task<decimal> rateTask = employee.GetHourlyRateAsync();
decimal rate = await rateTask;
Task<int> hoursTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hours = await hoursTask;
AddPayment(rate * hours);
// 写法3:并发启动,串行等待(推荐)
Task<decimal> rateTask = employee.GetHourlyRateAsync();
Task<int> hoursTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await rateTask * await hoursTask);
- 注意事项:
- 不要假设
await会“并行等待”多个任务——默认是串行等待(一个接一个)。 - 如需真正的并行等待多个任务,应使用
Task.WhenAll(后续章节会介绍)。
- 不要假设
5.6.2 await表达式的运算
-
核心概念:
await表达式根据异步操作是否已完成,表现出不同行为:若已完成,则同步继续;若未完成,则挂起方法、附加续延、立即返回。区分方法返回(暂停点)与方法完成(最终结束)是理解异步执行的关键。 -
关键点:
- 已完成的操作(如
Task.FromResult):- 不挂起方法,不切换线程,不附加续延。
- 若成功,获取结果并继续执行后续代码。
- 若失败,抛出异常。
- 未完成的操作(如
Task.Delay):- 方法挂起,附加续延,立即返回到调用方(不阻塞线程)。
- 续延将在操作完成后,在合适的上下文(同步上下文或线程池)中调度执行。
- 方法返回 vs 方法完成:
- 异步方法可多次返回(每次遇到未完成的
await时返回调用方)。 - 方法完成指整个异步方法执行完毕(返回最终结果或抛出异常)。
- 异步方法可多次返回(每次遇到未完成的
- 执行顺序示例(代码清单5-4):
- 先同步执行直到遇到未完成的
await才返回,因此"Method returned"在第一个await(已完成)之后、第二个await(未完成)之后打印。 - 返回的
Task只有在方法完全完成后才变为完成状态。
- 先同步执行直到遇到未完成的
- 已完成的操作(如
-
代码示例(清单5-4 关键部分):
static void Main()
{
Task task = DemoCompletedAsync();
Console.WriteLine("Method returned");
task.Wait();
Console.WriteLine("Task completed");
}
static async Task DemoCompletedAsync()
{
Console.WriteLine("Before first await");
await Task.FromResult(10); // 已完成 → 同步执行
Console.WriteLine("Between awaits");
await Task.Delay(1000); // 未完成 → 挂起并返回
Console.WriteLine("After second await");
}
输出:
Before first await
Between awaits
Method returned
After second await
Task completed
-
流程图说明(图5-6):

- 遇到
await→ 检查操作是否完成。 - 已完成 → 获取结果 → 继续执行。
- 未完成 → 添加续延 → 返回(方法挂起) → 续延恢复后回到“获取结果”步骤。
- 遇到
-
最佳实践与注意事项:
- 调用异步方法后,不会自动在新线程中执行。在遇到未完成的
await前,方法是完全同步执行的。 - 避免在
async方法开头执行长耗时阻塞操作,应将其剥离到单独方法或使用Task.Run异步化。 - 已完成操作的
await是性能优化设计(如缓存、已读数据),允许 API 统一返回Task但内部同步返回。
- 调用异步方法后,不会自动在新线程中执行。在遇到未完成的
5.6.3 可等待模式成员的使用(await 的完整执行链)
-
核心概念:
本节将可等待模式(5.4.1)与
await执行流程(5.6.2)结合,展示编译器在遇到await时如何依次调用可等待类型的成员:GetAwaiter()、IsCompleted、OnCompleted(如需挂起)和GetResult()(恢复后)。设计虽复杂,但编译器自动生成续延逻辑,让开发者能用自然的结构(循环、分支等)编写异步代码。 -
关键点:
-
完整调用链(图5-7):

- 获取 awaiter:调用
awaitable.GetAwaiter()。 - 检查是否完成:读取
awaiter.IsCompleted。- 若
true→ 直接跳到步骤4(同步完成,不附加续延)。 - 若
false→ 记住 awaiter,进入步骤3。
- 若
- 附加续延:调用
awaiter.OnCompleted(continuation),然后返回(方法挂起)。 - 获取结果:续延被调度后,调用
awaiter.GetResult()。- 若返回
void,表达式无值;若返回TResult,表达式得到该值。
- 若返回
- 继续执行:使用结果继续执行后续代码。
- 获取 awaiter:调用
-
设计意图:
- 若手动用回调/续延实现同等逻辑(特别是含循环、分支、异常处理时),代码将极度复杂。
async/await将“续延管理”交给编译器,开发者只需保持线性思维。- 虽然可视为“语法糖”,但其带来的可维护性提升远超简单语法糖。
-
5.6.4 异常拆封(await 如何从失败的任务中抛出异常)
-
核心概念:
Task和Task<TResult>通过AggregateException封装异常。await调用awaiter.GetResult()时,若任务失败,会抛出AggregateException内部的第一个异常,而非整个AggregateException。这一设计使异步异常处理更接近同步代码的体验。 -
关键点:
- 任务的失败表示:
- 状态为
Faulted(IsFaulted = true),Exception属性返回AggregateException(可能包含多个异常)。 - 状态为
Canceled(通过CancellationToken取消),Exception包含TaskCanceledException,GetResult()也会抛出该异常。
- 状态为
GetResult()的行为:- 若成功,返回结果值(或
void)。 - 若失败,拆封
AggregateException,仅抛出其第一个内部异常(而不是AggregateException本身)。 - 这样,
catch (HttpRequestException ex)可直接捕获,无需处理AggregateException。
- 若成功,返回结果值(或
- 多异常丢失问题:
- 当任务包含多个异常(如
Task.WhenAll返回的任务)时,await只抛出第一个,其他异常丢失。 - 若需查看所有异常,应直接检查原始任务的
Exception属性或使用Task.WhenAll后遍历。
- 当任务包含多个异常(如
- 与同步代码的对比:
- 目的:让异步异常处理“感觉”像同步
try/catch,降低心智负担。 - 但要注意,
AggregateException在异步组合场景中仍有用武之地(如Task.Wait()会抛出完整AggregateException)。
- 目的:让异步异常处理“感觉”像同步
- 任务的失败表示:
-
代码示例(清单5-5 简化版):
async Task<string> FetchFirstSuccessfulAsync(IEnumerable<string> urls)
{
var client = new HttpClient();
foreach (string url in urls)
{
try
{
// 若失败,GetResult() 抛出 HttpRequestException(不是 AggregateException)
return await client.GetStringAsync(url);
}
catch (HttpRequestException ex) // ✅ 直接捕获具体异常
{
Console.WriteLine($"Failed to fetch {url}: {ex.Message}");
}
}
throw new HttpRequestException("No URLs succeeded");
}
说明:GetStringAsync 返回的 Task<string> 若失败,await 会通过 GetResult() 抛出内部的 HttpRequestException,被 catch 捕获。
- 注意事项:
- 若任务因取消而失败,
GetResult()抛出TaskCanceledException(继承自OperationCanceledException)。 - 对于
Task.WhenAll返回的任务,await只抛出第一个异常,若要处理所有异常,应遍历任务的Exception.InnerExceptions。 - 同步阻塞方法(如
Task.Wait()和Task.Result)不会拆封,直接抛出AggregateException,两者行为不一致。
- 若任务因取消而失败,
5.6.5 完成方法(成功返回、异常封装、取消与积极校验)
-
核心概念:
async方法返回的Task是方法完成状态的“代言人”:成功时状态为RanToCompletion并设置Result;异常时状态为Faulted或Canceled,并将异常封装进AggregateException。但async方法从不直接抛出异常(即使在第一行),所有异常都通过返回的Task传递,这影响了参数校验的时机。 -
关键点:
1. 成功返回
Task<TResult>:return一个T值,由基础架构写入Task.Result。Task/void:可无return或return;,只需更新Task状态为完成。- 方法在第一个未完成的
await处返回调用方,但最终完成时才将Task置为终结态。
2. 异常封装(延迟异常)
async方法若抛出异常,该异常不会同步抛出,而是封装进返回的Task(状态Faulted)。- 即使异常发生在方法的第一行,调用方也只在
await该Task时才会收到异常(GetResult()拆封抛出)。 - 问题:参数校验(如
ArgumentNullException)也会被延迟,导致无法在调用时立即发现错误。 - 解决方案:采用“外层同步校验 + 内层异步实现”模式(代码清单5-7)。
- 外层是非
async方法,负责同步参数校验并立即抛出异常。 - 校验通过后调用内层
async方法(可独立方法、局部函数或匿名函数)执行实际异步逻辑。
- 外层是非
- 个人偏好(原文作者):使用 C# 7 的局部
async函数,既避免类污染又无委托开销。
3. 取消处理(
CancellationToken)- 通过
CancellationTokenSource生成令牌,传递给异步操作。 - 常用方式:在异步方法中调用
ThrowIfCancellationRequested(),若令牌被取消则抛出OperationCanceledException。 - 关键规则:
- 如果异步方法抛出
OperationCanceledException(或其子类TaskCanceledException),返回的Task状态为Canceled(而非Faulted)。 - 若只抛出普通异常(如
InvalidOperationException),状态为Faulted。
- 如果异步方法抛出
- 示例:即使方法无
await(纯同步抛出),只要抛出OperationCanceledException,返回的Task状态即为Canceled。 - 竞态条件澄清:因为
async方法在第一个await前是同步执行的,所以不会启动新线程,不存在竞态问题。
-
代码示例(清单5-7 积极参数校验模式):
// 外层:非 async 方法,同步校验参数(异常立即抛出)
static Task<int> ComputeLengthAsync(string text)
{
if (text == null)
throw new ArgumentNullException(nameof(text)); // 同步抛出
return ComputeLengthAsyncImpl(text);
}
// 内层:真正的 async 实现,假设参数已校验
static async Task<int> ComputeLengthAsyncImpl(string text)
{
await Task.Delay(500);
return text.Length;
}
// 调用方式不变
Task<int> task = ComputeLengthAsync(null); // 立即抛出 ArgumentNullException
- 注意事项:
- 若
async方法无await(如清单5-8),Visual Studio 会警告,但返回的Task状态仍由抛出的异常类型决定。 - 取消时,若使用
Task.Wait()或.Result同步阻塞,会收到AggregateException(内含TaskCanceledException),与await的拆封行为不同。 - 作者趋于“实用主义”:多数场景下延迟异常可接受,仅在必要时采用“外层校验”模式。
- 若
5.7 异步匿名函数(async lambda / async delegate)
-
核心概念:
异步匿名函数是“匿名函数(lambda/匿名方法)”与“异步函数(含
await)”的结合,用于创建表示异步操作的委托。其语法、行为、返回类型限制与普通async方法完全一致,但不能用于表达式树。 -
关键点:
-
语法:在普通 lambda 或匿名方法前加
async关键字。Func<Task> lambda = async () => await Task.Delay(1000); Func<Task<int>> anon = async delegate() { await Task.Delay(1000); return 10; }; -
返回类型限制:与
async方法相同:- C# 5/6:
void、Task、Task<TResult> - C# 7+:支持自定义
Task类型(如ValueTask)
- C# 5/6:
-
执行时机:
- 委托被调用时才启动异步操作(多次调用产生多个独立操作)。
await委托返回的Task不会启动操作,只会等待已启动的操作完成。
-
捕获变量:支持变量捕获,行为与普通匿名函数一致。
-
与 LINQ 配合:
- 不能用于查询表达式语法,但可调用等价方法(如
.Select)。 - 典型用法:
Select将Task<T>序列映射为其他Task序列。 - 限制:异步函数不能返回
bool,因此不能直接用于.Where。
- 不能用于查询表达式语法,但可调用等价方法(如
-
注意事项:
- 异步匿名函数不可用于表达式树(即不能赋值给
Expression<Func<...>>)。 - 示例中使用了
.Result同步阻塞,仅用于演示,生产代码应避免。
- 异步匿名函数不可用于表达式树(即不能赋值给
-
-
代码示例(清单5-9 风格):
// 定义异步 lambda
Func<int, Task<int>> function = async x =>
{
Console.WriteLine($"Starting... x={x}");
await Task.Delay(x * 1000);
Console.WriteLine($"Finished... x={x}");
return x * 2;
};
// 调用两次,并发执行
Task<int> first = function(5);
Task<int> second = function(3);
// 阻塞等待结果(仅演示,应避免)
Console.WriteLine($"First result: {first.Result}"); // 先等待 first
Console.WriteLine($"Second result: {second.Result}");
输出顺序(体现并发与等待顺序):
Starting... x=5
Starting... x=3
Finished... x=3
Finished... x=5
First result: 10
Second result: 6
5.8 C# 7 自定义 Task 类型(异步函数返回类型的扩展)
-
核心概念:
C# 7 放宽了异步函数(
async方法 / 异步匿名函数)的返回类型限制,不再仅限于void、Task和Task<TResult>,允许使用通过特定方式修饰的自定义 Task 类型。但实现自定义类型非常复杂,实际开发中几乎不需要,主要使用框架提供的ValueTask<TResult>。 -
关键点:
- 新特性:
- C# 5/6:异步函数只能返回
void、Task或Task<TResult>。 - C# 7+:允许返回自定义可等待类型(即满足特定模式,类似
await的可等待模式,但用于返回类型)。
- C# 5/6:异步函数只能返回
- 复杂性:
- 实现自定义 Task 类型需要繁琐的编码(状态机适配、接口实现等),除非实验目的,通常不推荐。
- 实际应用:
- 实际开发中主要使用
ValueTask<TResult>(已在 .NET Core 2.0+ / .NET 5+ 中提供)。 ValueTask<TResult>是值类型,适用于高频调用且结果常同步完成的场景(如缓存命中),可减少堆分配,提升性能。
- 实际开发中主要使用
- 与
await可等待模式的区别:- 之前(5.4.1)允许
await自定义可等待类型(如Task.Yield),那是消费端。 - 本节是生产端:允许
async方法返回自定义类型,而不仅仅是Task。
- 之前(5.4.1)允许
- 新特性:
-
代码示例(概念演示,实际极少手写):
// C# 7+ 允许返回自定义类型(假设 CustomTask<T> 满足编译器的要求)
public async CustomTask<int> ComputeAsync()
{
await Task.Delay(100);
return 42;
}
// 实际中推荐使用 ValueTask<TResult>
public async ValueTask<int> GetCachedOrFetchAsync(string key)
{
if (cache.TryGetValue(key, out int value))
return value; // 同步完成,无堆分配
int result = await FetchAsync(key);
cache[key] = result;
return result;
}
- 注意事项:
- 自定义返回类型必须满足编译器要求(类似
GetAwaiter模式),但文档很少,实现困难。 ValueTask<TResult>虽然性能优越,但使用时需注意:- 只能被
await一次(多次await可能抛出异常)。 - 不应在异步操作完成后继续持有(因为可能复用对象池)。
- 只能被
- 大多数场景下,仍使用
Task<TResult>即可,无需过度优化。
- 自定义返回类型必须满足编译器要求(类似
5.8.1 99.9%的情况:ValueTask<TResult>
-
核心概念:
ValueTask<TResult>是 C# 7 引入的值类型(struct),用于替代Task<TResult>,在高频率调用且结果常同步完成的场景下,可显著减少堆内存分配和 GC 压力,提升性能。但若操作经常异步挂起,则无优势甚至性能下降。 -
关键点:
- 来源与兼容性:
- .NET Core 2.0+ / .NET 5+ 原生提供,也可通过 NuGet 包
System.Threading.Tasks.Extensions用于 .NET Standard 1.0+。
- .NET Core 2.0+ / .NET 5+ 原生提供,也可通过 NuGet 包
- 核心优势:
Task<TResult>是类(引用类型),每次异步返回通常需分配堆对象。ValueTask<TResult>是值类型,在同步完成(结果已就绪)时无需堆分配,仅在真正异步挂起时才分配内部状态。
- 适用场景:
- 缓存命中:数据已在内存中,无需真正异步 I/O。
- 底层流读取:如缓存中间层,大部分调用直接返回缓存字节,无需触发异步操作。
- 高频调用、性能敏感型代码(如协议缓冲区反序列化)。
- 不适用场景:
- 大部分
await操作会挂起(如网络请求),此时ValueTask无优势,甚至因额外的结构体复制和状态管理导致性能略低于Task。
- 大部分
- 转换方法:
- 若需使用
Task.WhenAll/Task.WhenAny,可调用.AsTask()将ValueTask<TResult>转为常规Task。
- 若需使用
- 无返回值场景(
Task对应):- 对于返回
Task的异步方法,框架会缓存一个已完成的Task单例(同步完成且无异常时),通常无需使用ValueTask。
- 对于返回
- 来源与兼容性:
-
代码示例(清单5-10 简化版 —— 缓冲字节流):
public class ByteStream : IDisposable
{
private readonly Stream _stream;
private readonly byte[] _buffer = new byte[1024];
private int _bufferedBytes = 0;
private int _position = 0;
public ByteStream(Stream stream) => _stream = stream;
public async ValueTask<byte?> ReadByteAsync()
{
// 缓存中有数据 → 同步返回,无堆分配
if (_position < _bufferedBytes)
return _buffer[_position++];
// 缓存耗尽 → 异步读取
_bufferedBytes = await _stream.ReadAsync(_buffer, 0, _buffer.Length)
.ConfigureAwait(false);
_position = 0;
if (_bufferedBytes == 0) // 流末尾
return null;
return _buffer[_position++];
}
public void Dispose() => _stream.Dispose();
}
// 使用示例
using var stream = new ByteStream(File.OpenRead("file.dat"));
while ((byte? nextByte = await stream.ReadByteAsync()) is not null)
{
ConsumeByte(nextByte.Value);
}
- 注意事项:
ValueTask<TResult>只能被await一次(多次可能导致异常或不可预测行为),不可重复消费。- 不应在异步操作完成后继续持有
ValueTask的实例(可能涉及对象池复用)。 - 本例中的
ConfigureAwait(false)会在 5.10 节解释,用于避免捕获上下文,提高性能。 - Google.Protobuf 库中
CodedInputStream的原型即使用类似模式优化反序列化性能。
5.8.2 剩下0.1%的情况:创建自定义task类型
由于绝大多数时候都用不到,需要时AI即可
5.9 C#7.1中的异步Main方法
-
核心概念:
C# 7.1 允许将程序入口点
Main方法声明为async,返回Task或Task<int>,使控制台应用能直接使用await而无需自行处理GetAwaiter().GetResult()样板代码。 -
关键点:
- 传统
Main限制(C# 7.1 前):- 必须
static,名称Main,返回void或int,参数可选string[],不可泛型,不可async。
- 必须
- C# 7.1 变更:
- 允许
async Main,返回类型为Task(对应void)或Task<int>(对应int)。 - 返回类型不能是
void或自定义 Task 类型(如ValueTask)。 - 方法名仍必须是
Main(不是MainAsync)。
- 允许
- 编译器生成封装方法:
- 自动生成一个同步的“真正入口点”(满足传统签名要求)。
- 该封装方法调用
async Main,并对返回的Task调用.GetAwaiter().GetResult(),确保异常传播(类似于Main中手动task.Wait()但异常处理更干净)。
- 适用场景:
- 编写小型工具、控制台测试程序,或快速探究异步 API 时非常方便。
- 避免在主入口点中手动处理
Task的阻塞等待和异常拆封。
- 传统
-
代码示例(清单5-12 简化版):
using System;
using System.Threading.Tasks;
class Program
{
// C# 7.1+ 允许 async Main
static async Task Main()
{
Console.WriteLine("Before delay");
await Task.Delay(1000);
Console.WriteLine("After delay");
}
}
// 编译器生成的等效封装代码(示意):
// static void <Main>() // IL 中的隐藏入口
// {
// Program.Main().GetAwaiter().GetResult();
// }
- 注意事项:
- 若
async Main返回Task<int>,编译器封装会返回对应的int退出代码。 - 异常行为:
GetResult()会拆封AggregateException并抛出内部异常,与await行为一致,避免AggregateException包装。 - 该特性要求 C# 7.1 编译器(Visual Studio 2017 15.3+ 或 .NET Core 2.0+ 工具链)。
- 若
5.10 使用建议
5.10.1 使用ConfigureAwait(false)避免上下文捕获(择机使用)
-
核心概念:
ConfigureAwait(false)用于告诉await不要捕获当前同步上下文(如 UI 线程或 ASP.NET 请求上下文),而是将续延调度到线程池线程执行。在库代码或非 UI 逻辑中应择机使用,以减少不必要的上下文切换和死锁风险。 -
关键点:
- 默认行为:
await默认捕获当前SynchronizationContext,确保续延回到原上下文(如 UI 线程),便于安全更新 UI。- 但会带来性能开销(上下文切换)和潜在死锁(如在 UI 线程同步阻塞
.Result)。
ConfigureAwait(false)的作用:- 若任务未完成,续延将在线程池执行,而非原上下文。
- 若任务已同步完成,
ConfigureAwait(false)无任何影响(续延同步执行)。 - 返回类型为
ConfiguredTaskAwaitable<T>(而非Task<T>),但可直接await。
- 使用场景:
- 库代码(业务逻辑、数据库访问、Web 服务等):不需要回到 UI 或请求上下文,应使用
ConfigureAwait(false)。 - UI 代码中非 UI 逻辑部分:若后续不再访问 UI,也可使用。
- 库代码(业务逻辑、数据库访问、Web 服务等):不需要回到 UI 或请求上下文,应使用
- 注意事项:
- 必须每个
await都配置,不能只对第一个await配置后指望后续自动继承。 - 对调用方无负面影响:调用方若需更新 UI,其自己的
await仍会捕获上下文,保证 UI 更新安全。 - 当前缺乏全局配置机制,建议使用 Roslyn 分析器(如
ConfigureAwaitChecker.Analyzer)检查遗漏。
- 必须每个
- 默认行为:
-
代码示例:
// 推荐写法(库代码)
static async Task<int> GetPageLengthAsync(string url)
{
string text = await client.GetStringAsync(url)
.ConfigureAwait(false); // 续延在线程池执行
// 后续代码也在线程池(除非再次 await 未配置)
return text.Length;
}
// 不推荐的写法(默认捕获上下文)
static async Task<int> GetPageLengthAsync(string url)
{
string text = await client.GetStringAsync(url); // 续延回到原上下文
return text.Length;
}
- 常见误区澄清:
- 若
task已同步完成,ConfigureAwait(false)不会改变执行线程(仍同步执行当前线程)。 - 配置仅影响当前
await,不影响该Task后续的其他消费者。
- 若
5.10.2 启动多个独立task以实现并行
-
核心概念:
当多个异步操作之间无数据依赖时,应同时启动(即并发执行)而非串行等待,从而减少总耗时。
async/await本身不自动并行化,但通过先获取Task对象再分别await,可让多个操作并行进行(实际是异步 I/O 并发,不额外消耗线程)。 -
关键点:
-
并行启动方式:
-
先调用所有异步方法获取
Task对象(立即启动),再依次await它们。 -
示例对比:
// ❌ 串行(总耗时 = 操作1 + 操作2) decimal rate = await GetHourlyRateAsync(); int hours = await GetHoursWorkedAsync(); // 等 rate 完成才启动 // ✅ 并行(总耗时 ≈ Max(操作1, 操作2)) var rateTask = GetHourlyRateAsync(); var hoursTask = GetHoursWorkedAsync(); // 两个操作同时启动 decimal rate = await rateTask; int hours = await hoursTask;
-
-
性能原理:
- 异步 I/O(如网络请求)在等待响应时不占用线程,因此并发多个请求不会增加线程数,只利用 I/O 完成端口等机制。
- 总耗时约等于最慢的操作,而非两者之和。
-
异常处理注意事项:
- 若第一个
await抛出异常,后续的await不会执行,第二个任务可能未检查其失败(但操作已在后台运行)。 - 若需等待所有任务完成并收集全部异常,应使用
Task.WhenAll。 - 若任务间存在依赖(如先授权再执行操作),则无法并行,需按依赖顺序串行。
- 若第一个
-
代码可读性权衡:
- 可保留独立变量以提高可读性,同时保持并行(如最后一种写法)。
-
-
代码示例(优化前后):
// 串行等待(低效)
decimal rate = await employee.GetHourlyRateAsync(); // 等待完成
int hours = await timeSheet.GetHoursWorkedAsync(id); // 再启动第二个
AddPayment(rate * hours);
// 并行启动(高效)
var rateTask = employee.GetHourlyRateAsync();
var hoursTask = timeSheet.GetHoursWorkedAsync(id); // 同时发起
decimal rate = await rateTask; // 等待两者完成
int hours = await hoursTask;
AddPayment(rate * hours);
// 更紧凑的写法(同样并行)
AddPayment(
await employee.GetHourlyRateAsync() *
await timeSheet.GetHoursWorkedAsync(id)
);
- 最佳实践:
- 识别独立操作:检查任务间是否共享资源或有先后依赖。
- 优先使用并行启动:除非有依赖,否则尽量同时发起异步调用。
- 使用
Task.WhenAll处理多异常:当需要同时等待多个任务并捕获所有失败时,使用WhenAll并遍历异常。 - 避免隐式串行:注意不要先
await再启动第二个,以免丧失并发机会。
5.10.3 避免同步代码和异步代码混用
-
核心概念:
同步和异步模式之间不应随意混用。在代码中从同步上下文阻塞等待异步操作(如
.Result或.Wait())极易导致死锁,且双向封装(为同步方法写异步包装,或反之)都难以安全实现。除非完全理解底层机制,否则应避免此类混合。 -
关键点:
- 危险行为:
- 使用
Task<TResult>.Result或Task.Wait()在同步方法中阻塞等待异步操作。 - 这会导致线程被阻塞,而异步操作的续延可能需要在同一线程上执行(若同步上下文未配置为
false),从而形成死锁(如 UI 线程或 ASP.NET 请求上下文)。
- 使用
- 封装困境:
- 为仅提供同步 API 的库编写异步封装不安全(难以模拟真正的异步 I/O,且可能阻塞线程)。
- 为仅提供异步 API 的库编写同步封装同样不安全(必须阻塞等待,带来死锁和性能问题)。
- 权威建议:
- 引用 Stephen Toub 的两篇博文(结论均为“否”):
- Should I expose synchronous wrappers for asynchronous methods?
- Should I expose asynchronous wrappers for synchronous methods?
- 所有规则皆有例外,但必须完全理解风险后再考虑违反。
- 引用 Stephen Toub 的两篇博文(结论均为“否”):
- 最佳实践:
- 坚持“一路异步”原则:从底层 I/O 到顶层调用(如控制器、事件处理器)全部使用
async/await,避免在链中混入同步阻塞。 - 若必须从同步代码调用异步方法,可考虑使用
GetAwaiter().GetResult()(仍会阻塞,但异常处理更干净),或使用ConfigureAwait(false)降低死锁风险,但仍不推荐。 - 控制台应用的
Main方法已在 C# 7.1 支持async,无需再手动阻塞。
- 坚持“一路异步”原则:从底层 I/O 到顶层调用(如控制器、事件处理器)全部使用
- 危险行为:
-
代码示例(危险的反模式):
// ❌ 危险:在同步方法中阻塞等待异步操作(易死锁)
public string GetData()
{
return GetDataAsync().Result; // 或 .Wait()
}
// ✅ 推荐:保持异步链完整
public async Task<string> GetDataAsync()
{
return await FetchDataAsync();
}
- 注意事项:
- 即使在非 UI 环境(如控制台应用),
.Result仍可能隐藏异常(包装为AggregateException),降低调试体验。 - 混用模式还可能导致线程池饥饿或性能下降。
- 即使在非 UI 环境(如控制台应用),
5.10.4 根据需要提供取消机制
-
核心概念:
取消机制在异步模式中至关重要,但它依赖于整个调用链的协作(即每个异步方法都接受并传递
CancellationToken)。同步代码中没有对等概念,因此异步取消需要从设计之初就纳入考虑。 -
关键点:
- 取消是协作式的:
- 不能强制终止一个正在运行的操作,而是通过
CancellationToken请求取消,由操作内部定期检查令牌并自行终止。 - 必须在调用栈的每一层都传递同一个
CancellationToken,否则上层取消无法传播到底层 I/O。
- 不能强制终止一个正在运行的操作,而是通过
- 早期添加支持的必要性:
- 大多数现代异步 API(如
HttpClient、文件流、数据库连接)已原生支持CancellationToken参数。 - 即使当前不需要取消功能,也应在方法签名中预留可选参数(如
CancellationToken cancellationToken = default),否则后续添加将涉及破坏性变更。
- 大多数现代异步 API(如
- 如何应对不支持取消的操作:
- 若底层操作不支持取消(如旧版 API),不能简单“取消”它。
- 可考虑使用
Task.WhenAny结合超时任务(如Task.Delay)来模拟超时,但操作本身仍会在后台运行(需注意资源泄漏)。 - 参考 Stephen Toub 的文章 How do I cancel non-cancelable async operations? 了解变通方法。
- 推荐做法:
- 所有
async方法都应接受一个CancellationToken参数(即使默认值为CancellationToken.None)。 - 在方法内部,将令牌传递给所有可取消的异步调用。
- 在循环或长时间操作中定期调用
cancellationToken.ThrowIfCancellationRequested()。
- 所有
- 取消是协作式的:
-
代码示例(推荐模式):
// 方法签名预留取消令牌(即使暂未使用)
public async Task<string> FetchDataAsync(string url, CancellationToken cancellationToken = default)
{
// 传递令牌给 HttpClient(支持取消)
string result = await _httpClient.GetStringAsync(url, cancellationToken);
// 若有自定义处理,可检查取消
cancellationToken.ThrowIfCancellationRequested();
// 模拟长时间操作中的取消检查
for (int i = 0; i < data.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Process(data[i]);
}
return result;
}
- 注意事项:
- 取消令牌并非由底层操作自动响应,必须由代码显式检查并响应。
- 取消请求会触发
OperationCanceledException,调用方应捕获该异常以区分取消与失败。 - 对于无法取消的旧 API,不要强行封装,以免造成资源泄漏或不可预知行为。
5.10.5 测试异步模式
-
核心概念:
- 异步测试方法使用
async Task签名,单元测试框架原生支持(如 NUnit、xUnit、Unity Test Framework)。 Task.FromResult、Task.FromException、Task.FromCanceled可快速创建已完成的Task,用于模拟固定返回值、异常或取消。TaskCompletionSource<T>能构建尚在执行中的Task,并可在测试中手动控制完成时机(设置结果、异常或取消),适合模拟可被推迟完成的异步依赖。- 注意:
TaskCompletionSource设置结果时,关联的续延可能同步执行于同一线程,具体行为受同步上下文影响。
- 异步测试方法使用
-
Unity开发关键点:
- Unity 异步场景丰富:
UnityWebRequest.SendWebRequest、Addressables.LoadAssetAsync、AssetBundle.LoadFromFileAsync、自定义async协程等。 - Unity Test Framework 允许
[UnityTest]配合IEnumerator作为测试方法,也原生支持返回Task的异步测试。 - 模拟异步依赖时(如资源加载、网络请求),用
TaskCompletionSource代替真实异步操作,可避免测试依赖网络或磁盘 I/O,提高稳定性和速度。 - 主线程同步上下文:Unity 的同步上下文会将
await后的执行调度回主线程,测试时要注意死锁或意外的线程切换。利用UniTask可更好地控制异步行为,并支持UniTaskCompletionSource。 - 测试取消令牌(CancellationToken) 的行为时,可用
Task.FromCanceled或自行创建已取消的TaskCompletionSource。
- Unity 异步场景丰富:
-
代码示例(Unity语境):
using NUnit.Framework; using System.Threading.Tasks; using UnityEngine.TestTools; // 模拟从服务器下载配置的依赖接口 public interface IConfigLoader { Task<string> LoadConfigAsync(); } // 被测试的玩家数据管理器 public class PlayerDataManager { private IConfigLoader _configLoader; public string Config { get; private set; } public PlayerDataManager(IConfigLoader loader) => _configLoader = loader; public async Task<bool> InitializeAsync() { Config = await _configLoader.LoadConfigAsync(); return Config != null; } } [TestFixture] public class PlayerDataManagerTests { [Test] public async Task InitializeAsync_ConfigLoadedSuccessfully_ReturnsTrue() { // 用 TaskCompletionSource 模拟异步加载,稍后完成 var tcs = new TaskCompletionSource<string>(); var mockLoader = new Moq.Mock<IConfigLoader>(); mockLoader.Setup(l => l.LoadConfigAsync()).Returns(tcs.Task); var manager = new PlayerDataManager(mockLoader.Object); // 启动初始化任务 Task<bool> initTask = manager.InitializeAsync(); // 此时配置还未返回,验证任务尚未完成 Assert.IsFalse(initTask.IsCompleted); // 模拟异步加载完成 tcs.SetResult("player_config_data"); // 等待初始化完成并验证结果 bool result = await initTask; Assert.IsTrue(result); Assert.AreEqual("player_config_data", manager.Config); } [Test] public async Task InitializeAsync_ConfigLoadFaulted_PropagatesException() { var tcs = new TaskCompletionSource<string>(); tcs.SetException(new System.Exception("Network error")); var mockLoader = new Moq.Mock<IConfigLoader>(); mockLoader.Setup(l => l.LoadConfigAsync()).Returns(tcs.Task); var manager = new PlayerDataManager(mockLoader.Object); // 验证异常被抛出(使用 Assert.ThrowsAsync) Assert.ThrowsAsync<System.Exception>(async () => await manager.InitializeAsync()); } } -
常见陷阱与最佳实践:
- 忽略同步上下文:Unity 测试可能运行在主线程外的专用测试线程,
await后的代码若需要访问GameObject等主线程资源,会触发UnityException。推荐使用UniTask并配置UniTask.Yield(PlayerLoopTiming.Update)或显式切换线程。 - 测试提前终止:异步测试方法返回
Task后,框架需等待该Task完成;但若测试逻辑中开启多个异步操作且不await它们,可能导致测试提前通过但实际工作未完成。应确保所有异步任务被正确await。 TaskCompletionSource的同步延续:在 Unity 主线程上下文调用SetResult时,可能导致await后续代码同步执行,从而意外修改状态顺序。最佳实践是显式使用Task.Yield()或在测试中故意避免单线程同步依赖。- 避免真实 I/O:单元测试应全部使用
Task.FromResult或TaskCompletionSource模拟异步操作,以保证测试快速、可靠、可重复。 - 取消测试:若要测试
CancellationToken取消流程,使用Task.FromCanceled或TaskCompletionSource.SetCanceled()创建已取消的任务,而非实际操作超时。
- 忽略同步上下文:Unity 测试可能运行在主线程外的专用测试线程,

浙公网安备 33010602011771号