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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (30投票s)

2020 年 7 月 24 日

MIT

7分钟阅读

viewsIcon

47517

downloadIcon

315

在希望使用或不使用 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() 方法之外,它还必须实现两个成员,称为 IsCompletedGetResult(),它们不属于任何接口。

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

在这种情况下,我们需要创建一个实现两个接口之一的对象:INotifyCompletionINotifyCriticalCompletion。后者不会复制执行上下文,这意味着它可能更快,但非常危险,因为它会提升代码的权限。通常,您会希望使用前者,因为代码访问安全风险通常会超过任何性能提升。单个方法 OnCompleted() 在操作完成时被调用。这时您将进行任何后续操作。我们稍后会讲到。请注意,OnCompleted() 应该是 public 的,以避免框架装箱您的 struct,因为访问接口必须这样做。装箱会导致堆分配。但是,如果方法是公共的,它可以跳过装箱并直接访问方法,我认为。我还没有深入研究 IL 来验证这一点,但这并非不可能,所以我们可以高效地处理这种情况。

我们还必须实现 IsCompletedGetResult(),它们不属于任何实际接口。编译器会生成代码来调用这些方法,因此它不是运行时的事情,接口或抽象类将是唯一的方式。编译器不需要通过接口访问事物,因为没有二进制合同涉及。它仅仅是编译器在源级别生成调用该方法的代码,而无需在运行时通过接口的 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 日 - 初始提交
© . All rights reserved.