将 Task.Run 与 Async/Await 结合使用
单独线程上的 Async/Await
C# 中的async/await方法之所以很棒,部分原因是它将等待的异步概念与其他细节隔离开来。因此,当您在第三方库或 .NET 本身中等待预定义方法时,您不必关心所等待操作的性质。如果预定义方法返回Task,则只需将调用方法标记为async并在方法调用前面放置await关键字。了解与 await 关键字相关的控制流很有帮助,但基本上就是这样。
但是当您需要调用一些不返回Task 的预定义方法时该怎么办?如果是一些快速执行的简单操作,那么您可以同步调用它,而不需要await。但如果它是一个长时间运行的操作,您可能需要找到一种方法使其异步。对于具有图形用户界面的应用程序尤其如此,当同步执行长时间运行的操作时,它可能会显得僵硬。
将同步操作转换为异步操作的一种方法是将其运行在单独的线程上,这就是Task.Run的作用所在。Run方法将代码排队以在不同线程上运行(通常来自“线程池”,这是一组由 .NET 为您的应用程序管理的工作线程)。而且,重要的是,Task.Run返回一个Task,这意味着您可以将await关键字与它一起使用!
因此,让我们探索将Task.Run与async/await结合使用。 它并不难使用,但正如我们将看到的,知道何时使用它并不那么简单。
Task.Run 概览
让我们看一个Task.Run的简单示例,以了解其语法:
async void OnButtonClick()
{
await Task.Run(() => /* your code here*/);
}
Task.Run接受一个Action(或者在需要返回值的情况下接受一个Func<T> ),因此非常灵活。您可以在线编写代码,例如:
await Task.Run(() => DoExpensiveOperation(someParameter));
...或者在一个块内,例如:
await Task.Run(() =>
{
for (int i = 0; i < int.MaxValue; i++)
{
...
}
});
对于没有参数的单个方法,只需传递方法的名称:
await Task.Run(MyMethod);
无论使用哪种语法,执行都以相同的方式进行:释放当前线程,并在线程池中的线程上执行传入的代码。但是,执行完成后,调用方法的其余部分将在哪个线程中执行?大多数情况下,您希望它在调用await时所在的原始线程上继续执行,尤其是在具有图形用户界面的应用程序的情况下,您需要在主应用程序线程上更新 UI 元素。幸运的是,await捕获当前SynchronizationContext,其中包括有关当前线程的信息,并且默认情况下在完成后自动返回到该线程。
注意:没有 await 的行为更加棘手,因此,除非您了解这些细微差别,否则请务必将await与Task.Run一起使用,以避免意外行为。
CPU 与 I/O 密集型代码
让我们来解决何时使用Task.Run的问题。为此,我们需要了解 CPU 密集型代码和 I/O 密集型代码之间的区别。首先,什么是 CPU 密集型代码?通过说某些东西“受”CPU 的“限制”,我们基本上是在说计算机的处理器(或处理器中运行的特定线程)是瓶颈。您的处理器正在尽可能快地执行某些计算,但它仍然需要很长时间才能导致明显的延迟。换句话说,CPU 阻碍了您在该代码中实现更快的性能。您也可以说延迟是由 CPU引起的。
相比之下,对于 I/O 密集型代码,输入或输出过程的数据传输速率是瓶颈。这可能是本地输入/输出过程,例如读取或将文件保存到本地存储,也可能是与远程服务器通信以上传或下载某些内容。在这两种情况下,CPU 都处于某种空闲状态,因为它正在等待完成发送或接收某些数据。如果网址无响应,则 CPU 几乎完全处于空闲状态,只是等待开始数据传输。
如您所见,尽管两种情况下都存在延迟,但操作的性质却截然不同。在编写代码时,请尝试在使用await时对每个异步操作进行分类。问问自己它是 CPU 密集型操作还是 I/O 密集型操作。但这真的很重要吗?
了解异步操作的本质
是的,异步操作的性质很重要。简而言之,通过Task.Run在单独的线程上启动操作主要用于 CPU 密集型操作,而不是 I/O 密集型操作。但为什么呢?
大多数现代计算机处理器都有多个核心,这有点像拥有多个处理器。当您的应用程序开始运行时,.NET 运行时会为您的应用程序创建一个线程池。此池的默认大小通常与处理器中的核心数相对应。如果应用程序的主线程使一个核心处于繁忙状态,您可以通过使用Task.Run传递一些工作来利用其他核心。
请注意,尽管相关,但线程≠核心。线程是软件概念,核心是硬件组件。您可以创建任意数量的线程,但核心数量是固定的。也就是说,当您调用Task.Run时,线程将在其他核心上运行(如果可用),因为这将提供最大的性能优势。
这就是带有 CPU 绑定操作的Task.Run 。如果将Task.Run与 I/O 操作一起使用,则您正在创建一个线程(并且可能占用一个 CPU 核心),该线程大部分时间都在等待。这可能是一种让您的应用程序保持响应的快速简便的方法,但它不是最有效的系统资源利用方式。更好的方法是使用await 而不使用 Task.Run进行 I/O 操作。这将具有保持应用程序响应的相同积极效果,但不会浪费地占用额外的线程/核心。
为了使用await而不使用Task.Run进行 I/O 操作,您需要使用返回Task的异步方法,而无需调用Task.Run本身。当使用 .NET 内置的某些类(例如FileStream和HttpClient )时,这很简单,它们为此目的提供了异步方法。但您可能会发现 .NET 或第三方库中的某些类仅提供同步方法,在这种情况下,您可能被迫使用Task.Run来实现异步,即使它只是一个 I/O 操作。此外,虽然这不是推荐的做法,但有些第三方库只是将对同步方法的调用“包装”为对Task.Run的调用,本质上是代表您调用Task.Run。这可能不会给您的特定应用程序带来任何问题,但请注意这些可能性,并优先使用没有Task.Run的异步 I/O 操作。
下载并模糊图像
让我们通过一个更具体的例子来强化上述概念。在本系列之前的指南中,我们编写了一个应用程序,该应用程序从互联网上下载图像并保存该图像的模糊版本。我们将必要的操作分解为三种方法:
static Task<byte[]> DownloadImage(string url) { ... }
static Task<byte[]> BlurImage(string imagePath) { ... }
static Task SaveImage(byte[] bytes, string imagePath) { ... }
现在是时候完全实现这些方法了,记住我们刚刚学到的知识。显然,下载图像和将图像保存到磁盘都是 I/O 操作,所以我们尽量不要对它们使用Task.Run 。这很容易使用 .NET 的HttpClient和FileStream方法(以“Async”结尾)来完成。
static Task<byte[]> DownloadImage(string url)
{
var client = new HttpClient();
return client.GetByteArrayAsync(url);
}
static async Task SaveImage(byte[] bytes, string imagePath)
{
using (var fileStream = new FileStream(imagePath, FileMode.Create))
{
await fileStream.WriteAsync(bytes, 0, bytes.Length);
}
}
相比之下,模糊图像(像任何图像、视频或音频处理一样)非常耗费 CPU,因为它必须进行许多计算才能确定生成的图像的外观。为了更好地理解这一点,一个更简单的例子是使图像变暗。图像变暗器的一个简单实现是从图像中每个像素颜色的红色、绿色和蓝色值中减去一个常数值,使这些值更接近零。图像处理归结为算术,而算术发生在 CPU 中。因此,为了模糊图像,我们将使用Task.Run在单独的线程上执行此操作。在这个例子中,我使用了一个名为 ImageSharp 的库。它在 NuGet 中以SixLabors.ImageSharp的形式提供;我使用的是版本1.0.0-beta0006。
static async Task<byte[]> BlurImage(string imagePath)
{
return await Task.Run(() =>
{
var image = Image.Load(imagePath);
image.Mutate(ctx => ctx.GaussianBlur());
using (var memoryStream = new MemoryStream())
{
image.SaveAsJpeg(memoryStream);
return memoryStream.ToArray();
}
});
}
这里我们需要Task.Run 的主要原因是调用image.Mutate,尽管调用image.SaveAsJpeg也是 CPU 密集型的,因为它正在压缩图像。然而,我们在开始时也通过调用Image.Load执行了一些 I/O。ImageSharp 库中没有可用的Image.Load的异步版本,因此我们将它与其他所有操作一起作为对Task.Run的调用的一部分。
如您所见,有时您会在单个方法中混合使用 CPU 密集型和 I/O 密集型操作。由于Image.Load也接受字节数组,我们可以做的一项改进是添加第四个方法LoadImage ,我们使用FileStream将文件读入字节数组,然后在BlurImage中我们可以接受字节数组而不是图像路径。这将使我们更接近拥有一个纯粹受 CPU 限制的模糊方法。在构建代码时,您必须做出最佳判断,无论是在可读性方面还是在系统资源效率方面。
结论
一旦你理解了Task.Run 的作用以及何时适用,它就是一个非常有用的工具,特别是与async/await结合使用时。然而,知识不止于此!在本系列的下一篇指南中,我们将介绍一些使用Task.Run 的高级技巧,以便在应用程序变得越来越复杂时让你的生活更轻松。
了解更多
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~