第三章:C#异步编程基础

第三章:C#异步编程基础


在本章中,我们将探讨 C# 异步编程中的一些基础操作,从延迟和进度报告,到任务的管理和异常处理,提供具体的使用场景和技术细节。这些内容将帮助开发者写出高效、健壮的异步代码。

3.1 暂停一段时间

在 C# 编程中,有时需要暂停程序的执行一段时间。常用的方法有两种:Task.Delay(异步非阻塞)和 Thread.Sleep(同步阻塞)。这两种方式有着截然不同的特点和应用场景。

1. Task.Delay

Task.Delay 是 C# 中用于异步暂停执行的一种机制。它创建一个表示特定时间延迟的 Task,不会阻塞当前线程,非常适合异步方法使用。

  • 使用场景

    • 实现异步等待,比如在重试机制中等待一段时间后重试。
    • 限制 API 调用频率,避免短时间内发起太多请求。
    • 在测试中模拟异步操作的延迟。
    • 在 UI 应用程序中避免长时间阻塞主线程。
  • 代码示例

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Start waiting...");
        await Task.Delay(2000); // 等待 2 秒
        Console.WriteLine("Finished waiting.");
    }
}

输出

Start waiting...
(等待 2 秒后)
Finished waiting.
  • 背后原理

    • Task.Delay 会返回一个已经计划(scheduled)的 Task,其内部通过 System.Threading.Timer 实现。
    • 延迟期间,任务处于挂起状态,不占用线程资源。延迟结束后,Task 被标记为完成,通知异步方法继续执行。
  • 最佳实践

    1. 在异步方法中使用:避免在同步方法中调用 Task.Delay.Wait()Task.Delay().GetAwaiter().GetResult(),以免阻塞线程。

    2. 使用 CancellationToken:支持取消操作,确保任务可以及时中断。

      CancellationTokenSource cts = new CancellationTokenSource();
      try
      {
          await Task.Delay(5000, cts.Token);
          Console.WriteLine("Delay completed.");
      }
      catch (TaskCanceledException)
      {
          Console.WriteLine("Delay was canceled.");
      }
      
    3. 避免长时间延迟:过长的延迟会占用定时器资源。对于长时间等待,考虑使用其他方案,例如 TimerPolling

2. Thread.Sleep

Thread.Sleep 是一种同步阻塞方法,调用时会暂停当前线程指定的毫秒数,期间线程无法执行其他任务。

  • 使用场景

    • 在单线程环境中简单暂停操作。
    • 用于早期同步编程模拟长时间操作(现代异步编程中已不推荐)。
    • 临时测试中等待(不推荐,可能导致测试不稳定)。
  • 代码示例

using System;
using System.Threading;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Start waiting...");
        Thread.Sleep(2000); // 阻塞当前线程 2 秒
        Console.WriteLine("Finished waiting.");
    }
}

输出

Start waiting...
(等待 2 秒后)
Finished waiting.
  • 背后原理
    • Thread.Sleep 会将当前线程挂起,线程进入休眠状态,从 CPU 时间片中移除。
    • 休眠期间,线程不执行任务,但依然占用系统资源,如线程栈和上下文。
    • 线程休眠结束后,需要操作系统重新调度恢复执行。

3. Task.Delay vs. Thread.Sleep

区别分析

特性 Task.Delay Thread.Sleep
阻塞行为 非阻塞 阻塞当前线程
线程资源 不占用线程 占用线程资源
适用场景 异步方法,UI 线程 同步方法,控制台应用
取消操作支持 支持(使用 CancellationToken 不支持
  • 背后原理

    • Task.Delay 使用定时器异步等待,不阻塞线程,允许 CPU 执行其他任务。
    • Thread.Sleep 会阻塞线程,导致 CPU 无法利用该线程资源做其他工作。
  • 代码对比示例

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Using Thread.Sleep:");
        Thread.Sleep(2000); // 阻塞线程
        Console.WriteLine("Thread.Sleep completed.");

        Console.WriteLine("\nUsing Task.Delay:");
        await Task.Delay(2000); // 异步非阻塞
        Console.WriteLine("Task.Delay completed.");
    }
}

输出

Using Thread.Sleep:
(等待 2 秒后)
Thread.Sleep completed.

Using Task.Delay:
(等待 2 秒后)
Task.Delay completed.

Thread.Sleep 调用期间,CPU 被阻塞,而 Task.Delay 则是异步等待,CPU 可以执行其他任务。

4. 为什么使用 Task.Delay 而不是 Thread.Sleep

  1. 非阻塞优势Task.Delay 不会阻塞线程,可以更高效地利用系统资源,适合异步编程。
  2. 线程池影响Thread.Sleep 会阻塞线程池中的工作线程,降低并发能力;Task.Delay 不占用线程池线程,支持高并发。
  3. 取消操作Task.Delay 支持取消操作,而 Thread.Sleep 无法中途取消。

5. 最佳实践

  1. 优先使用 Task.Delay:在异步方法中暂停执行时,选择 Task.Delay 而不是 Thread.Sleep
  2. 避免在 UI 线程中使用 Thread.Sleep:在 UI 应用程序(如 WPF、WinForms)中使用 Thread.Sleep 会导致界面卡顿,应使用 await Task.Delay()
  3. 支持取消操作:使用 CancellationToken 提供取消支持,以提高应用程序的灵活性。
  4. 测试代码避免使用 Thread.Sleep:测试中应使用异步等待,而非依赖固定时间延迟,避免引入不稳定因素。

3.2 返回已完成的任务

在异步编程中,有时需要立即返回一个已完成(Completed)的 Task 对象。这种情况通常用于模拟异步方法的返回值,或者在某些特定场景下无需执行任何异步操作,但又要求方法的签名是异步的。

Task.CompletedTaskTask.FromResult

C# 中有两个常用方法可以返回已完成的任务对象:

  1. Task.CompletedTask:用于返回一个已完成的、无返回值(void 等效)的任务。
  2. Task.FromResult:用于返回一个已完成的、有返回值(泛型 TResult)的任务。

使用场景

  • 模拟异步操作:在单元测试或开发中,使用已完成的任务来模拟异步方法的行为。
  • 避免不必要的异步操作:在某些条件下,异步方法无需实际执行异步逻辑时,可以直接返回已完成的任务,减少不必要的等待。
  • 接口的默认实现:当实现异步接口时,如果某个方法无需执行任何操作,可以直接返回 Task.CompletedTask

代码示例

示例 1:使用 Task.CompletedTask

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        await DoNothingAsync();
        Console.WriteLine("Completed.");
    }

    static Task DoNothingAsync()
    {
        // 无需执行实际异步操作,直接返回已完成的任务
        return Task.CompletedTask;
    }
}

输出

Completed.

示例 2:使用 Task.FromResult

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        int result = await GetNumberAsync();
        Console.WriteLine($"Result: {result}");
    }

    static Task<int> GetNumberAsync()
    {
        // 返回一个已完成的任务,包含返回值 42
        return Task.FromResult(42);
    }
}

输出

Result: 42

背后原理

  • Task.CompletedTask

    • Task.CompletedTask 是 .NET 中预先创建的一个静态只读 Task 实例,表示一个已经完成状态的任务。
    • 每次调用时不会创建新的 Task 对象,而是返回同一个已完成的任务实例,因此效率高、内存占用低。
  • Task.FromResult

    • Task.FromResult 创建并返回一个包含指定结果的已完成任务。
    • Task.CompletedTask 类似,但支持返回泛型 TResult,适用于需要返回具体结果的异步方法。
    • 内部会创建一个状态为 RanToCompletionTask<TResult> 实例,并立即将结果设置为指定值。

总结

  • Task.CompletedTaskTask.FromResult 是高效返回已完成任务的方式,适用于异步方法无需执行实际操作的场景。
  • 避免使用 Task.Run 和手动创建已完成任务的方法,这样可以减少资源浪费,提高性能。

3.3 报告进度

在异步编程中,任务执行时间较长时,及时向调用者报告进度(Progress Reporting)是提升用户体验的重要手段。C# 提供了 IProgress<T> 接口和 Progress<T> 类,帮助我们在异步方法中实现进度报告。

IProgress<T>Progress<T>

  • IProgress<T>:定义了报告进度的接口,包含 Report(T value) 方法。
  • Progress<T>:实现了 IProgress<T> 接口,常用于异步任务中报告进度,且能确保进度更新在调用线程(通常是 UI 线程)上执行。

使用场景

  • 长时间运行的异步操作需要反馈进度,如文件下载、数据处理、复杂计算。
  • UI 应用程序中更新进度条或状态信息。
  • 执行批量任务时,报告当前完成的百分比或任务状态。

代码示例

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        var progress = new Progress<int>(value => 
        {
            Console.WriteLine($"Progress: {value}%");
        });

        await ProcessDataAsync(progress);
        Console.WriteLine("Processing completed.");
    }

    static async Task ProcessDataAsync(IProgress<int> progress)
    {
        for (int i = 1; i <= 10; i++)
        {
            await Task.Delay(200); // 模拟耗时操作
            progress.Report(i * 10); // 报告进度
        }
    }
}

输出

Progress: 10%
Progress: 20%
...
Progress: 100%
Processing completed.

背后原理

  • 线程上下文切换Progress<T> 会捕获创建它的上下文(如 UI 线程),在调用 Report() 时,通过 SynchronizationContext.Post 在原始上下文上执行更新,避免跨线程操作问题。
  • 非 UI 线程场景:如果没有捕获 UI 上下文,Report() 方法会在线程池线程上调用,适用于控制台或后台任务。

最佳实践

  1. 优先使用 IProgress<T>:使用接口参数传递进度报告器,保持方法的灵活性。
  2. 避免跨线程更新 UI:在 UI 应用程序中,使用 Progress<T> 确保更新在 UI 线程执行,避免线程安全问题。
  3. 限制报告频率:避免频繁调用 Report() 方法,尤其是在高频率或循环中,在大量循环中频繁报告进度会影响性能,建议在一定间隔或重要节点上报告。
// 改善前
for (int i = 0; i < 10000; i++)
{
    progress.Report(i);
}

// 改善后
if (i % 100 == 0)
{
    progress.Report(i);
}

3.4 等待一组任务完成 (Task.WhenAll)

Task.WhenAll 是 .NET 提供的用于等待一组异步任务全部完成的方法。它会返回一个表示所有输入任务的组合任务,只有当所有任务完成时,返回的任务才会标记为完成。如果其中任何任务失败,返回的组合任务会以第一个抛出的异常结束。

  • 使用场景

    • 并行执行多个独立的异步任务,并在所有任务完成后进行进一步操作。
    • 等待一组任务并处理结果,避免依次等待造成的性能损耗。
    • 常用于需要并行调用多个 I/O 操作,例如多个 HTTP 请求或数据库查询。
  • 代码示例

public async Task ExampleAsync()
{
    var task1 = Task.Delay(1000);  // 模拟第一个异步任务,延迟1秒
    var task2 = Task.Delay(2000);  // 模拟第二个异步任务,延迟2秒
    var task3 = Task.Delay(3000);  // 模拟第三个异步任务,延迟3秒

    Console.WriteLine("开始等待所有任务完成...");
    
    // 等待所有任务完成
    await Task.WhenAll(task1, task2, task3);

    Console.WriteLine("所有任务已完成");
}

// 调用方法
await ExampleAsync();

输出

开始等待所有任务完成...
(延迟3秒后)
所有任务已完成
  • 背后原理
    • Task.WhenAll 会创建并返回一个新任务,该任务在所有传入的任务都完成后才会结束。
    • 如果所有任务都成功完成,Task.WhenAll 返回包含所有任务结果的数组。
    • 如果有任何任务抛出异常,Task.WhenAll 会将第一个抛出的异常作为 AggregateException 抛出,包含所有失败任务的异常。
    • Task.WhenAll 内部会使用 TaskCompletionSource 追踪每个任务的完成状态,直到所有任务完成为止。

最佳实践

  • 使用 Task.WhenAll 时,请确保输入的任务已经启动,否则会导致 Deadlock 或任务永远不会完成。
  • Task.WhenAll 有一个重载可以接收 IEnumerable 中的任务,但不建议直接使用这个重载,尤其是在与 LINQ 查询结合时。因为 LINQ 查询是延迟执行的,任务并不会立即启动。如果先对序列求值(如调用 .ToArray().ToList() 创建新集合),可以明确启动所有任务,让代码更清晰、行为更可预测。
async Task<string> DownloadAllAsync(HttpClient client,
    IEnumerable<string> urls)
{
    // 为每个URL定义要执行的操作
    var downloads = urls.Select(url => client.GetStringAsync(url));
    // 注意,实际上尚未开始任何任务,因为没有计算序列

    // 同时启动所有URL下载
    Task<string>[] downloadTasks = downloads.ToArray();
    // 现在所有任务都开始了

    // 异步等待所有下载完成
    string[] htmlPages = await Task.WhenAll(downloadTasks);

    return string.Concat(htmlPages);
}
  • 处理异常时,注意使用 AggregateException.Flatten(),以提取所有子任务的异常信息。
  • 如果某些任务之间存在依赖关系,Task.WhenAll 不适用,应考虑使用 await 来依次等待。
  • 尽量并行执行 CPU 密集型任务和 I/O 密集型任务,避免因为线程资源耗尽导致性能下降。

3.5 等待任意任务完成 (Task.WhenAny)

Task.WhenAny 是 .NET 中用于等待一组任务中的 任意一个任务完成 的方法。与 Task.WhenAll 不同的是,Task.WhenAny 在检测到至少有一个任务完成时就会返回,而不是等待所有任务完成。这可以用于需要对最快完成的任务优先处理的场景。

  • 使用场景

    • 需要从多个异步操作中优先处理最快完成的任务,提升响应速度。
    • 实现超时机制,结合 Task.Delay 创建的延时任务,等待指定时间内第一个完成的任务。
    • 处理多个来源的数据请求,优先返回最先完成的结果,而不是等待所有请求完成。
  • 代码示例

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var client = new HttpClient();

        // 定义两个异步请求
        var task1 = client.GetStringAsync("https://www.example.com");
        var task2 = client.GetStringAsync("https://www.microsoft.com");

        // 等待任意一个任务完成
        Task<string> completedTask = await Task.WhenAny(task1, task2);

        // 处理第一个完成的任务结果
        string result = await completedTask;
        Console.WriteLine("First completed task result:");
        Console.WriteLine(result.Substring(0, 100)); // 打印前100个字符
    }
}

输出

First completed task result:
<html><head><title>Example Domain</title>...
  • 背后原理
    • Task.WhenAny 返回一个 Task<Task>,即一个表示内部任务的包装任务。当传入的任务集合中有任意一个任务完成时,返回的包装任务会立即完成,并返回第一个完成的任务实例。
    • 由于返回的是第一个完成的任务,后续仍需 await 该任务来获取实际的结果,否则返回的只是任务本身而不是结果值。
    • Task.WhenAny 会监听所有传入的任务状态变化,通过 TaskCompletionSource 和异步回调来实现这一点,效率较高。

最佳实践

  • 使用 Task.WhenAny 时,不要忘记对返回的任务结果进行 await,以获取实际值。

  • 在使用 Task.WhenAny 等待多个任务时,最好处理未完成任务的后续状态,避免资源泄漏。例如:在处理完第一个完成的任务后,考虑取消或忽略未完成的任务。

  • 对于超时控制,结合 Task.Delay 使用。例如:

    var timeoutTask = Task.Delay(5000);
    var completedTask = await Task.WhenAny(task1, timeoutTask);
    if (completedTask == timeoutTask)
    {
        Console.WriteLine("Operation timed out.");
    }
    

    我不建议这样做,使用取消(CancellationToken)来表达超时更合乎情理,而且取消可以带来增益,它可以切实地取消任务

  • 注意异常处理:Task.WhenAny 不会直接抛出异常。如果第一个完成的任务抛出了异常,需要对其结果进行 await,才会捕获异常。x

3.6 在任务完成时处理它们

假设有个任务集合需要等待,而且需要在每个任务完成时执行一些处理。但是最好能在每个任务完成时即刻处理,而不是等待其他任务完成。

问题分析

假设有一组异步任务,每个任务都需要执行一定时间。我们希望在每个任务完成时就处理结果,而不是按顺序等待任务完成。如果按顺序等待,那么最快完成的任务也可能因为排在后面而延迟处理,造成性能浪费。

  • 不理想的示例
async Task<int> DelayAndReturnAsync(int value)
{
    await Task.Delay(TimeSpan.FromSeconds(value));
    return value;
}

async Task ProcessTasksAsync()
{
    // 创建任务列表
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    Task<int>[] tasks = { taskA, taskB, taskC };

    // 顺序等待每个任务完成
    foreach (var task in tasks)
    {
        int result = await task;
        Console.WriteLine(result);
    }
}

输出

2
3
1

问题:代码会按任务声明顺序等待,即使 taskC(延迟 1 秒)先完成,也会等到 taskAtaskB 完成后才处理。这显然不符合预期。

解决方案

async Task<int> DelayAndReturnAsync(int value)
{
    await Task.Delay(TimeSpan.FromSeconds(value));
    return value;
}

async Task AwaitAndProcessAsync(Task<int> task)
{
    int result = await task;
    Trace.WriteLine(result);
}

async Task ProcessTasksAsync()
{
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    Task<int>[] tasks = { taskA, taskB, taskC };

    IEnumerable<Task> taskQuery = 
        from t in tasks 
        select AwaitAndProcessAsync(t);
    // 或者
    // IEnumerable<Task> taskQuery = tasks.Select(t=>AwaitAndProcessAsync(t));

    Task[] processingTasks = taskQuery.ToArray();

    // 等待所有处理任务完成
    await Task.WhenAll(processingTasks);
}

3.7 避免延续同步上下文

在 C# 异步编程中,默认情况下,await 会捕获当前的同步上下文(SynchronizationContext),并在异步操作完成后,回到该上下文继续执行后续代码。在某些情况下,保留同步上下文可能会导致性能问题或死锁,尤其是在 UI 应用(如 WPF 或 WinForms)或者老版的ASP.NET程序中。因此,C# 提供了 ConfigureAwait(false) 方法,来避免捕获和回到同步上下文。

  • 使用场景

    • 在库或后台代码中执行异步操作时,通常不需要回到原来的上下文,因此应使用 ConfigureAwait(false) 来提高性能,避免不必要的上下文切换。
    • 在服务器端编程中(如 ASP.NET Core),同步上下文通常不重要,因此也建议使用 ConfigureAwait(false),避免无谓的上下文开销。
    • 在 UI 应用中,在不需要更新 UI 的地方使用 ConfigureAwait(false),避免上下文切换,从而提升异步代码的执行效率。
  • 代码示例

public async Task ExampleAsync()
{
    // 假设在 UI 线程上运行
    Console.WriteLine($"开始执行,线程ID: {Thread.CurrentThread.ManagedThreadId}");

    // 异步操作,不需要返回 UI 上下文
    await Task.Delay(1000).ConfigureAwait(false);

    // 继续执行,不会回到原来的同步上下文
    Console.WriteLine($"异步任务完成,线程ID: {Thread.CurrentThread.ManagedThreadId}");
}
  • 输出示例
开始执行,线程ID: 1
异步任务完成,线程ID: 4

在这个示例中,代码首先在主线程(假设线程 ID 为 1)上执行。当异步操作完成后,由于 ConfigureAwait(false),后续代码不会回到主线程,而是在另一个线程(例如线程 ID 为 4)上继续执行。

  • 代码示例(死锁场景)
public class DeadlockExample
{
    public async Task DelayAsync()
    {
        // 模拟一个异步操作
        await Task.Delay(1000);
    }

    public void RunWithDeadlock()
    {
        // 在同步上下文中等待异步任务完成
        // 这会导致死锁
        DelayAsync().Wait();
    }
}
  • 输出
(程序卡住,不会有任何输出,形成死锁)

解释
RunWithDeadlock 方法中,DelayAsync().Wait() 会同步等待异步任务 DelayAsync 完成。由于 await Task.Delay(1000) 捕获了当前的同步上下文,并且 Wait() 会阻塞当前线程,导致异步任务无法回到主线程继续执行,从而产生死锁。

如何解决死锁

通过使用 ConfigureAwait(false),可以避免捕获同步上下文,从而避免死锁。

  • 代码示例(避免死锁)
public class DeadlockExample
{
    public async Task DelayAsync()
    {
        // 使用 ConfigureAwait(false),避免捕获同步上下文
        await Task.Delay(1000).ConfigureAwait(false);
    }

    public void RunWithoutDeadlock()
    {
        // 不会产生死锁,因为异步操作不会回到原来的上下文
        DelayAsync().Wait();
    }
}
  • 输出
(程序执行成功,不会卡住)

解释
ConfigureAwait(false) 告诉编译器不要捕获当前的同步上下文。这样,DelayAsync 的异步部分可以在线程池中的线程继续执行,而不需要回到主线程,避免了由于 Wait() 阻塞导致的死锁问题。

  • 背后原理

    1. 同步上下文与死锁
      SynchronizationContext 是 .NET 中用于管理线程间上下文切换的机制。UI 框架(如 WPF、WinForms)使用同步上下文来确保 UI 操作在主线程上执行。在默认情况下,await 会捕获当前的同步上下文,并在异步方法完成后将代码继续执行在原来的上下文中。如果使用 Task.Wait()Task.Result 同步等待异步任务完成,而任务又在等待返回主线程继续执行,就会造成相互等待的死锁。

    2. ConfigureAwait(false)
      ConfigureAwait(false) 告诉 await 不要捕获当前的同步上下文。在异步操作完成后,后续代码可以在任何线程上执行,无需回到原来的上下文。这避免了同步等待时的上下文切换和死锁问题。

  • 最佳实践

    • 库代码:在库代码中,除非明确需要同步上下文,应该始终使用 ConfigureAwait(false) 以避免同步上下文带来的性能问题和死锁风险。

    • UI 应用:在 UI 应用中,只有在需要更新 UI 元素时,才应该依赖同步上下文。在其他地方使用 ConfigureAwait(false),避免性能开销和死锁。

    • 同步等待异步任务:尽量避免使用 Task.Wait()Task.Result 来同步等待异步任务。如果必须使用,确保通过 ConfigureAwait(false) 避免上下文捕获,防止死锁。

    • ASP.NET Core:在 ASP.NET Core 中,默认情况下没有同步上下文,推荐在所有异步操作中使用 ConfigureAwait(false),以最大化性能和线程利用率。

通过实践 ConfigureAwait(false),可以显著减少死锁风险,特别是在需要同步等待异步任务的场景中。

3.8 async Task 方法的异常处理

async Task 方法中的异常会被包装在返回的 Task 对象中,可以通过 try/catch 捕## async Task 方法的异常处理

在 C# 中,async Task 方法用于定义一个返回类型为 Task 的异步方法。与同步方法一样,异步方法也可能会抛出异常。在异步方法中,异常不会立即被抛出,而是被封装在 Task 对象中。调用代码通过 await 操作符或检查 Task 状态来捕获异常。

异常处理是异步编程中的重要一环,尤其是确保异步任务的错误能够被正确捕获和处理。理解如何在 async Task 方法中处理异常有助于编写健壮的异步代码。

  • 使用场景

    • 当异步方法可能抛出异常时,需要确保调用方能够正确捕获并处理这些异常。
    • 在依赖多个异步任务的场景中,确保每个任务的异常都能被处理,防止未捕获异常导致程序崩溃。
  • 代码示例

public async Task ThrowExceptionAsync()
{
    await Task.Delay(1000); // 模拟异步操作
    throw new InvalidOperationException("异步操作中发生异常");
}

public async Task HandleExceptionAsync()
{
    try
    {
        await ThrowExceptionAsync();
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"捕获到异常: {ex.Message}");
    }
}

// 调用方法
await HandleExceptionAsync();
  • 输出
捕获到异常: 异步操作中发生异常

在这个示例中,ThrowExceptionAsync 模拟了一个异步方法,延迟 1 秒后抛出 InvalidOperationException。在 HandleExceptionAsync 中,使用 try-catch 块捕获并处理了该异常。

  • 背后原理

    1. 异常传播
      async Task 方法中,异常不会立即抛出,而是会被封装在返回的 Task 对象中。当调用方使用 await 操作符等待该任务时,异常会在 await 行内重新抛出,从而允许调用方捕获和处理它。如果调用方没有使用 await(即直接忽略了 Task),异常将被静默地封装在 Task 中,直到调用方检查 TaskIsFaulted 属性或调用 Task.Result 时才会抛出。

    2. 未处理的异常
      如果没有使用 awaittry-catch 处理 async Task 方法中的异常,异常将导致程序崩溃或在后台线程上引发未处理的异常。TaskScheduler.UnobservedTaskException 事件可以用来捕获未处理的异常,但这通常不是最佳实践。

    3. 异常与 Task.WhenAll
      如果多个异步任务通过 Task.WhenAll 并行执行,任何一个任务的异常都会被捕获并聚合成 AggregateException。在这种情况下,处理多个任务的异常时,应该遍历 AggregateException.InnerExceptions 来处理所有异常。

  • 代码示例(多个任务的异常处理)

public async Task MultipleExceptionsAsync()
{
    var task1 = Task.Run(() => throw new InvalidOperationException("任务1失败"));
    var task2 = Task.Delay(1000);
    var task3 = Task.Run(() => throw new ArgumentException("任务3失败"));

    try
    {
        await Task.WhenAll(task1, task2, task3);
    }
    catch (Exception ex)
    {
        // 捕获多个任务中的异常
        if (ex is AggregateException aggregateException)
        {
            foreach (var innerException in aggregateException.InnerExceptions)
            {
                Console.WriteLine($"捕获到异常: {innerException.Message}");
            }
        }
        else
        {
            Console.WriteLine($"捕获到异常: {ex.Message}");
        }
    }
}

// 调用方法
await MultipleExceptionsAsync();
  • 输出
捕获到异常: 任务1失败
捕获到异常: 任务3失败

在这个示例中,Task.WhenAll(task1, task2, task3) 运行多个任务,其中 task1task3 抛出了异常。通过捕获 AggregateException,所有异常都被处理。

  • 最佳实践

    • 始终使用 await:在调用 async Task 方法时,始终使用 await 来确保任务完成并捕获异常。忽略 Task 可能会导致异常被错过,最终在程序的生命周期中某个时刻触发未处理异常。

    • 使用 try-catch 捕获异常:在包含异步调用的代码中,像处理同步异常一样,使用 try-catch 来捕获并处理异步方法中的异常。确保在 await 的代码块周围使用 try-catch,而不是在异步方法内部。

    • 处理多个任务的异常:在使用 Task.WhenAllTask.WhenAny 时,要特别注意聚合异常的处理。Task.WhenAll 会将所有任务的异常合并为一个 AggregateException,因此在处理时要遍历 InnerExceptions

    • 避免使用 Task.ResultTask.Wait():同步等待异步任务(如使用 Task.ResultTask.Wait())可能会导致死锁或无法正确捕获异常,尤其是在 UI 应用程序中。应始终使用 await 来等待异步任务。

    • 观察未处理的异常:对于未使用 await 的任务,建议通过 TaskScheduler.UnobservedTaskException 事件观察未处理的异常。尽管这不是推荐的异常处理方式,但它可以作为一个后备机制,防止未处理的异常影响应用程序的稳定性。

通过正确处理 async Task 方法中的异常,可以确保异步代码的健壮性和可靠性,避免潜在的崩溃或未处理的错误。

3.9 async void 方法的异常处理

async void 是 C# 中的一种异步方法签名,通常用于事件处理,因为事件处理程序必须返回 void。然而,与 async Task 方法不同,async void 无法返回一个 Task 对象供调用者进行等待和异常捕获,这使得 async void 方法的异常处理更加复杂和危险。

  • 使用场景

    • 仅在事件处理程序中应该使用 async void,因为事件处理程序要求返回类型为 void
    • 警告:除事件处理外,绝不建议在其他地方使用 async void,因为难以捕获其抛出的异常。
  • 代码示例

public async void AsyncVoidMethod()
{
    await Task.Delay(1000); // 模拟异步操作
    throw new InvalidOperationException("异步操作中发生异常");
}

public void StartAsyncVoidMethod()
{
    try
    {
        AsyncVoidMethod();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"捕获到异常: {ex.Message}");
    }
}

// 调用方法
StartAsyncVoidMethod();
  • 输出
(程序崩溃,异常未被捕获)

解释
在上面的示例中,AsyncVoidMethod 抛出的异常不会被 StartAsyncVoidMethod 中的 try-catch 捕获。原因是 async void 方法本质上不返回 Task,因此异常不会像在 async Task 方法中那样被传播到调用方。在 async void 方法中抛出的异常将直接导致程序崩溃,或在某些情况下触发应用程序的全局异常处理机制(如 AppDomain.UnhandledExceptionTaskScheduler.UnobservedTaskException)。

异常传播机制

  1. 无法 await
    async void 方法无法返回 Task,因此调用方无法使用 await 来等待该方法完成或捕获其中的异常。任何在 async void 方法中抛出的异常,都会绕过调用方的 try-catch 机制,直接在调用栈中传播。

  2. 全局异常处理
    async void 方法中抛出的未处理异常,会被视为未观察到的异常,并可能触发 AppDomain.UnhandledException 事件。在某些应用程序(如 WPF、WinForms)中,未处理的异常可能会导致应用程序崩溃。

解决方案

为了避免使用 async void 导致的异常处理问题,最好能将 async void 转换为 async Task,如果确实无法避免(例如在事件处理程序中),则需要在方法内部进行异常捕获和处理。

  • 代码示例(内部处理异常)
public async void AsyncVoidMethodHandled()
{
    try
    {
        await Task.Delay(1000); // 模拟异步操作
        throw new InvalidOperationException("异步操作中发生异常");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"捕获到异常: {ex.Message}");
    }
}

public void StartAsyncVoidMethodHandled()
{
    // 异常在 AsyncVoidMethodHandled 内部被捕获
    AsyncVoidMethodHandled();
}

// 调用方法
StartAsyncVoidMethodHandled();
  • 输出
捕获到异常: 异步操作中发生异常

解释
在这个示例中,将 try-catch 放在 async void 方法内部,确保了异常能够被捕获并处理,避免未捕获异常导致程序崩溃。

背后原理

  1. async void 的特殊性
    异步方法通常返回 TaskTask<T>,这样调用方可以等待任务完成并捕获异常。而 async void 无法返回 Task,因此调用方无法等待它,也无法通过 await 机制捕获异常。async void 直接将异常传播到调用栈的最顶层,导致无法通过调用方的 try-catch 捕获异常。

  2. 全局异常处理机制
    在 WPF 和 WinForms 应用程序中,未处理的异常通常会触发应用程序的全局异常处理事件,如 AppDomain.UnhandledExceptionDispatcherUnhandledException。这些异常处理机制虽然可以避免程序立即崩溃,但并不是处理异常的最佳实践,因为它们通常在异常发生时,已经无法恢复程序的正常状态。

  3. 事件处理程序中的 async void
    由于事件处理程序的签名限制,只能使用 void 作为返回类型,因此不得不使用 async void。在这种情况下,必须在 async void 方法内部捕获异常,防止未处理异常导致程序崩溃。

最佳实践

  • 尽量避免使用 async void:除非在事件处理程序中,尽量不要使用 async void。大多数异步方法应该返回 TaskTask<T>,以便调用方能够等待任务完成并捕获异常。

  • async void 中捕获异常:如果必须使用 async void(例如事件处理程序),务必在方法内部使用 try-catch 捕获并处理异常,防止未捕获的异常导致程序崩溃。

  • 转换为 async Task:如果可以,将 async void 改为 async Task,以便调用方能够等待任务完成并处理异常。例如,可以将事件处理程序中的 async void 方法包装到另一个返回 Task 的方法中:

    public async Task HandleEventAsync()
    {
        await Task.Delay(1000);
        throw new InvalidOperationException("事件处理过程中发生异常");
    }
    
    public void OnEvent(object sender, EventArgs e)
    {
        // 使用异步任务包装事件处理程序
        HandleEventAsync().ContinueWith(t =>
        {
            if (t.Exception != null)
            {
                // 处理异常
                Console.WriteLine($"捕获到异常: {t.Exception.GetBaseException().Message}");
            }
        });
    }
    
  • 全局异常处理:在某些情况下,可以为 async void 方法添加全局异常处理机制,如在 WPF 中的 DispatcherUnhandledExceptionAppDomain.UnhandledException 事件中处理未捕获的异常,作为最后的后备机制。但这通常只用于记录日志或进行最后的清理工作,因为此时程序的状态已经不可恢复。

3.10 ValueTask 的创建与使用

什么是 ValueTask

ValueTask 是 C# 中的一种结构体,用于优化异步编程中的性能。与 Task 不同,ValueTask 可以避免不必要的对象分配,特别是在高频率、短生命周期的异步操作中。Task 类总是分配一个对象来表示异步操作的状态,而 ValueTask 则可以通过结构体来避免这种分配。

ValueTask 的主要优势在于它可以表示两种情况:

  1. 同步完成的操作:如果操作已经完成,ValueTask 可以直接返回结果,而无需创建 Task 对象。
  2. 异步操作:如果操作是异步的,它也可以包装一个 Task,并在异步操作完成后返回结果。

何时使用 ValueTask

  • 高频异步调用:在频繁调用异步方法的场景下,如果大多数操作是快速完成的(甚至是同步完成的),使用 ValueTask 可以减少 Task 对象的分配成本,提升性能。
  • 异步操作可能是同步完成的:如果异步操作在某些情况下可以同步完成,使用 ValueTask 可以避免不必要的 Task 分配。

何时不应使用 ValueTask

  • 如果操作总是异步完成,并且不频繁调用,使用 Task 更加简单易用。ValueTask 的复杂性不值得为此优化。
  • ValueTask 不应该被多次 await、转换为 Task 后再多次使用,或者使用在 Task.WhenAllTask.WhenAny 等 API 中。

创建 ValueTask 的几种方式

1. 返回同步结果的 ValueTask

如果异步操作已经完成,或者你知道可以同步返回结果,可以直接创建一个同步完成的 ValueTask

public ValueTask<int> GetSyncResultAsync()
{
    // 返回一个已完成的 ValueTask,结果为 42
    return new ValueTask<int>(42);
}

在这个示例中,GetSyncResultAsync 方法返回一个同步完成的 ValueTask,它的值是 42。这里没有分配任何 Task 对象。

2. 包装 TaskValueTask

如果你已经有一个 Task,可以将它包装在 ValueTask 中。

public async Task<int> SomeAsyncOperation()
{
    await Task.Delay(1000); // 模拟异步操作
    return 42;
}

public ValueTask<int> GetAsyncResultAsync()
{
    // 包装现有的 Task
    return new ValueTask<int>(SomeAsyncOperation());
}

在这个示例中,SomeAsyncOperation 是一个异步方法,返回一个 Task<int>GetAsyncResultAsync 方法将 Task<int> 包装在 ValueTask<int> 中返回。

3. 使用 ValueTask.CompletedTask

如果你需要返回一个已经完成的异步操作(但不关心结果),可以使用 ValueTask.CompletedTask。这是一个静态的、已完成的 ValueTask,适用于不需要返回值的异步方法。

public ValueTask DoNothingAsync()
{
    // 返回已完成的 ValueTask
    return ValueTask.CompletedTask;
}

ValueTask 的使用注意事项

  1. 一次性使用ValueTask 的值只能被 await 一次。多次 await 相同的 ValueTask 会导致不可预测的行为,因为它可能会重复计算结果或返回未完成的状态。

    public async Task ExampleAsync()
    {
        ValueTask<int> valueTask = GetAsyncResultAsync();
    
        // 下面的代码是不安全的
        int result1 = await valueTask;
        int result2 = await valueTask; // 这里会出现问题,因为 ValueTask 已经被 await 过了
    }
    
  2. 避免转为 Task:虽然可以通过 ValueTask.AsTask()ValueTask 转换为 Task,但这会失去 ValueTask 的性能优势。只有在需要与 Task API 兼容的场景下才应这样做。

    public Task<int> ConvertToTask(ValueTask<int> valueTask)
    {
        return valueTask.AsTask(); // 这会导致额外的分配
    }
    
  3. Task API 的兼容性:像 Task.WhenAllTask.WhenAny 这样的 Task API 不支持直接传递 ValueTask,因此必须先将其转换为 Task,但这会导致性能损失。

    public async Task ExampleAsync(ValueTask<int>[] valueTasks)
    {
        // 这里需要将 ValueTask 转为 Task,导致性能损失
        var tasks = valueTasks.Select(vt => vt.AsTask()).ToArray();
        await Task.WhenAll(tasks);
    }
    

ValueTaskTask 的性能对比

  • Task 的开销:每次创建一个 Task 对象时,都会在堆上分配内存。对于高频异步操作,这种分配会导致大量 GC 压力。
  • ValueTask 的优势ValueTask 是一个结构体,它可以直接在栈上分配,避免了堆上的分配,减少了内存开销和 GC 压力。

但需要注意的是,ValueTask 的使用场景有限。如果异步操作大多数情况下都是异步完成的,ValueTask 的复杂性和约束可能带来更多问题,而不是性能的提升。

什么时候该使用 ValueTask

  • 性能敏感的场景:当你需要优化频繁调用的异步方法,特别是那些经常同步返回结果的场景,ValueTask 可以减少不必要的对象分配。
  • 异步操作可能是同步完成的:如果异步操作有时会同步完成,ValueTask 可以避免创建 Task 对象。

总结

  • ValueTask 是一种优化手段,能够在高频异步调用中减少 Task 对象的分配。
  • 适合用于异步操作可能同步完成的场景。
  • 使用 ValueTask 时要注意其局限性,例如它只能被 await 一次,不能随意转换为 Task 使用。
  • 在大多数场景中,Task 足够简单且性能良好,只有在性能敏感的场景中才应考虑使用 ValueTask

通过正确使用 ValueTask,可以在某些场景下显著提高异步代码的性能,减少内存分配和 GC 压力。

posted @ 2024-12-09 15:49  平元兄  阅读(100)  评论(0编辑  收藏  举报