.NET异步编程深度解析:从TAP模式基础到实战实现的完整指南

作者:微信公众号:【架构师老卢】
9-7 17:17
20

异步编程在.NET中已经走过了漫长的道路,从复杂的基于回调的方法发展到优雅的基于任务的异步模式(TAP)。对于经验丰富的开发人员来说,理解TAP不仅仅是编写非阻塞代码——它关乎设计可扩展、可维护和直观的系统。在第一部分中,我们将探讨TAP的基础知识、其约定和构建模块,然后在第二部分深入实现。

目录

  1. 异步模式的演进:从APM/EAP到TAP
  2. TAP方法命名约定:"Async"后缀及其相关规则
  3. 启动异步操作:快速启动机制
  4. 异常处理:异步方法中的错误处理
  5. 工作运行位置:理解任务执行上下文
  6. 任务的生命周期:热任务 vs 冷任务与TaskStatus
  7. 取消操作:放弃异步操作
  8. 进度报告:我们到哪了?
  9. 设计重载:取消和进度报告的组合使用

异步模式的演进:从APM/EAP到TAP
想象一下2008年左右的.NET开发人员。要异步从流中读取数据,你可能需要编写BeginReadEndRead方法(APM模式),或者触发带有相应完成事件的ReadAsync方法(EAP模式)。这些旧模式虽然有效,但笨拙且容易出错。于是TAP出现了——随.NET Framework 4引入——它使用Task类型一举代表整个异步操作。使用TAP,一个方法完成所有工作:你调用一个Async方法并获取一个代表正在进行的工作的Task(或Task<TResult>)。不再需要配对方法或自定义事件处理程序;只需等待任务,就完成了。

TAP成为首选模式的原因:

-> 简单性:TAP将异步操作浓缩为返回Task的单个方法。这种统一使代码更易于读写,特别是在有async/await关键字等语言支持的情况下。

-> 一致性:自.NET Framework 4起,TAP是新异步API的推荐方法。它利用System.Threading.Tasks库,为并发提供了强大的基础设施。

-> 语言集成:C#(async/await)、VB(Async/Await)和F#都对TAP有内置支持,使异步代码看起来几乎像同步代码,无需显式回调或线程管理。

快速比较——假设我们想在类中提供异步Read操作:

-> APM(IAsyncResult模型):暴露BeginRead(...)EndRead(...)方法。调用者开始操作,随后通过调用End并传入返回的IAsyncResult来完成操作。

-> EAP(基于事件的模型):暴露ReadAsync(...)方法(void返回)和ReadCompleted事件(带有携带结果的EventArgs)。调用者通过事件启动和处理完成。

-> TAP(基于任务):暴露单个返回Task<int>ReadAsync(...)。调用者等待任务以在准备好时获取结果。

TAP在清晰度和易用性上明显胜出。现在,让我们更深入地探讨如何命名TAP方法及其签名。

TAP方法命名约定:"Async"后缀及其相关规则
TAP方法的一个标志是其名称中的Async后缀。如果你有一个同步返回字符串的GetData方法,异步版本应称为GetDataAsync并返回Task<string>。这种命名使调用者立即明白该方法是异步的,需要等待或以其他异步方式处理。

TAP方法命名和签名的几个准则:

-> 对返回可等待类型(Task、Task<TResult>、ValueTask等)的方法使用Async后缀。例如:ReadAsyncSaveAsyncCalculateAsync

-> 避免在启动异步操作但不返回任务的方法上使用Async后缀。这些罕见情况应使用如Begin或Start的动词来表明它们不会直接返回结果以供等待。例如,一个仅启动工作并返回void的方法(在现代API中不常见)可能命名为BeginUpload而不是UploadAsync(因为没有什么可等待的)。

-> Task与Task<TResult>:如果同步对应物返回void,TAP版本返回Task(无结果)。如果同步方法返回类型TResult,TAP版本返回该类型的Task<TResult>。这样,操作的结果(如果有)将作为任务的Result或通过await可用。

-> 匹配同步方法的参数:通常,异步方法应接受与同步版本相同顺序的相同参数。但有一个大例外:任何out或ref参数。由于任务只能返回一个对象,多个输出应捆绑到元组或自定义类中作为Task<TResult>的TResult。TAP通过使用更丰富的返回类型完全避免out和ref。

-> 鼓励使用取消令牌:即使同步版本没有取消机制,考虑向异步方法添加CancellationToken参数。这使调用者能够在需要时取消操作(更多关于取消的内容 below)。

-> 进度报告(可选):如果你的操作耗时较长并能报告中间进度(如下载百分比或处理的字节数),你可以提供一个接受IProgress<T>的重载来发布进度更新。我们将在进度部分详细讨论这一点。

组合任务的方法呢?这些通常称为组合器——例如,Task.WhenAllTask.WhenAny不代表单个异步操作,而是对多个任务进行操作。组合器通常在名称中包含"all"或"any",并且不一定遵循Async后缀规则,因为它们的意图通过上下文是明显的。如果你创建仅与任务一起工作的实用方法(如重试给定任务操作的自定义RetryAsync方法),你在命名上有灵活性——在这些情况下,清晰度胜过后缀规则。

启动异步操作:快速启动机制
TAP方法通常在开始时同步完成少量工作,然后迅速启动真正的异步操作。这是设计使然——你希望方法快速返回一个任务,特别是在UI线程上调用时。任何冗长的准备或验证应在方法体中最小化,因为:

-> UI响应性:如果在UI线程上调用异步方法(在客户端应用中常见),在返回任务之前做太多工作会冻结界面。用户不会欣赏一个卡顿的按钮,因为你的DoWorkAsync方法在实际异步之前正在进行数字运算。

-> 并发性:通常你可能并行启动多个异步操作。如果每个操作都因准备工作而陷入困境,你就会失去并发性的好处。快速返回让其他操作无需延迟即可启动。

-> 快速路径完成:有时操作可能非常快,可以在异步方法内部同步完成。例如,想象一个ReadAsync发现请求的数据已在内存缓冲区中——它可以设置结果并立即完成任务,节省上下文切换的开销。TAP允许通过返回已完成的Task(如果工作立即完成)来实现这一点。从调用者的角度来看,它仍然是一个异步方法,但可能几乎瞬时完成。

如何实现快速启动?通常,你将执行基本参数检查(对无效输入抛出ArgumentNullException等),可能记录一些内容,然后启动异步工作。一旦异步操作启动(例如通过调用异步I/O方法或排队工作项),你就返回代表该操作的Task。繁重的工作在后台发生(通常在其他线程上或由操作系统完成),而你的方法已经将任务交给了调用者。

异常处理:异步方法中的错误处理
TAP中的错误处理遵循一个简单原则:使用错误立即抛出,操作错误转到Task。这意味着什么?如果调用者误用了你的方法(例如,在不允许的地方传递null),你应该同步抛出异常,就像常规方法一样。这些异常(通常是ArgumentException、ArgumentNullException等)指示错误或误用,应在开发期间捕获——它们不应该是正常运行时流的一部分。

在异步操作期间发生的所有其他异常应由任务捕获而不是直接抛出。实际上,在初始同步部分之后发生的任何异常都将存储在返回的任务中:

-> 如果异步操作失败,任务转换到Faulted状态并持有异常(可通过task.Exception访问)。如果你等待任务,这个异常(或其中之一)将在你的代码中重新抛出以进行处理。

-> 在典型场景中,一个任务最多包含一个异常(导致失败的第一个错误)。但是,如果你在多个任务上执行类似Task.WhenAll的操作,多个失败可能会聚合。在这种情况下,结果任务可能持有一个包含所有内部异常的AggregateException。但在等待时,默认情况下只重新抛出第一个异常(你仍然可以通过异常的InnerExceptions检查其他异常)。

-> 如果在异步操作期间抛出OperationCanceledException(并且它与传入的取消令牌相关联),任务以Canceled状态结束而不是Faulted。从await的角度来看,取消的任务也会抛出(它将抛出OperationCanceledException以发出取消信号)。

一个关键要点:作为异步方法的实现者,不要在任务返回后让异常完全逃逸。总是在你的操作中捕获它们并将其分配给任务(或让它们在异步方法内部冒泡,这会自动完成)。你应该直接抛出方法调用的唯一异常是那些指示“你做错了”的异常(如传递错误参数)。这样,生产代码可以假设:调用异步方法很少立即爆炸——任何运行时问题在等待任务时出现。

工作运行位置:理解任务执行上下文
TAP的一个强大方面是它不规定你如何执行异步工作——它只是标准化你如何表示它(使用Task)。作为异步方法的开发者,你可以选择工作的目标环境:

-> 你可以在线程池上运行操作(典型用于计算绑定工作或卸载I/O而不阻塞特定线程)。

-> 你可以使用操作系统或框架提供的异步I/O操作,这些操作在等待时不占用任何线程(例如,使用利用操作系统重叠I/O的Stream.ReadAsync;在这种情况下,工作在内核中并通过中断发生,而不是在专用的.NET线程上)。

-> 如果需要,你可以强制执行到特定线程或上下文(例如,一些UI框架要求某些操作在UI线程上——尽管通常你不会公开这样的TAP方法)。

-> 你甚至可以有一个异步方法本身根本不执行任何工作,但仅返回一个由系统中其他事件发出信号的Task(例如,当消息到达队列其他地方时返回完成的任务)。

从调用者的角度来看,他们只有一个Task要等待。他们可能不知道或不关心工作实际发生在哪里,这是一件好事。异步性是隐藏在Task抽象背后的实现细节。

关于调用任务的另一件事:消费者可以选择以不同方式等待。他们可能等待它(默认情况下将捕获上下文并在,比如说,UI线程上恢复)。或者他们可能使用ContinueWith来指定任务完成时的回调,甚至可能在另一个线程上或不返回原始上下文。或者他们可能阻塞在task.Wait()上(在大多数情况下不推荐,但可能)。关键是,Task提供了灵活性:它是一个代表操作的对象,可以从任何地方检查或等待。作为实现者,你应该确保返回的任务是“热”的(已经启动,见下一节),并让调用者决定他们想要如何处理其完成。

任务的生命周期:热任务 vs 冷任务与TaskStatus
.NET中的每个Task都经历由TaskStatus枚举定义的生命周期,如Created、Running、RanToCompletion、Faulted或Canceled。你在TAP中遇到的大多数任务都是热任务——意味着它们在到达你时已经启动并运行(或已完成)。例如,当你调用异步方法时,它开始执行并通常遇到await(此时它可能返回一个尚未完成的任务给你)。你不手动启动这些任务;它们是新鲜出炉的热任务。

但是,Task确实有一种无需启动即可创建的方式:如果你直接使用其构造函数实例化Task,它从Created状态开始,直到你调用Start()才会运行。这些有时称为冷任务。在TAP中,你几乎不应从公共API遇到冷任务。事实上,从TAP方法返回的所有任务在到达你时必须处于活动状态(热)。如果TAP方法内部使用Task构造函数(可能用于某些高级场景),它必须在返回任务之前对该任务调用Start()。否则,如果调用者等待冷任务,它将永远不会完成——绝对不是我们想要的!如果调用者错误地尝试在已经运行或完成的任务上调用Start,它将抛出InvalidOperationException。因此,作为规则,永远不要从TAP方法暴露冷任务——始终确保它已启动。

通常,除非做特殊事情,否则你不会在TAP中手动创建Task对象。大多数时候,你要么:

-> 使用async关键字编写方法,并让C#编译器生成Task(默认情况下在方法调用时立即热),要么

-> 使用如Task.RunTaskCompletionSource的帮助程序(分别产生已启动的任务或你控制完成的任务)。

因此,这样思考Task的生命:它要么等待运行(如果你显式创建它为冷),运行中,或已完成(三种结束状态之一:RanToCompletion、Faulted、Canceled)。TAP任务为你跳过“等待运行”阶段——它们直接进入运行或至少已调度。一旦任务完成(三种最终状态中的任何一种),它就永远保持该状态(任务是单次使用的)。此时,IsCompleted变为true,如果它是Faulted或Canceled,IsFaulted或IsCanceled将分别为true。任何附加的延续都将被触发,等待任务将根据需要给出结果、抛出异常或抛出取消异常。

取消操作:放弃异步操作
现实世界的操作可能需要取消——也许用户点击了停止按钮,或者工作在其完成时已经过时。TAP通过使用CancellationToken支持取消。这是一个可选功能:不是每个异步方法都支持取消,但许多都支持。

如果你希望你的TAP方法支持取消:

-> 添加一个CancellationToken参数,通常为可选且带有默认值(例如,CancellationToken cancellationToken = default)。按照约定,为清晰起见命名为cancellationToken。

-> 在你的实现中,你需要定期检查cancellationToken.IsCancellationRequested或在接受令牌的异步操作中使用令牌(许多框架异步方法接受令牌)。如果请求取消,你应该停止工作并发出取消信号。

-> 如何发出信号?如果你在异步方法内部,当你检测到取消时,可以简单地抛出OperationCanceledException(传递令牌)。C#编译器识别此模式并将其转换为取消的任务(即,任务以Canceled状态结束,而不是Faulted)。

-> 如果你使用低级API或TaskCompletionSource,你可能调用tcs.SetCanceled()将任务转换到Canceled状态。

重要的是,只有当你真的因取消而停止操作时,才将任务标记为Canceled。如果操作正常完成或尽管有取消请求但出现不同错误,那么任务应以RanToCompletion或Faulted结束。取消是协作式的——仅仅因为令牌被发出信号并不意味着工作必须取消。但如果你的方法可以并确实遵守请求,它以Canceled结束(这再次导致await抛出OperationCanceledException)。

如果取消令牌在开始操作之前已经发出信号怎么办?一个行为良好的TAP方法将在最开始检查:如果cancellationToken.IsCancellationRequested在开始时为true,你可以通过返回已取消的任务(或在异步方法中立即抛出OperationCanceledException,导致相同结果)来短路。这节省了做调用者想要取消的任何工作。

从消费者方面,使用取消很简单。创建CancellationTokenSource,将其Token传递给异步方法,然后如果你想中止,稍后在源上调用Cancel()。所有被给予该令牌的任务将观察到取消并在遵守时取消自己。例如:

var cts = new CancellationTokenSource();
try 
{
    string result = await DownloadStringTaskAsync(url, cts.Token);
    // 使用结果
}
catch (OperationCanceledException) 
{
    Console.WriteLine("下载被取消。");
}
// ... 稍后从另一个上下文:
cts.Cancel();

你可以为多个任务重用相同的令牌(以一起取消一组操作),或为单独操作使用单独的令牌。如果你有一个不接受取消的API,你可以传递CancellationToken.None来指示“我不取消这个”。

还有一件事:如果你调用Cancel(),任何等待的代码将获得OperationCanceledException。如果代码改为阻塞在Task.Wait()或类似上,该调用将抛出包含OperationCanceledException的AggregateException。关键是取消传播异常以发出取消信号。只需记住,如果你需要以不同于其他错误的方式处理取消,捕获该特定异常类型。

进度报告:我们到哪了?
长时间运行的操作通常有中间进度要报告(例如,文件下载可以报告已完成多少字节或百分比)。TAP不像一些旧模式那样使用事件进行进度报告;相反,它依赖IProgress<T>接口实现生产者->消费者回调机制。

如果你的异步方法可以报告进度:

-> 添加一个类型为IProgress<T>的参数(通常命名为progress)。泛型类型T是你要发送以报告进度的任何数据。它可以是简单值如int(完成百分比)、字节数,甚至是携带多条信息的复杂类型。

-> 调用者将提供IProgress<T>的实现——通常你可以只使用内置的Progress<T>类。该类接受回调(或事件)并负责分派进度更新,通常捕获同步上下文(因此进度处理程序在原始上下文上调用,例如UI线程)。

-> 从你的异步方法内部,每当你有要报告的内容时,调用progress.Report(value)。这将在内部调用消费者提供的回调。

IProgress<T>的一个优点是它为你处理线程问题。默认的Progress<T>实现将确保ProgressChanged事件或提供的委托在捕获的上下文(例如UI线程)上运行,因此你的异步方法可以从任何线程报告进度而无需问题。如果没有捕获上下文(例如,你在没有同步上下文的线程上创建了Progress<T>),它只是在线程池线程上引发进度事件。

另一个好处是灵活性:消费者可以选择如何处理进度。他们可能只关心最新值(并丢弃旧值),或缓冲它们,或每次更新UI元素。IProgress<T>抽象意味着你的方法不关心——它只是发送更新。

示例:假设我们有一个可以报告到目前为止已读取多少字节的ReadAsync方法:

public Task ReadAsync(byte[] buffer, int offset, int count, IProgress<long> progress);

如果操作分块读取数据,在每个块之后,它可以做progress?.Report(bytesReadSoFar);。如果progress为null,它什么也不做(意味着调用者对进度不感兴趣)。始终允许progress为可选(null),因此不需要进度的调用者可以调用更简单的重载或传递null。

另一个示例:FindFilesAsync方法可能不仅报告百分比,还报告部分结果(到目前为止找到的文件列表)。你可以为进度报告定义自定义类FindFilesProgressInfo或使用元组如(double percent, IReadOnlyCollection<FileInfo> resultsSoFar)。模式是灵活的——定义对你的操作有意义的进度数据类型。

进度报告通常应在进度事件发生时从异步方法内部同步完成。这可能听起来奇怪——同步引发事件(Report)——但它确保更新及时且按顺序传递。Progress<T>实现负责在需要时异步卸载到正确的上下文,因此你的异步方法不会因缓慢的UI更新而停滞。总之,如果你提供进度,只需调用Report并让消费者的IProgress<T>处理如何封送该更新。

设计重载:取消和进度报告(混合搭配)
此时,你可能想知道:如果我在API中同时支持取消和进度,我需要多少重载?可能:

-> 简单的MethodAsync(...);(无取消,无进度)。

-> MethodAsync(..., CancellationToken cancellationToken);

-> MethodAsync(..., IProgress<T> progress);

-> MethodAsync(..., CancellationToken cancellationToken, IProgress<T> progress);

总共四个,这需要大量维护。许多支持取消和进度的TAP API将仅提供极端情况:一个没有两者,一个两者都有。在.NET自己的库中,你经常看到:

Task OperationAsync(params);
Task OperationAsync(params, CancellationToken cancellationToken, IProgress<T> progress);

并且它们可能省略两个“中间”重载。为什么?因为在C#中,你总是可以通过传递CancellationToken.None进行取消(如果你不在乎)或null进行进度(如果你不想要)来调用完整的方法。这涵盖了所有场景而无需方法重载的扩散。

如果你期望基本上每个人都会使用取消或进度,你可能决定只有完整的方法(强制他们传递令牌或进度,即使是默认值)。相反,如果取消或进度对你的操作没有意义,根本不包含它们的参数——保持API表面清洁。

要点:提供有用的东西,但不要过度。两个重载(无 vs 全部)通常能很好地平衡。没有取消/进度的重载可以内部为方便起见仅使用默认令牌和/或null进度调用完整方法。

至此,我们已经涵盖了如何设计TAP方法——命名、返回内容、可选取消和进度参数等。现在让我们转变视角:我们如何实际实现这些异步方法?之后,消费者如何有效使用它们?接下来的部分将 walk through 实现TAP方法(有和没有编译器的帮助),然后使用await和组合器消费任务。

相关留言评论
昵称:
邮箱:
阅读排行