AIGC标识 [深入解析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 框架)两条黄金法则
      1. 不要在 UI 线程执行任何长耗时操作(否则界面卡死)。
      2. 不要从 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
    • HttpClientGetStringAsync 返回 Task<string>await 后得到 string
    • Task 虽实现 IDisposable,但通常不需要手动释放(框架会处理)。

5.1.2 拆分第一个例子(awaitTask 的关系及续延机制)

  • 核心概念

    await 可作用于 Task<TResult>,其返回值是拆封后的 TResultasync 方法执行到 await 时会立即返回调用方,待异步操作完成后通过续延(continuation) 恢复执行,且默认会回到原始上下文(如 UI 线程),期间不阻塞线程。

  • 关键点

    • 拆分写法

      Task<string> task = client.GetStringAsync(url);
      string text = await task;   // 拆封 Task<string> 得到 string
      
    • await 的主要作用:避免线程阻塞,而非仅用于拆封。

    • 执行流程

      1. 同步执行 async 方法直到遇到 await 表达式。
      2. 若异步操作未完成,方法立即返回(调用方继续运行)。
      3. 异步操作完成后,续延(本质是回调)被调度执行,恢复方法剩余部分。
      4. 续延默认捕获原始上下文(如 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.ContinueWithawait
    • 令牌不同于取消令牌(CancellationToken),但理念类似——不关心背后实现,只关注允许的行为。

    4. await 的非阻塞等待

    • 遇到 await 时,若操作未完成,方法立即返回(不阻塞线程)。
    • 待操作完成后,续延被调度,恢复方法执行。
    • 类比:点比萨外卖后继续做其他事,而不是站在门口等。

    5. 异步调用链

    • 异步方法通常返回 Task/Task<T>,调用方可选择阻塞或继续 await
    • 典型模式是形成一条异步调用链,每个方法都表现出与异步操作一致的行为。
  • 典型执行流程

    1. 执行某些同步操作
    2. 启动异步操作,获取令牌(Task
    3. (可选)执行其他不依赖结果的操作
    4. 等待令牌完成(await)——此时方法返回,不阻塞
    5. 继续执行依赖结果的操作
    6. 方法完成(可能返回令牌给调用方)

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 特殊性**:
      • 传统 ASP.NET 有自定义同步上下文,需小心避免死锁(例如在请求上下文中调用 Task.Wait()Task.Result)。
      • ASP.NET Core 移除了旧的同步上下文,因此死锁风险降低,但仍需注意异步最佳实践。
    • 危险操作
      • 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所示):
    1. 调用方法(如 PrintPageLength)→ 调用 async 方法,获得 Task<int>
    2. async 方法(如 GetPageLengthAsync)→ 内部调用异步操作(如 GetStringAsync),获得 Task<string>await 后得到 string 并计算长度。
    3. 异步操作(如 HttpClient.GetStringAsync)→ 返回 Task<string>
    • 边界类型
      • async 方法返回:TaskTask<T>void(仅事件处理器),或 C# 7+ 自定义可等待类型。
      • 异步操作返回:Task<TResult> 或其他可等待模式实现。
    • 控制台示例(代码清单5-2):
      • GetPageLengthAsync 返回 Task<int>
      • PrintPageLength 同步调用 .Result 阻塞获取结果(不推荐,仅示例)。

img
img

  • 代码示例
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):

    1. 5.3 节:声明 async 方法(语法、返回类型、参数)。
    2. 5.4 节:使用 await 运算符等待异步操作。
    3. 5.5 节:方法执行完成后的返回值与续延行为。

    img

  • 注意事项

    • asyncawait 是仅有的两个新语法,但理解控制流、上下文和错误处理需要系统学习。
    • 示例中的 .Result 用于演示类型映射,实际开发应使用 await 避免死锁。

5.3 async 方法声明

  • 核心概念

    async 关键字用于标记异步方法,是方法实现细节,不影响方法签名。编译器在 IL 中省略该修饰符,因此调用方无需关心方法是否为 async,只关注返回的 TaskTask<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 方法的返回类型(voidTaskTask<TResult>)

  • 核心概念

    async 方法通过返回值与调用方交互。C# 5 只允许三种返回类型:voidTaskTask<TResult>。C# 7 增加了新的选项(后续讨论)。返回 TaskTask<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,因为异步状态机无法处理这些参数)。
  • 代码示例(返回 Taskvoid 对比):

// 正确:无返回值的异步方法返回 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 方法的参数受到特定限制,主要是为了适应异步方法的执行模型——方法可能在异步操作完成前就返回,导致引用参数无法正常赋值或交互。

  • 关键点

    • 不允许使用 outref 参数
      • 原因: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):
      1. T 必须具备可访问的 GetAwaiter() 方法(实例或扩展方法),返回 awaiter 类型。
      2. awaiter 必须实现 System.Runtime.INotifyCompletion 接口(含 void OnCompleted(Action))。
      3. awaiter 必须有 bool IsCompleted 属性(可读)。
      4. awaiter 必须有 void GetResult()TResult GetResult() 实例方法(无参数)。
    • await 表达式的类型
      • GetResult() 返回 void,则 await 表达式无返回值(如 await Task.Yield())。
      • 若返回 TResult,则 await 表达式返回 TResult(如 await task 得到 string)。
    • 成员的可访问性GetAwaiterIsCompletedGetResult 等不一定需要 public,只要能被 async 方法访问即可(罕见)。
    • 扩展方法的作用(历史)
      • C# 5 发布时 .NET 4.0 的 Task 没有 GetAwaiter 实例方法。
      • 通过扩展方法(由 NuGet 包提供)使得 C# 5 编译器能在 .NET 4.0 上使用 await Task
      • 现在 .NET 4.5+ 已原生包含,很少需要自定义扩展。
  • 代码示例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 是实例或扩展方法,但不能是 refout 参数。
    • IsCompletedtrue 时,await 可能同步执行(不挂起)。
    • 可等待模式让自定义类型(如 ValueTaskTask 兼容类型)能无缝接入 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 块或带有 catchtry 块中使用 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 拆封 Taskreturn 封装值进 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 只能作用于单一值(通常是 TaskTask<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):

    img

    • 遇到 await → 检查操作是否完成。
    • 已完成 → 获取结果 → 继续执行。
    • 未完成 → 添加续延 → 返回(方法挂起) → 续延恢复后回到“获取结果”步骤。
  • 最佳实践与注意事项

    • 调用异步方法后,不会自动在新线程中执行。在遇到未完成的 await 前,方法是完全同步执行的。
    • 避免在 async 方法开头执行长耗时阻塞操作,应将其剥离到单独方法或使用 Task.Run 异步化。
    • 已完成操作的 await 是性能优化设计(如缓存、已读数据),允许 API 统一返回 Task 但内部同步返回。

5.6.3 可等待模式成员的使用(await 的完整执行链)

  • 核心概念

    本节将可等待模式(5.4.1)与 await 执行流程(5.6.2)结合,展示编译器在遇到 await 时如何依次调用可等待类型的成员:GetAwaiter()IsCompletedOnCompleted(如需挂起)和 GetResult()(恢复后)。设计虽复杂,但编译器自动生成续延逻辑,让开发者能用自然的结构(循环、分支等)编写异步代码。

  • 关键点

    • 完整调用链(图5-7):

      img

      1. 获取 awaiter:调用 awaitable.GetAwaiter()
      2. 检查是否完成:读取 awaiter.IsCompleted
        • true → 直接跳到步骤4(同步完成,不附加续延)。
        • false → 记住 awaiter,进入步骤3。
      3. 附加续延:调用 awaiter.OnCompleted(continuation),然后返回(方法挂起)。
      4. 获取结果:续延被调度后,调用 awaiter.GetResult()
        • 若返回 void,表达式无值;若返回 TResult,表达式得到该值。
      5. 继续执行:使用结果继续执行后续代码。
    • 设计意图

      • 若手动用回调/续延实现同等逻辑(特别是含循环、分支、异常处理时),代码将极度复杂。
      • async/await 将“续延管理”交给编译器,开发者只需保持线性思维。
      • 虽然可视为“语法糖”,但其带来的可维护性提升远超简单语法糖。

5.6.4 异常拆封(await 如何从失败的任务中抛出异常)

  • 核心概念

    TaskTask<TResult> 通过 AggregateException 封装异常。await 调用 awaiter.GetResult() 时,若任务失败,会抛出 AggregateException 内部的第一个异常,而非整个 AggregateException。这一设计使异步异常处理更接近同步代码的体验。

  • 关键点

    • 任务的失败表示
      • 状态为 FaultedIsFaulted = true),Exception 属性返回 AggregateException(可能包含多个异常)。
      • 状态为 Canceled(通过 CancellationToken 取消),Exception 包含 TaskCanceledExceptionGetResult() 也会抛出该异常。
    • 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;异常时状态为 FaultedCanceled,并将异常封装进 AggregateException。但 async 方法从不直接抛出异常(即使在第一行),所有异常都通过返回的 Task 传递,这影响了参数校验的时机。

  • 关键点

    1. 成功返回

    • Task<TResult>return 一个 T 值,由基础架构写入 Task.Result
    • Task / void:可无 returnreturn;,只需更新 Task 状态为完成。
    • 方法在第一个未完成的 await 处返回调用方,但最终完成时才将 Task 置为终结态。

    2. 异常封装(延迟异常)

    • async 方法若抛出异常,该异常不会同步抛出,而是封装进返回的 Task(状态 Faulted)。
    • 即使异常发生在方法的第一行,调用方也只在 awaitTask 时才会收到异常(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:voidTaskTask<TResult>
      • C# 7+:支持自定义 Task 类型(如 ValueTask
    • 执行时机

      • 委托被调用时才启动异步操作(多次调用产生多个独立操作)。
      • await 委托返回的 Task 不会启动操作,只会等待已启动的操作完成。
    • 捕获变量:支持变量捕获,行为与普通匿名函数一致。

    • 与 LINQ 配合

      • 不能用于查询表达式语法,但可调用等价方法(如 .Select)。
      • 典型用法:SelectTask<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 方法 / 异步匿名函数)的返回类型限制,不再仅限于 voidTaskTask<TResult>,允许使用通过特定方式修饰的自定义 Task 类型。但实现自定义类型非常复杂,实际开发中几乎不需要,主要使用框架提供的 ValueTask<TResult>

  • 关键点

    • 新特性
      • C# 5/6:异步函数只能返回 voidTaskTask<TResult>
      • C# 7+:允许返回自定义可等待类型(即满足特定模式,类似 await 的可等待模式,但用于返回类型)。
    • 复杂性
      • 实现自定义 Task 类型需要繁琐的编码(状态机适配、接口实现等),除非实验目的,通常不推荐
    • 实际应用
      • 实际开发中主要使用 ValueTask<TResult>(已在 .NET Core 2.0+ / .NET 5+ 中提供)。
      • ValueTask<TResult> 是值类型,适用于高频调用结果常同步完成的场景(如缓存命中),可减少堆分配,提升性能。
    • await 可等待模式的区别
      • 之前(5.4.1)允许 await 自定义可等待类型(如 Task.Yield),那是消费端
      • 本节是生产端:允许 async 方法返回自定义类型,而不仅仅是 Task
  • 代码示例(概念演示,实际极少手写):

// 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+。
    • 核心优势
      • 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,返回 TaskTask<int>,使控制台应用能直接使用 await 而无需自行处理 GetAwaiter().GetResult() 样板代码。

  • 关键点

    • 传统 Main 限制(C# 7.1 前):
      • 必须 static,名称 Main,返回 voidint,参数可选 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,也可使用。
    • 注意事项
      • 必须每个 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>.ResultTask.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?
      • 所有规则皆有例外,但必须完全理解风险后再考虑违反
    • 最佳实践
      • 坚持“一路异步”原则:从底层 I/O 到顶层调用(如控制器、事件处理器)全部使用 async/await,避免在链中混入同步阻塞。
      • 若必须从同步代码调用异步方法,可考虑使用 GetAwaiter().GetResult()(仍会阻塞,但异常处理更干净),或使用 ConfigureAwait(false) 降低死锁风险,但仍不推荐。
      • 控制台应用的 Main 方法已在 C# 7.1 支持 async,无需再手动阻塞。
  • 代码示例(危险的反模式):

// ❌ 危险:在同步方法中阻塞等待异步操作(易死锁)
public string GetData()
{
    return GetDataAsync().Result;   // 或 .Wait()
}

// ✅ 推荐:保持异步链完整
public async Task<string> GetDataAsync()
{
    return await FetchDataAsync();
}
  • 注意事项
    • 即使在非 UI 环境(如控制台应用),.Result 仍可能隐藏异常(包装为 AggregateException),降低调试体验。
    • 混用模式还可能导致线程池饥饿或性能下降。

5.10.4 根据需要提供取消机制

  • 核心概念

    取消机制在异步模式中至关重要,但它依赖于整个调用链的协作(即每个异步方法都接受并传递 CancellationToken)。同步代码中没有对等概念,因此异步取消需要从设计之初就纳入考虑。

  • 关键点

    • 取消是协作式的
      • 不能强制终止一个正在运行的操作,而是通过 CancellationToken 请求取消,由操作内部定期检查令牌并自行终止。
      • 必须在调用栈的每一层都传递同一个 CancellationToken,否则上层取消无法传播到底层 I/O。
    • 早期添加支持的必要性
      • 大多数现代异步 API(如 HttpClient、文件流、数据库连接)已原生支持 CancellationToken 参数。
      • 即使当前不需要取消功能,也应在方法签名中预留可选参数(如 CancellationToken cancellationToken = default),否则后续添加将涉及破坏性变更。
    • 如何应对不支持取消的操作
      • 若底层操作不支持取消(如旧版 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.FromResultTask.FromExceptionTask.FromCanceled 可快速创建已完成的 Task,用于模拟固定返回值、异常或取消。
    • TaskCompletionSource<T> 能构建尚在执行中的 Task,并可在测试中手动控制完成时机(设置结果、异常或取消),适合模拟可被推迟完成的异步依赖。
    • 注意:TaskCompletionSource 设置结果时,关联的续延可能同步执行于同一线程,具体行为受同步上下文影响。
  • Unity开发关键点

    • Unity 异步场景丰富:UnityWebRequest.SendWebRequestAddressables.LoadAssetAsyncAssetBundle.LoadFromFileAsync、自定义 async 协程等。
    • Unity Test Framework 允许 [UnityTest] 配合 IEnumerator 作为测试方法,也原生支持返回 Task 的异步测试。
    • 模拟异步依赖时(如资源加载、网络请求),用 TaskCompletionSource 代替真实异步操作,可避免测试依赖网络或磁盘 I/O,提高稳定性和速度。
    • 主线程同步上下文:Unity 的同步上下文会将 await 后的执行调度回主线程,测试时要注意死锁或意外的线程切换。利用 UniTask 可更好地控制异步行为,并支持 UniTaskCompletionSource
    • 测试取消令牌(CancellationToken) 的行为时,可用 Task.FromCanceled 或自行创建已取消的 TaskCompletionSource
  • 代码示例(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.FromResultTaskCompletionSource 模拟异步操作,以保证测试快速、可靠、可重复。
    • 取消测试:若要测试 CancellationToken 取消流程,使用 Task.FromCanceledTaskCompletionSource.SetCanceled() 创建已取消的任务,而非实际操作超时。
posted @ 2026-07-28 19:18  绘星tsuki  阅读(1)  评论(0)    收藏  举报