第三章: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被标记为完成,通知异步方法继续执行。
-
最佳实践:
-
在异步方法中使用:避免在同步方法中调用
Task.Delay.Wait()或Task.Delay().GetAwaiter().GetResult(),以免阻塞线程。 -
使用
CancellationToken:支持取消操作,确保任务可以及时中断。CancellationTokenSource cts = new CancellationTokenSource(); try { await Task.Delay(5000, cts.Token); Console.WriteLine("Delay completed."); } catch (TaskCanceledException) { Console.WriteLine("Delay was canceled."); } -
避免长时间延迟:过长的延迟会占用定时器资源。对于长时间等待,考虑使用其他方案,例如
Timer或Polling。
-
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
- 非阻塞优势:
Task.Delay不会阻塞线程,可以更高效地利用系统资源,适合异步编程。 - 线程池影响:
Thread.Sleep会阻塞线程池中的工作线程,降低并发能力;Task.Delay不占用线程池线程,支持高并发。 - 取消操作:
Task.Delay支持取消操作,而Thread.Sleep无法中途取消。
5. 最佳实践
- 优先使用
Task.Delay:在异步方法中暂停执行时,选择Task.Delay而不是Thread.Sleep。 - 避免在 UI 线程中使用
Thread.Sleep:在 UI 应用程序(如 WPF、WinForms)中使用Thread.Sleep会导致界面卡顿,应使用await Task.Delay()。 - 支持取消操作:使用
CancellationToken提供取消支持,以提高应用程序的灵活性。 - 测试代码避免使用
Thread.Sleep:测试中应使用异步等待,而非依赖固定时间延迟,避免引入不稳定因素。
3.2 返回已完成的任务
在异步编程中,有时需要立即返回一个已完成(Completed)的 Task 对象。这种情况通常用于模拟异步方法的返回值,或者在某些特定场景下无需执行任何异步操作,但又要求方法的签名是异步的。
Task.CompletedTask 和 Task.FromResult
C# 中有两个常用方法可以返回已完成的任务对象:
Task.CompletedTask:用于返回一个已完成的、无返回值(void等效)的任务。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,适用于需要返回具体结果的异步方法。 - 内部会创建一个状态为
RanToCompletion的Task<TResult>实例,并立即将结果设置为指定值。
总结
Task.CompletedTask和Task.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()方法会在线程池线程上调用,适用于控制台或后台任务。
最佳实践
- 优先使用
IProgress<T>:使用接口参数传递进度报告器,保持方法的灵活性。 - 避免跨线程更新 UI:在 UI 应用程序中,使用
Progress<T>确保更新在 UI 线程执行,避免线程安全问题。 - 限制报告频率:避免频繁调用
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 秒)先完成,也会等到 taskA 和 taskB 完成后才处理。这显然不符合预期。
解决方案
| 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() 阻塞导致的死锁问题。
-
背后原理:
-
同步上下文与死锁:
SynchronizationContext是 .NET 中用于管理线程间上下文切换的机制。UI 框架(如 WPF、WinForms)使用同步上下文来确保 UI 操作在主线程上执行。在默认情况下,await会捕获当前的同步上下文,并在异步方法完成后将代码继续执行在原来的上下文中。如果使用Task.Wait()或Task.Result同步等待异步任务完成,而任务又在等待返回主线程继续执行,就会造成相互等待的死锁。 -
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 块捕获并处理了该异常。
-
背后原理:
-
异常传播:
在async Task方法中,异常不会立即抛出,而是会被封装在返回的Task对象中。当调用方使用await操作符等待该任务时,异常会在await行内重新抛出,从而允许调用方捕获和处理它。如果调用方没有使用await(即直接忽略了Task),异常将被静默地封装在Task中,直到调用方检查Task的IsFaulted属性或调用Task.Result时才会抛出。 -
未处理的异常:
如果没有使用await或try-catch处理async Task方法中的异常,异常将导致程序崩溃或在后台线程上引发未处理的异常。TaskScheduler.UnobservedTaskException事件可以用来捕获未处理的异常,但这通常不是最佳实践。 -
异常与
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) 运行多个任务,其中 task1 和 task3 抛出了异常。通过捕获 AggregateException,所有异常都被处理。
-
最佳实践:
-
始终使用
await:在调用async Task方法时,始终使用await来确保任务完成并捕获异常。忽略Task可能会导致异常被错过,最终在程序的生命周期中某个时刻触发未处理异常。 -
使用
try-catch捕获异常:在包含异步调用的代码中,像处理同步异常一样,使用try-catch来捕获并处理异步方法中的异常。确保在await的代码块周围使用try-catch,而不是在异步方法内部。 -
处理多个任务的异常:在使用
Task.WhenAll或Task.WhenAny时,要特别注意聚合异常的处理。Task.WhenAll会将所有任务的异常合并为一个AggregateException,因此在处理时要遍历InnerExceptions。 -
避免使用
Task.Result或Task.Wait():同步等待异步任务(如使用Task.Result或Task.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.UnhandledException 或 TaskScheduler.UnobservedTaskException)。
异常传播机制
-
无法
await:async void方法无法返回Task,因此调用方无法使用await来等待该方法完成或捕获其中的异常。任何在async void方法中抛出的异常,都会绕过调用方的try-catch机制,直接在调用栈中传播。 -
全局异常处理:
在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 方法内部,确保了异常能够被捕获并处理,避免未捕获异常导致程序崩溃。
背后原理
-
async void的特殊性:
异步方法通常返回Task或Task<T>,这样调用方可以等待任务完成并捕获异常。而async void无法返回Task,因此调用方无法等待它,也无法通过await机制捕获异常。async void直接将异常传播到调用栈的最顶层,导致无法通过调用方的try-catch捕获异常。 -
全局异常处理机制:
在 WPF 和 WinForms 应用程序中,未处理的异常通常会触发应用程序的全局异常处理事件,如AppDomain.UnhandledException或DispatcherUnhandledException。这些异常处理机制虽然可以避免程序立即崩溃,但并不是处理异常的最佳实践,因为它们通常在异常发生时,已经无法恢复程序的正常状态。 -
事件处理程序中的
async void:
由于事件处理程序的签名限制,只能使用void作为返回类型,因此不得不使用async void。在这种情况下,必须在async void方法内部捕获异常,防止未处理异常导致程序崩溃。
最佳实践
-
尽量避免使用
async void:除非在事件处理程序中,尽量不要使用async void。大多数异步方法应该返回Task或Task<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 中的DispatcherUnhandledException或AppDomain.UnhandledException事件中处理未捕获的异常,作为最后的后备机制。但这通常只用于记录日志或进行最后的清理工作,因为此时程序的状态已经不可恢复。
3.10 ValueTask 的创建与使用
什么是 ValueTask?
ValueTask 是 C# 中的一种结构体,用于优化异步编程中的性能。与 Task 不同,ValueTask 可以避免不必要的对象分配,特别是在高频率、短生命周期的异步操作中。Task 类总是分配一个对象来表示异步操作的状态,而 ValueTask 则可以通过结构体来避免这种分配。
ValueTask 的主要优势在于它可以表示两种情况:
- 同步完成的操作:如果操作已经完成,
ValueTask可以直接返回结果,而无需创建Task对象。 - 异步操作:如果操作是异步的,它也可以包装一个
Task,并在异步操作完成后返回结果。
何时使用 ValueTask?
- 高频异步调用:在频繁调用异步方法的场景下,如果大多数操作是快速完成的(甚至是同步完成的),使用
ValueTask可以减少Task对象的分配成本,提升性能。 - 异步操作可能是同步完成的:如果异步操作在某些情况下可以同步完成,使用
ValueTask可以避免不必要的Task分配。
何时不应使用 ValueTask?
- 如果操作总是异步完成,并且不频繁调用,使用
Task更加简单易用。ValueTask的复杂性不值得为此优化。 ValueTask不应该被多次await、转换为Task后再多次使用,或者使用在Task.WhenAll、Task.WhenAny等 API 中。
创建 ValueTask 的几种方式
1. 返回同步结果的 ValueTask
如果异步操作已经完成,或者你知道可以同步返回结果,可以直接创建一个同步完成的 ValueTask。
| public ValueTask<int> GetSyncResultAsync() | |
| { | |
| // 返回一个已完成的 ValueTask,结果为 42 | |
| return new ValueTask<int>(42); | |
| } |
在这个示例中,GetSyncResultAsync 方法返回一个同步完成的 ValueTask,它的值是 42。这里没有分配任何 Task 对象。
2. 包装 Task 的 ValueTask
如果你已经有一个 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 的使用注意事项
-
一次性使用:
ValueTask的值只能被await一次。多次await相同的ValueTask会导致不可预测的行为,因为它可能会重复计算结果或返回未完成的状态。public async Task ExampleAsync() { ValueTask<int> valueTask = GetAsyncResultAsync(); // 下面的代码是不安全的 int result1 = await valueTask; int result2 = await valueTask; // 这里会出现问题,因为 ValueTask 已经被 await 过了 } -
避免转为
Task:虽然可以通过ValueTask.AsTask()将ValueTask转换为Task,但这会失去ValueTask的性能优势。只有在需要与TaskAPI 兼容的场景下才应这样做。public Task<int> ConvertToTask(ValueTask<int> valueTask) { return valueTask.AsTask(); // 这会导致额外的分配 } -
与
TaskAPI 的兼容性:像Task.WhenAll和Task.WhenAny这样的TaskAPI 不支持直接传递ValueTask,因此必须先将其转换为Task,但这会导致性能损失。public async Task ExampleAsync(ValueTask<int>[] valueTasks) { // 这里需要将 ValueTask 转为 Task,导致性能损失 var tasks = valueTasks.Select(vt => vt.AsTask()).ToArray(); await Task.WhenAll(tasks); }
ValueTask 与 Task 的性能对比
Task的开销:每次创建一个Task对象时,都会在堆上分配内存。对于高频异步操作,这种分配会导致大量 GC 压力。ValueTask的优势:ValueTask是一个结构体,它可以直接在栈上分配,避免了堆上的分配,减少了内存开销和 GC 压力。
但需要注意的是,ValueTask 的使用场景有限。如果异步操作大多数情况下都是异步完成的,ValueTask 的复杂性和约束可能带来更多问题,而不是性能的提升。
什么时候该使用 ValueTask?
- 性能敏感的场景:当你需要优化频繁调用的异步方法,特别是那些经常同步返回结果的场景,
ValueTask可以减少不必要的对象分配。 - 异步操作可能是同步完成的:如果异步操作有时会同步完成,
ValueTask可以避免创建Task对象。
总结
ValueTask是一种优化手段,能够在高频异步调用中减少Task对象的分配。- 适合用于异步操作可能同步完成的场景。
- 使用
ValueTask时要注意其局限性,例如它只能被await一次,不能随意转换为Task使用。 - 在大多数场景中,
Task足够简单且性能良好,只有在性能敏感的场景中才应考虑使用ValueTask。
通过正确使用 ValueTask,可以在某些场景下显著提高异步代码的性能,减少内存分配和 GC 压力。
浙公网安备 33010602011771号