C# .NET 中的异步编程 – 基于任务的异步模式 (TAP)






2.82/5 (8投票s)
C# .NET 中的基于任务的异步模式
在之前的文章中,我们了解了 .NET 中异步编程的动机,并探讨了使用这种编程风格时最常见的陷阱和指导原则。正如我们之前提到的,async
/await
机制首次引入是在 .NET 4.5 中,但在该版本之前,还曾有过其他异步编程的尝试。具体来说,在上述 .NET 版本之前,有两种模式用于实现异步:异步编程模型模式 (APM) 和基于事件的异步模式 (EAP)。现在不应该再使用这些模式了,但它们是回顾历史的好方法。
异步模式
例如,如果我们使用 APM 来开发异步机制,我们的类将必须实现 IAsyncResult 接口 – 这意味着,对于我们的异步操作,我们需要实现两个方法:BeginOperationName
和 EndOperationName
。调用 BeginOperationName
后,应用程序可以在调用线程上继续执行其他操作,而异步操作将在新线程中运行。异步操作的结果将由调用线程通过调用 EndOperationName
方法来获取。
另一方面,支持 EAP 的类将有一个或多个命名为 MethodNameAsync
的方法。这些方法在接收到某个 Event
时被调用。这些类还应该有 MethodNameCompleted
和 MethodNameAsyncCancel
方法,这些方法的意思很明确。然而,这两种模式都使用线程,而 async/await
机制避免了这一点。此外,我们还可以注意到它们严重依赖命名约定,并且需要开发人员来同步所有这些线程,这很容易出错。
因此,从 .NET 4.5 版本开始,我们可以使用基于任务的异步模式 (TAP),它使用 Task
、Task
和 ValueTask
(自 C# 7
起)进行异步操作。虽然 EAP 和 APM 仍在使用于遗留代码中,但已不再推荐使用,应改为使用 TAP。
TAP 模式
仔细观察,我们在之前的文章中已经讨论过 TAP 模式。本质上,它是我们已经熟悉的事物的形式化,并增加了一些额外的东西。TAP 模式与 APM 和 EAP 不同,它只定义一个 async
方法,该方法可以返回 Task, Task
或 ValueTask
。此外,我们也可以在没有 async
的情况下创建它们,但那时我们需要手动处理 Task
的生命周期。这些方法通常以 Async.
作为后缀命名。此外,这些方法可以同步执行少量工作,或通过使用 await
调用其他异步方法。这实际上意味着你可以链接这些方法。
// Without async
public Task<int> OperationAsync(string input)
{
// some code
return new Task<int>().Start();
}
// With async
public async Task<int> MethodAsyncInternal(string input)
{
// code that uses await
return value;
}
使用 Task
作为返回类型的一个好处无疑是可以获取其状态。由于整个异步生命周期都封装在此 Task
类中,该生命周期的状态由 TaskStatus 枚举表示。在 TAP 方法中,Tasks
主要有两种存在方式:通过公共 Task
构造函数,或使用 await
。使用公共构造函数时,Tasks
最初处于 Created
状态,您必须通过调用 Start
方法手动安排它们。这些任务被称为“冷任务”。否则,我们的 Tasks
将被启动,并跳过 Created
状态。无论哪种方式,从 TAP 方法返回的所有 Tasks
都 **应该** 被激活。
使用 Task
的另一个好处是我们可以使用取消机制。为了使 TAP 方法可以取消,它必须接受一个 CancellationToken 作为参数,通常命名为 cancellationToken
。这是首选方法。它看起来是这样的:
public async Task<string> OperationAsync(string param1, int param2, int param3,
CancellationToken cancellationToken)
现在,取消请求可能来自应用程序的其他部分。当收到此请求时,异步操作可以取消整个操作。TAP 方法将返回一个处于 Canceled
状态的 Task
,这被认为是任务的最终状态。重要的是要注意,默认情况下没有抛出异常。
var token = new CancellationTokenSource();
string result = await OperationAsync(param1, param2, param3, token.Token);
… // at some point later, potentially on another thread
token.Cancel();
使用 TAP 时,我们可以做的另一件很酷的事情是获取每个异步操作的进度通知。这可以通过将接口 IProgress 作为参数传递给异步方法来处理。通常,此参数称为 progress
。IProgress
接口支持进度以不同的方式实现,具体取决于应用程序的需求。有一个 IProgress
接口的默认实现 – Progress.
public class Progress<T> : IProgress<T>
{
public Progress();
public Progress(Action<T> handler);
protected virtual void OnReport(T value);
public event EventHandler<T> ProgressChanged;
}
这个类的实例公开 ProgressChanged
事件。每次异步操作报告进度时都会引发此事件。该事件在创建 Progress
实例的 SynchronizationContext 上引发。如果上下文不可用,则使用默认的上下文,该上下文指向线程池。事件的处理程序通过 Progress
类的构造函数传入。进度更新也是异步引发的。
在代码中就是这样使用的
var progress = new Progress<int>(ReportProgress); int uploadedFiles = await UploadFilesAsync(listOfFiles, progress);
在这里,我们构造了一个 Progress
对象,该对象将在每次更新进度时调用 ReportProgress
方法。然后将该对象传递给 UploadFilesAsync
方法,该方法应上传大量文件,这些文件作为 listOfFiles
参数传入。每次上传文件时,进度都应该发生变化。以下是它可能的样子:
async Task<int> UploadFilesAsync(List<File> listOfFiles, IProgress<int> progress)
{
int processCount = await Task.Run<int>(() =>
{
int tempCount = 0;
foreach (var file in listOfFiles)
{
//await the uploading logic here
int processed = await UploadFileAsync(file);
if (progress != null)
{
progress.Report(tempCount);
}
tempCount++;
}
return tempCount;
});
return processCount;
}
对于文件列表中的每个已上传文件,都会启动带有已上传文件数量的 Report
方法。这将引发一个事件,该事件将被处理程序捕获。在我们的例子中,该处理程序是传递给 Progress
类构造函数的 ReportProgress
方法。
使用 TAP 进行并行化
然而,使用 TAP 或 async/await
的最大好处在于能够在同一方法内创建不同的工作流。这意味着我们可以通过多种方式组合多个 Task
,从而改变我们方法和应用程序的流程。例如,假设您的应用程序中有以下情况:
这意味着任务将按顺序执行。第二个任务将在第一个任务完成后运行,第三个任务将在第二个任务完成后运行。我们如何在方法中模拟这种情况?看看这段代码:
async Task RunWorkflow()
{
var stopwatch = Stopwatch.StartNew();
// Task 1 takes 2 seconds to be done.
await Task.Delay(2000)
.ContinueWith(task => Completed("Task 1", stopwatch.Elapsed));
// Task 2 takes 3 seconds to be done.
await Task.Delay(3000)
.ContinueWith(task => Completed("Task 2", stopwatch.Elapsed));
// Task 3 takes 1 second to be done.
await Task.Delay(1000)
.ContinueWith(task => Completed("Task 3", stopwatch.Elapsed));
// Print the final result.
Completed("Workflow: ", stopwatch.Elapsed);
stopwatch.Stop();
}
void Completed(string name, TimeSpan time)
{
Console.WriteLine($"{name} : {time}");
}
所以,在这里,我们假设 Task 1
需要两秒钟完成工作,Task 2
需要三秒钟,最后 Task 3
需要一秒钟完成。在此之后,我们使用了 ContinueWith 方法,该方法基本上是创建一个另一个任务,该任务将在 Task
完成后异步执行。另外,请记住,我为这个应用程序使用了 .NET Core 2.0。使用 .NET Core 或 .NET Framework 4.7 来运行此示例很重要。结果看起来像这样:
这样,我们就实现了顺序工作流。现在,这通常是我们不希望在应用程序中发生的事情。更常见的是,我们希望应用程序运行多个并行任务,并在它们全部完成后继续执行,就像在示例中看到的:
为了实现这一点,我们可以利用 Task
类中的 WhenAll
方法,我们在上一篇博客文章中已经遇到过。那看起来会是这样的:
async Task RunWorkflow()
{
var stopwatch = Stopwatch.StartNew();
// Task 1 takes 2 seconds to be done.
var task1 = Task.Delay(2000)
.ContinueWith(task => Completed("Task 1", stopwatch.Elapsed));
// Task 2 takes 3 seconds to be done.
var task2 = Task.Delay(3000)
.ContinueWith(task => Completed("Task 2", stopwatch.Elapsed));
// Task 3 takes 1 second to be done.
var task3 = Task.Delay(1000)
.ContinueWith(task => Completed("Task 3", stopwatch.Elapsed));
await Task.WhenAll(task1, task2, task3);
// Print the final result.
Completed("Workflow: ", stopwatch.Elapsed);
stopwatch.Stop();
}
void Completed(string name, TimeSpan time)
{
Console.WriteLine($"{name} : {time}");
}
在这里,我们没有等待每个 Task
,而是采用了一种不同的策略。首先,我们创建了每个任务的实例,然后使用了 Task
类的 WhenAll
方法。该方法创建一个任务,该任务将在所有定义的任务完成后才完成。以下是我们运行此代码时的样子:
现在,这要好得多,正如您所见,任务是并行运行的,整个工作流的持续时间与 Task 2
的操作一样长。但有时我们希望在第一个 Task
完成后立即完成工作流。想象一下,您正在从多个服务器拉取数据,并希望在第一个服务器响应后立即继续处理。我们也可以使用 Task
类来完成此操作,或者更具体地说,是 Task
类的 WhenAny
方法。工作流将如下所示:
在我们的例子中,Task 3
将首先完成执行,所以我们希望在该任务完成后立即继续处理。代码与前面的示例不会有太大差异:
async Task RunWorkflow()
{
var stopwatch = Stopwatch.StartNew();
// Task 1 takes 2 seconds to be done.
var task1 = Task.Delay(2000)
.ContinueWith(task => Completed("Task 1", stopwatch.Elapsed));
// Task 2 takes 3 seconds to be done.
var task2 = Task.Delay(3000)
.ContinueWith(task => Completed("Task 2", stopwatch.Elapsed));
// Task 3 takes 1 second to be done.
var task3 = Task.Delay(1000)
.ContinueWith(task => Completed("Task 3", stopwatch.Elapsed));
await Task.WhenAny(task1, task2, task3);
// Print the final result.
Completed("Workflow: ", stopwatch.Elapsed);
stopwatch.Stop();
}
void Completed(string name, TimeSpan time)
{
Console.WriteLine($"{name} : {time}");
}
简而言之,我们只是调用了 WhenAny
方法而不是 WhenAll
方法,它基本上创建了一个任务,该任务将在任何提供的任务完成时完成。运行此代码将在输出中打印此内容:
我们可以看到输出看起来有点奇怪。不过,这很有意义。发生的情况是,Task 3
完成了,这导致我们的 WhenAll
任务完成。反过来,这又导致我们的 Workflow
完成,并因此也完成了我们剩余的两个任务。
您可能会问自己:“嗯,有没有一种方法可以创建一个不会中断最后两个任务的工作流?”这是一个好问题。我们希望看到的是类似这样的情况:
这种情况可能发生在我们希望在每个不同任务完成后进行少量处理的情况下。这可以通过再次使用 Task
类的 WhenAll
方法和一个简单的循环来实现:
async Task RunWorkflow()
{
var stopwatch = Stopwatch.StartNew();
// Task 1 takes 2 seconds to be done.
var task1 = Task.Delay(2000)
.ContinueWith(task => Completed("Task 1", stopwatch.Elapsed));
// Task 2 takes 3 seconds to be done.
var task2 = Task.Delay(3000)
.ContinueWith(task => Completed("Task 2", stopwatch.Elapsed));
// Task 3 takes 1 second to be done.
var task3 = Task.Delay(1000)
.ContinueWith(task => Completed("Task 3", stopwatch.Elapsed));
var tasks = new List<Task>() { task1, task2, task3 };
while (tasks.Count > 0)
{
var finishedTask = await Task.WhenAny(tasks);
tasks.Remove(finishedTask);
Console.WriteLine($"Additional processing after finished Task!
{tasks.Count} more tasks remaining!");
}
// Print the final result.
Completed("Workflow: ", stopwatch.Elapsed);
stopwatch.Stop();
}
void Completed(string name, TimeSpan time)
{
Console.WriteLine($"{name} : {time}");
}
所以,当我们运行此代码时,我们将得到此输出:
结论
在本文中,我们继续探讨了 .NET 世界中异步编程这一大主题。我们回顾了一下历史,看看 .NET 如何尝试通过不同的模式来解决这个问题,最终在 .NET 4 中找到了解决方案——基于任务的异步模式或 TAP。虽然它主要是对我们已经讨论过的内容的形式化,但它为我们提供了一个很好的回顾,了解我们应该做什么和不应该做什么。此外,我们还看到了如何使用 Tasks
在应用程序中创建不同的工作流。
感谢阅读!
历史
- 2018 年 6 月 5 日:初始版本