如何在任何对象上使用 C# await 关键字






4.91/5 (30投票s)
在希望使用或不使用 Task.Run() 来等待自定义项的场景中使用 await
引言
async
/await
关键字是为应用程序添加异步功能的强大方法,可提高性能和响应能力。如果您知道如何去做,就可以创建自己的可等待成员和类型,并利用 await 来处理任何您喜欢的内容。在这里,我们将深入了解 await
的工作原理,并介绍几种使某项内容可等待的方法,以及如何选择最合适的方法。
概念化这个混乱的局面
在我创建可等待套接字库时,我了解了一些关于如何将可等待功能扩展到 Task
以外的其他内容的方法。感谢这里的一些好心人,我偶然发现了 Stephen Toub 的 这篇文章,我一直都在关注他。他解释了我即将要讲的内容,但他讲得很简略,并且他的文章是为高级读者准备的,阅读起来很有挑战性。我们将回顾他的一些代码,并努力使其对更广泛的开发人员群体更易于理解。
可等待类型
可等待类型是指包含至少一个名为 GetAwaiter()
的实例方法,该方法检索可等待器类型的实例。这些也可以实现为扩展方法。理论上,您可以为例如 int
实现一个扩展方法,使其返回一个可等待器,该可等待器表示当前的异步操作,例如延迟指定的整数毫秒数。使用它就像 await 1500
延迟 1500 毫秒一样。我们稍后将确切地这样做。关键是任何实现了 GetAwaiter()
(直接或通过扩展方法) 并返回可等待器对象的内容都可以被 await。Task
暴露了这一点,这也是 Task
可以被 await 的原因。
可等待器类型
GetAwaiter()
返回的类型必须实现 System.Runtime.CompilerServices.INotifyCompletion
或相应的 ICriticalNotifyCompletion
接口。除了实现接口的 OnCompleted()
方法之外,它还必须实现两个成员,称为 IsCompleted
和 GetResult()
,它们不属于任何接口。
TaskAwaiter
TaskAwaiter
暴露了所有可等待器对象的成员,并且可以从 Task
返回。有时,我们将启动一个新任务并返回其可等待器以简化事情。但是,由于它只由 Task
返回,所以我们不能用它来返回与任务无关的内容。如果您想使某个不使用 Task
执行其工作的内容可等待,则必须创建自己的可等待器对象。
让我们开始编写代码吧!
编写这个混乱的程序
简单情况:使用 TaskAwaiter
在一个静态类上,我们可以实现以下扩展方法
internal static TaskAwaiter GetAwaiter(this int milliseconds)
=> Task.Delay(milliseconds).GetAwaiter();
现在,您可以对 int
执行 await
,它将等待指定的毫秒数。请记住,任何启动 Task
的内容 (如 Task.Delay()
) 都可以这样使用。然而,正如我所说,如果您的操作没有产生一个您可以返回其可等待器的任务,您就必须实现自己的可等待器。让我们看另一个与上面类似的示例 - 这是 Stephen Toub 的一个示例
public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
{
return Task.Delay(timeSpan).GetAwaiter();
}
您可以看到,它执行相同的操作,但使用的是 TimeSpan
而不是 int
,这意味着您也可以 await
一个 TimeSpan
实例。如果可以将 GetAwaiter()
方法直接放在您的类型上,则不必使用扩展方法,在这种情况下,它不应该是 static
的。这样做将使您的类型可等待,就像扩展方法对其他类型一样。
现在我们可以这样做
await 1500; // wait for 1500ms
和
await new TimeSpan(0, 0, 0, 2); // wait for 2 seconds
我实际上不推荐对大多数简单类型使用可等待器,因为这很模糊。我的意思是 await 1500
什么也没说,这使得阅读起来更困难。我对 await TimeSpan
也有同样的看法。这里的代码是为了说明这个概念。通过接下来的代码,我们将创建一个更实际的东西。
不那么简单的情况:创建自定义可等待器类型
有时,为满足操作而生成 Task
没有意义。如果您包装的异步编程模式不使用 Task
,则可能是这种情况。如果您的操作本身很简单,也可能是这种情况。如果所有类型都使用 struct
类型作为可等待类型和/或可等待器类型,它们将避免堆分配。据我所知,运行 Task
需要在托管堆上分配至少一个对象。此外,Task
只是复杂,因为它需要满足所有人的需求。我们真正需要的是一种轻量级的方法来 await
。
在这种情况下,我们需要创建一个实现两个接口之一的对象:INotifyCompletion
或 INotifyCriticalCompletion
。后者不会复制执行上下文,这意味着它可能更快,但非常危险,因为它会提升代码的权限。通常,您会希望使用前者,因为代码访问安全风险通常会超过任何性能提升。单个方法 OnCompleted()
在操作完成时被调用。这时您将进行任何后续操作。我们稍后会讲到。请注意,OnCompleted()
应该是 public
的,以避免框架装箱您的 struct
,因为访问接口必须这样做。装箱会导致堆分配。但是,如果方法是公共的,它可以跳过装箱并直接访问方法,我认为。我还没有深入研究 IL 来验证这一点,但这并非不可能,所以我们可以高效地处理这种情况。
我们还必须实现 IsCompleted
和 GetResult()
,它们不属于任何实际接口。编译器会生成代码来调用这些方法,因此它不是运行时的事情,接口或抽象类将是唯一的方式。编译器不需要通过接口访问事物,因为没有二进制合同涉及。它仅仅是编译器在源级别生成调用该方法的代码,而无需在运行时通过接口的 vtable (指向 .NET 中对象方法的函数指针列表) 进行解析调用。我希望这很清楚,但如果有点令人困惑,请不要担心,因为为了使用这种技术,完全理解这个细节并不重要。
万一它不那么明显,IsCompleted
属性指示操作是否已完成。
GetResult()
方法不接受任何参数,并且返回类型是您的伪任务结果的返回类型。如果它没有结果,则可以为 void
。如果这是 Task<int>
的等价物,您将在此处返回 int
。我希望这有道理。这就是您想执行任务主要工作的地方。此方法会阻塞,这意味着您的代码可以是同步的。如果检索结果失败(意味着操作失败),您将在此方法中 throw
。
我试图想出一个创建自己的可等待器的好用例,但又不太复杂,并且发现这很困难。幸运的是,Sergey Tepliakov 在 这里 提供了一个很好的例子,我只需要对 OnCompleted()
和 IsCompleted
进行一些修改。我们将在下面进行探讨。
// modified from
// https://devblogs.microsoft.com/premier-developer/extending-the-async-methods-in-c/
// premodifed source by Sergey Tepliakov
static class LazyUtility
{
// our awaiter type
public struct Awaiter<T> : INotifyCompletion
{
private readonly Lazy<T> _lazy;
public Awaiter(Lazy<T> lazy) => _lazy = lazy;
public T GetResult() => _lazy.Value;
public bool IsCompleted => _lazy.IsValueCreated;
public void OnCompleted(Action continuation)
{
// run the continuation if specified
if (null != continuation)
Task.Run(continuation);
}
}
// extension method for Lazy<T>
// required for await support
public static Awaiter<T> GetAwaiter<T>(this Lazy<T> lazy)
{
return new Awaiter<T>(lazy);
}
}
这扩展了 Lazy<T>
以使其可等待。当我们调用 GetResult()
中 Lazy<T>
的 Value
属性时,所有工作都由 Lazy<T>
完成。这样,如果您有一个耗时的初始化,您可以通过简单地 await 您的 Lazy<T>
实例来异步完成它。
请注意,我们在这里从不生成 Task
。同样,GetResult()
可能会阻塞,就像在这里一样,如果您的 Lazy<T>
的初始化代码耗时很长。
另外请注意,我们在构造函数中接受了一个 Lazy<T>
参数,这出于明显的原因很重要。
我们在 OnCompleted()
中运行 continuation
,这允许通过 Task.ContinueWith()
等方式链接任务。
您可以看到,我们还在 IsCompleted
中转发到 IsValueCreated
。这可以让框架知道 GetResult()
中的工作是否已完成,一旦 Lazy<T>
创建了该值,它就完成了。请注意,我们自己从不创建 Task
,除了在 OnCompleted()
中运行 continuation
时。这比为它创建一个 Task
更高效地进行 await。
现在我们可以这样做
var result = await myLazyT; // awaitable initialization of myLazyT
应注意的是,您在此类中进行的任何操作都应是线程安全的。Lazy<T>
已经是线程安全的。
希望这能帮助您开始创建可等待对象。祝您玩得开心!
历史
- 2020 年 7 月 24 日 - 初始提交