使用任务并行库进行异步编程
为什么任务并行库对你很重要
异步编程是一个涉及很多方面的广泛主题,但其重要性怎么强调也不为过。即使是最简单的应用程序也常常具有一些功能,如果不异步实现,这些功能将无法使用,或者充其量是低效的。因此,对于 C# 开发人员来说,掌握async和await关键字的工作知识至关重要。但是,如果没有 .NET 的任务并行库 (TPL),这些关键字提供的功能将无法实现。因此,对于任何对使用 C# 进行专业异步编程感兴趣的人来说,了解 TPL 都是至关重要的。
任务并行库到底是什么?
TPL 是.NET 的System.Threading.Tasks命名空间中的一组软件 API。它最初是在 .NET Framework 4.0 版中引入的。以前的 .NET 版本有许多其他支持异步操作的 API,但它们不一致、使用起来很麻烦,并且没有内置对常用功能(如取消和进度报告)的支持。此外,TPL 还支持一定程度的异步操作控制和协调,如果开发人员尝试自己实现这些功能,则很难实现。
任务:所有异步事物的抽象
首先,简要说明一下术语:虽然异步编程和多线程编程经常在同一个上下文中被提及,但它们并不是一回事。异步编程更通用一点,因为它与延迟有关(由于某种原因,您的应用程序必须等待),而多线程编程是一种实现并行化的方法(您的应用程序必须同时执行一项或多项操作)。也就是说,这两个主题密切相关;并行执行多个线程工作的应用程序通常需要等到这些工作完成后才能采取某些操作(例如更新用户界面)。因此,无论线程数如何,等待的想法都是术语“异步”所指的更通用的特征。
所有这些与 TPL 有什么关系?好吧,TPL 被引入是为了解决并行化问题,因此得名任务并行库,它的许多 API 都处理特定于多线程编程的概念。但是,正如我们所了解的,多线程编程的要求与一般异步编程的要求非常相似。TPL 利用了这一事实,并引入了一个称为任务的漂亮抽象,它可用于应用程序需要等待的任何事情。需要在单独的线程上执行一些复杂的 CPU 密集型操作?这就是一项任务。需要从远程网络下载某些东西?这也是一项任务。本地 I/O 操作(例如将文件保存到磁盘)也可以表示为任务。您甚至可以聚合多个不同的任务(一些涉及线程,一些不涉及),并等待它们所有,就好像它们是单个任务一样。
任务并行库的实践
让我们考虑一个例子来了解 TPL 的任务是如何运作的。假设您正在编写一个将处理远程图像的 .NET Core 控制台应用程序。假设您需要从互联网下载图像,对该图像应用模糊处理,然后将其保存到磁盘。现在,通常控制台应用程序同步是可以的,但假设您想要一个以毫秒为单位不断更新的实时仪表板,例如
while (!done)
{
Console.CursorLeft = 0;
Console.Write(System.DateTime.Now.ToString("HH:mm:ss.fff"));
Thread.Sleep(50);
}
为了使这样的仪表板保持可靠地最新状态,您需要异步执行 I/O 和图像处理操作。使用 TPL,您可以通过在返回 Task 的方法中执行此类操作来实现这一点:
static Task<byte[]> DownloadImage(string url) { ... }
static Task<byte[]> BlurImage(string imagePath) { ... }
static Task SaveImage(byte[] bytes, string imagePath) { ... }
请注意,当您想要为特定Task返回某些内容时, Task可以具有通用参数T。在此示例中,对于这两种方法,您都希望返回已下载或模糊的图像的字节数组。在我们的SaveImage方法中,图像数据被写入磁盘,并且没有返回任何内容。
现在来看看代码的主要部分,我们调用上述函数。假设我们只处理 JPEG 图像。
bool done = false;
var url = "https://...jpg";
var fileName = Path.GetFileName(url);
DownloadImage(url).ContinueWith(task1 =>
{
var originalImageBytes = task1.Result;
var originalImagePath = Path.Combine(ImageResourcesPath, fileName);
SaveImage(originalImageBytes, originalImagePath).ContinueWith(task2 =>
{
BlurImage(originalImagePath).ContinueWith(task3 =>
{
var blurredImageBytes = task3.Result;
var blurredFileName = $"{Path.GetFileNameWithoutExtension(fileName)}_blurred.jpg";
var blurredImagePath = Path.Combine(ImageResourcesPath, blurredFileName);
SaveImage(blurredImageBytes, blurredImagePath).ContinueWith(task4 =>
{
done = true;
});
});
});
});
while (!done) { /* update the dashboard */ }
Console.WriteLine("Done!");
请注意,对于每个任务,我们都使用名为ContinueWith的函数添加所谓的延续。延续是一项新任务,当前项(即上一个)任务完成时,TPL 自动启动它。因此,我们预先定义了一系列操作,TPL 监视并协调何时调用每个操作。应用程序的执行继续快速完成任务定义,然后进入底部仪表板的while循环。由于我们与任务异步执行所有昂贵且潜在的操作,因此每个任务都可以根据需要花费任意长的时间,而不会影响仪表板的实时更新。
这是否意味着每个Task都在单独的线程上运行?要真正知道这个问题的答案,我们需要查看 DownloadImage 、 SaveImage和BlurImage方法的实现。也就是说,Task抽象的美妙之处在于,对于我们在此处编写的调用代码而言,我们不需要知道。
向一组任务添加延续
我们可以进一步扩展我们的例子,对多张图片执行相同的操作。在这种情况下,我们希望等到所有图片处理完后再退出应用程序。实现这一点的一种方法是保存对链中最后每个任务的引用,即与保存每个模糊图像相对应的任务。如果我们维护这些任务的列表,当我们到达最后一张图片时,我们可以使用Task.WhenAll将它们全部聚合到一个任务中,然后我们可以通过ContinueWith再次向该任务添加延续:
var saveBlurImageTasks = new List<Task>();
foreach (var url in urls)
{
var fileName = Path.GetFileName(url);
DownloadImage(url).ContinueWith(task1 =>
{
var originalImageBytes = task1.Result;
var originalImagePath = Path.Combine(ImageResourcesPath, fileName);
SaveImage(originalImageBytes, originalImagePath).ContinueWith(task2 =>
{
BlurImage(originalImagePath).ContinueWith(task3 =>
{
var blurredImageBytes = task3.Result;
var blurredFileName = $"{Path.GetFileNameWithoutExtension(fileName)}_blurred.jpg";
var blurredImagePath = Path.Combine(ImageResourcesPath, blurredFileName);
var saveBlurImageTask = SaveImage(blurredImageBytes, blurredImagePath);
saveBlurImageTasks.Add(saveBlurImageTask);
if (saveBlurImageTasks.Count == urls.Count)
{
Task.WhenAll(saveBlurImageTasks).ContinueWith(finalTask =>
{
done = true;
});
}
});
});
});
}
任务并行库的高级功能
如您所见,TPL 主要由Task类和相关函数组成。到目前为止,我们只是触及了 TPL 的皮毛。Task 类中有许多额外的静态方法,其中一些方法为任务集提供额外的操作。但是,即使对于单个任务,您也可以自定义其行为的很多不同方面。例如,如果您想根据任务是否失败、被取消或成功完成有条件地执行延续,您可以通过向ContinueWith方法提供对TaskContinuationOptions的选择来实现。还可以使用该枚举配置许多优化。
您还可以控制任务是否、何时以及如何与线程相对应。例如,您可以创建自己的TaskScheduler类实现并自定义任务排队到线程的方式。您还可以指定是否希望延续任务在主应用程序线程上运行,即使先前的任务在线程池中的线程上运行。
最后,如前所述,TPL 通过其 API 中的CancellationToken实现一致取消,并且可以使用.NET Framework 4.5 版中引入的IProgress<T>接口进行进度报告。
异常处理和注意事项
TPL 具有一组非常强大的 API,但其极高的灵活性也存在一些缺点。作为示例,让我们简要了解一下使用 TPL 的异常处理。任务完全封装了它们的异常,这意味着任务代码中发生的异常不会中断应用程序的执行,因此您不能只使用来自调用方的try / catch。相反,您必须检查已完成的任务状态和其他属性,以查看是否发生故障以及原因。在具有高度并行化的复杂应用程序中(即许多线程同时运行),这种异常封装可能正是您想要的。如果任务遇到任何异常,将设置AggregateException类型的异常。您可以遍历聚合的InnerExceptions并做出相应的反应。
if (task.Status == TaskStatus.Faulted && task.Exception != null)
{
foreach (var ex in task.Exception.InnerExceptions)
{
Console.WriteLine($"Exception: {ex}");
}
}
刚开始使用 TPL 的开发人员经常会感到困惑,因为他们的应用程序在没有任何异常迹象的情况下出现意外行为,所以一定要记住这一点。您几乎总是希望至少添加某种日志记录。
TPL 的另一个不太理想的方面是,为了获得任务的结果,您通常需要设置回调 - 即任务完成时调用的方法。我们上面通过ContinueWith设置的延续 lambda就是这种情况的示例。回调是异步编程中使用的经过验证的模式,但正如我们在示例中看到的,它可能有点难以阅读,因为每个回调都缩进并用额外的括号和圆括号标记。对于 C# 开发人员来说幸运的是,async和await关键字的创建部分是为了缓解这一确切问题。
持久的创新
任务并行库已被证明非常重要。它不仅使 C# 开发人员的异步编程更加一致、可靠和灵活,还为语言级别的异步编程革命性方法(即 C# 的async和await关键字)奠定了基础。本系列的下一篇指南将探讨async和await如何基于任务并行库的成功,使异步编程变得更好。
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~