65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的异步延迟初始化 – 强大的力量伴随着重大的责任

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2023 年 8 月 14 日

CPOL

8分钟阅读

viewsIcon

13294

在这篇博文中,我们将探讨 C# 中异步延迟初始化的概念

在软件开发的世界里,性能和效率是关键。作为开发人员,我们不断努力编写不仅能解决手头问题,而且能以最高效方式解决问题的代码。C# 中一种这样的效率技术是使用 Lazy<T> 类进行延迟初始化。这允许我们将可能缓慢的初始化推迟到执行过程中更“即时”的点,直到我们需要使用该值。但是,如果我们能更进一步,引入异步呢?有没有什么能提供异步延迟的功能?

在这篇博文中,我们将探讨 C# 中异步延迟初始化的概念,并使用 Lazy<Task<T>> 来实现。因此,无论您是经验丰富的 C# 程序员还是好奇的初学者,都请系好安全带,踏上激动人心的异步延迟初始化之旅!一如既往,我们将讨论我们今天所讨论内容的优缺点。

理解延迟初始化

在深入异步世界之前,让我们先理解什么是延迟初始化。延迟初始化是一种编程技术,其中对象或值的初始化被推迟到首次访问它时。当创建对象成本高昂(在时间或资源方面),并且应用程序启动时对象并未立即使用时,这特别有用。没有延迟初始化,启动时间可能会不必要地失控。如果您一直在关注 关于插件架构的内容使用 Autofac 进行依赖注入,您可能会发现您的初始化代码范围越来越大。

在 C# 中,这可以使用 Lazy<T> 类来实现。这是一个简单的例子

Lazy<MyClass> myObject = new Lazy<MyClass>(() => new());

在上面的代码片段中,myObject 是一个 Lazy<MyClass> 实例。实际的 MyClass 对象不会被创建,直到首次访问 myObject.Value。如果 MyClass 创建成本很高,并且您仅在首次需要它之前访问此属性,这可以显著提高应用程序的启动性能。更重要的是,Lazy<T> 类型处理了初始化周围的线程安全!

您可以在此视频中查看更多示例

异步延迟初始化的需求

虽然 Lazy<T> 是一个强大的工具,但它似乎不支持异步初始化。当对象的初始化涉及 I/O 操作或其他耗时任务时,这可能是一个问题,而这些任务可以受益于异步运行。阻塞主线程进行此类操作可能导致糟糕的用户体验,因为它可能使您的应用程序无响应。此外,随着 async/await 模式在 .NET 代码库中越来越普遍,对异步延迟初始化的需求将持续增长。

这就是 Lazy<Task<T>> 发挥作用的地方。通过使用 Lazy<Task<T>>,我们可以实现异步延迟初始化。Task<T> 代表一个返回结果的异步操作。当与 Lazy<T> 结合时,它允许在需要时异步运行耗时操作并消耗其结果。

那么,Lazy<T> 是否开箱即用地支持异步延迟初始化?严格来说,是的,但我们中的许多人从未考虑过我们可以简单地将这里的类型参数替换为 Task<T>!通过将任务作为延迟包装器的类型,我们基本上可以开箱即用地获得异步延迟!许多 C# 开发人员已经知道这一点,但如果您和我一样,答案就隐藏在我们眼前。

介绍 Lazy<Task<T>>

让我们看看如何使用 Lazy<Task<T>> 实现异步延迟初始化。这是一个简单的例子

Lazy<Task<MyClass>> myObject = new Lazy<Task<MyClass>>(() => Task.Run(() => new MyClass()));

在上面的代码片段中,myObject 是一个 Lazy<Task<MyClass>> 实例。Task.Run(() => new MyClass()) 是一个创建新的 MyClass 对象的异步操作。此操作在首次访问 myObject.Value 之前不会运行。此外,由于它被包装在 Task 中,它将异步运行。

您还可以做更多,而不仅仅是直接实例化一个对象!让我们来看这个例子

public async Task<MyClass> CreateMyClassAsync()
{
  // simulate being busy!
  await Task.Delay(2000);
  return new MyClass();
}

Lazy<Task<MyClass>> myObject = new Lazy<Task<MyClass>>(CreateMyClassAsync);

上面的代码引用了一个 async/await 方法,旨在演示您实际上可以将任何异步代码路径传递进去。那些只显示对象构造函数被调用的第一个示例有点牵强,因为那应该几乎是瞬时的。因此,通过这个例子,希望您能开始看到更长时间运行操作的潜力。

使用 Lazy<Task<T>>

要使用 Lazy<Task<T>> 的结果,我们需要 await Task<T>

MyClass result = await myObject.Value;

在上面的代码片段中,myObject.Value 返回一个 Task<MyClass>。通过 await 此任务,我们可以在 MyClass 对象准备好后获取它。如果任务尚未完成,这将异步等待任务完成,然后再继续。这确保了即使 MyClass 的初始化需要很长时间,您的应用程序也能保持响应。当然,这假设您代码中的其余 async/await 模式实际上是正确使用的!

您可以在此视频中观看更多详细信息

异步延迟初始化的威力

如果您还没有以这种方式使用 Lazy<T>,那么异步延迟初始化可以成为您 C# 开发工具包中的强大工具。它结合了延迟初始化和异步编程的优点,使您能够按需创建昂贵的对象,而不会阻塞主线程。同样,假设您正确使用了 async/await 和任务!

以下是使用异步延迟初始化的一些好处

  1. 提高性能:通过将昂贵对象的创建推迟到需要时,您可以提高应用程序的启动性能。
  2. 更好的响应能力:通过异步运行初始化代码,您可以确保您的应用程序在初始化耗时很长的情况下仍保持响应。
  3. 简单性Lazy<Task<T>> 模式简单易用。它利用了 .NET 中现有的 Lazy<T>Task<T> 类,因此无需学习新的 API。
  4. 控制:您可以控制在哪里为初始化成本付费。如果可以在初始化时声明变量,但在该点不为其付费,那么您现在就有了这样一个工具。

异步延迟注意事项

然而,与任何工具一样,它应该有目的地使用!过度使用延迟初始化(无论是同步还是异步)都可能导致自身的问题,例如内存使用量增加以及处理不当可能导致的死锁。请记住,如果您要解决的问题是某个操作耗时很长,Lazy<T> 本身并不能真正解决这个问题,它只是允许您移动它。如果您能将其移到有利的位置,那很好!但仅仅移动它并不一定能解决您的问题。

当我们混合使用异步部分时,又增加了一层复杂性,而复杂性与延迟初始化不太兼容!考虑到如果您需要使用 Lazy<Task<T>>,您可能需要调用一些 async/await 代码或其他需要运行的任务。如果您需要与外部系统交互(例如,从磁盘读取文件,从数据库提取数据,从 Web API 查询结果等),那么就存在出错的空间。运行此异步代码时,复杂性越高,需要交互的事物越多,出错的空间就越大。

您需要考虑如果您的 Lazy<Task<T>> 运行的代码可能失败,会是什么样子。对于一个期望运行一次来缓存结果的东西,如何实现弹性?我不是来规定一个放之四海而皆准的解决方案,但我确实认为您需要认真考虑这一点。在这篇文章中,提供了一个示例,该示例允许一些容错,但作者也指出,这可能因场景而异。

Stephen Toub 的 AsyncLazy<T>

Stephen Toub,微软的首席软件工程师,提出了一个 AsyncLazy<T>,它结合了两者:Lazy<T> 的延迟性和 Task<T> 的异步性。它看起来像这样(代码略有修改,因为博文有几年了)

public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<T> valueFactory) :
        base(() => Task.Run(valueFactory))
    { }

    public AsyncLazy(Func<Task<T>> taskFactory) :
        base(() => Task.Run(() => taskFactory()).Unwrap())
    { }
}

在此代码中,AsyncLazy<T>Lazy<Task<T>> 的子类。它提供了两个构造函数:一个接受 Func<T>,另一个接受 Func<Task<T>>。为什么我们仍然在第二个构造函数上使用 Task.Run?嗯,看看 Stephen 的这一点

[如果我们没有将其包装在 Task.Run 中],那么当用户访问此实例的 Value 属性时,taskFactory 委托将被同步调用。如果 taskFactory 委托在返回任务实例之前所做的工作很少,这可能是完全合理的。但是,如果 taskFactory 委托执行任何不容忽视的工作,调用 Value 将会阻塞,直到对 taskFactory 的调用完成。为了应对这种情况,第二种方法是使用 [Task.Run] 运行 taskFactory,即异步运行委托本身,就像第一个构造函数一样,尽管这个委托已经返回了一个 Task<T>。当然,现在 [Task.Run] 将返回一个 Task<Task<T>>,因此我们在 .NET 4 中使用 Unwrap 方法将 Task<Task<T>> 转换为 Task<T>

Stephen Toub

您可以在此处看到如何使用它

AsyncLazy<MyClass> myObject = new AsyncLazy<MyClass>(() => new MyClass());

// later...

MyClass result = await myObject.Value;

在上面的代码片段中,myObject 是一个 AsyncLazy<MyClass> 实例。new MyClass() 调用将在首次访问 myObject.Value 之前不会运行,并且它将异步运行。

结论

异步延迟初始化是一项强大的技术,可以帮助您在 C# 中编写更高效、响应更快的应用程序。通过结合 Lazy<T>Task<T> 类,您可以将昂贵对象的创建推迟到需要时,并且可以这样做而不会阻塞主线程。

但是,与任何工具一样,它应该谨慎使用。过度使用延迟初始化(无论是同步还是异步)都可能导致自身的问题,例如内存使用量增加以及处理不当可能导致的死锁。此外,随着复杂性的增加,容错性和错误管理方面的考虑会成为挑战。

© . All rights reserved.