第三章: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
的性能优势。只有在需要与Task
API 兼容的场景下才应这样做。public Task<int> ConvertToTask(ValueTask<int> valueTask) { return valueTask.AsTask(); // 这会导致额外的分配 }
-
与
Task
API 的兼容性:像Task.WhenAll
和Task.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); }
ValueTask
与 Task
的性能对比
Task
的开销:每次创建一个Task
对象时,都会在堆上分配内存。对于高频异步操作,这种分配会导致大量 GC 压力。ValueTask
的优势:ValueTask
是一个结构体,它可以直接在栈上分配,避免了堆上的分配,减少了内存开销和 GC 压力。
但需要注意的是,ValueTask
的使用场景有限。如果异步操作大多数情况下都是异步完成的,ValueTask
的复杂性和约束可能带来更多问题,而不是性能的提升。
什么时候该使用 ValueTask
?
- 性能敏感的场景:当你需要优化频繁调用的异步方法,特别是那些经常同步返回结果的场景,
ValueTask
可以减少不必要的对象分配。 - 异步操作可能是同步完成的:如果异步操作有时会同步完成,
ValueTask
可以避免创建Task
对象。
总结
ValueTask
是一种优化手段,能够在高频异步调用中减少Task
对象的分配。- 适合用于异步操作可能同步完成的场景。
- 使用
ValueTask
时要注意其局限性,例如它只能被await
一次,不能随意转换为Task
使用。 - 在大多数场景中,
Task
足够简单且性能良好,只有在性能敏感的场景中才应考虑使用ValueTask
。
通过正确使用 ValueTask
,可以在某些场景下显著提高异步代码的性能,减少内存分配和 GC 压力。