【问题标题】:How does using await differ from using ContinueWith when processing async tasks?处理异步任务时使用 await 与使用 ContinueWith 有何不同?
【发布时间】:2020-02-26 13:53:05
【问题描述】:

这就是我的意思:

public Task<SomeObject> GetSomeObjectByTokenAsync(int id)
    {
        string token = repository.GetTokenById(id);
        if (string.IsNullOrEmpty(token))
        {
            return Task.FromResult(new SomeObject()
            {
                IsAuthorized = false
            });
        }
        else
        {
            return repository.GetSomeObjectByTokenAsync(token).ContinueWith(t =>
            {
                t.Result.IsAuthorized = true;
                return t.Result;
            });
        }
    }

可以等待上述方法,我认为它与基于 Task 的 A同步 Pattern 建议的操作非常相似? (我知道的其他模式是 APMEAP 模式。)

现在,下面的代码呢:

public async Task<SomeObject> GetSomeObjectByToken(int id)
    {
        string token = repository.GetTokenById(id);
        if (string.IsNullOrEmpty(token))
        {
            return new SomeObject()
            {
                IsAuthorized = false
            };
        }
        else
        {
            SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
            result.IsAuthorized = true;
            return result;
        }
    }

这里的主要区别在于该方法是async,并且它使用了await 关键字——那么与之前编写的方法相比,这有什么变化呢?我知道它也可以——等待。任何返回 Task 的方法都可以,除非我弄错了。

每当方法被标记为async 时,我都知道使用这些 switch 语句创建的状态机,并且我知道await 本身不使用线程 - 它根本不会阻塞线程只是去做其他事情,直到它被回调以继续执行上述代码。

但是,当我们使用await 关键字调用它们时,这两种方法之间的根本区别是什么?有什么区别吗?如果有 - 哪个是首选?

编辑:我觉得第一个代码 sn-p 是首选,因为我们有效地 elide async/await 关键字,没有任何影响 - 我们返回一个将继续同步执行的任务,或者热路径上已经完成的任务(可以缓存)。

【问题讨论】:

  • 很少,在这种情况下。在您的第一个示例中,result.IsAuthorized = true 将在线程池上运行,而在第二个示例中,它可能在调用GetSomeObjectByToken 的同一线程上运行(如果它安装了SynchronizationContext,例如它是UI线程)。 GetSomeObjectByTokenAsync 抛出异常时的行为也会略有不同。一般来说,awaitContinueWith 更受欢迎,因为它几乎总是更具可读性。
  • this article 中,它被称为“省略”,这似乎是一个很好的词。斯蒂芬绝对了解他的业务。这篇文章的结论本质上是它并不重要,性能方面,但如果你不等待,就会有一些编码陷阱。你的第二个例子是一个更安全的模式。
  • 您可能对来自 Microsoft 的 ASP.NET 团队的合作伙伴软件架构师的 Twitter 讨论感兴趣:twitter.com/davidfowl/status/1044847039929028608?lang=en
  • 它被称为“状态机”,而不是“虚拟机”。
  • @Enigmativity 谢谢聪明人,我忘记编辑了!

标签: c# multithreading .net-core async-await task-parallel-library


【解决方案1】:

async/await 机制使编译器将您的代码转换为状态机。您的代码将同步运行,直到第一个 await 遇到尚未完成的可等待对象(如果有)。

在 Microsoft C# 编译器中,此状态机是值类型,这意味着当所有 awaits 都完成可等待对象时,它的成本非常低,因为它不会分配对象,因此,它赢了'不产生垃圾。当任何 awaitable 未完成时,此值类型不可避免地会被装箱。 1

请注意,如果 Tasks 是 await 表达式中使用的等待对象类型,这并不能避免分配。

使用ContinueWith,只有在您的延续没有closure 并且如果您不使用状态对象或重复使用状态对象时,您才可以避免分配(Task 除外)可能的(例如,来自游泳池)。

此外,任务完成时会调用延续,创建堆栈帧,它不会内联。框架试图避免堆栈溢出,但在某些情况下它可能无法避免,例如在分配大数组时。

它试图避免这种情况的方法是检查剩下多少堆栈,如果通过某种内部措施认为堆栈已满,它会安排继续在任务调度程序中运行。它试图以性能为代价避免致命的堆栈溢出异常。

这是async/awaitContinueWith之间的细微差别:

  • async/await 将在SynchronizationContext.Current 中安排延续,否则在TaskScheduler.Current 2

  • ContinueWith 将在提供的任务调度器中调度延续,或者在没有任务调度器参数的重载中的TaskScheduler.Current 中调度延续

模拟async/await的默认行为:

.ContinueWith(continuationAction,
    SynchronizationContext.Current != null ?
        TaskScheduler.FromCurrentSynchronizationContext() :
        TaskScheduler.Current)

Task.ConfigureAwait(false)模拟async/await的行为:

.ContinueWith(continuationAction,
    TaskScheduler.Default)

由于循环和异常处理,事情开始变得复杂。除了保持代码可读性之外,async/await 还可以与任何awaitable 一起使用。

最好使用混合方法处理您的情况:在需要时调用异步方法的同步方法。使用这种方法的代码示例:

public Task<SomeObject> GetSomeObjectByTokenAsync(int id)
{
    string token = repository.GetTokenById(id);
    if (string.IsNullOrEmpty(token))
    {
        return Task.FromResult(new SomeObject()
        {
            IsAuthorized = false
        });
    }
    else
    {
        return InternalGetSomeObjectByTokenAsync(repository, token);
    }
}

internal async Task<SomeObject> InternalGetSomeObjectByToken(Repository repository, string token)
{
    SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
    result.IsAuthorized = true;
    return result;
}

根据我的经验,我发现在 应用程序 代码中添加如此复杂的代码实际上可以为开发、审查和测试此类方法带来回报的地方很少,而在 strong> 编码任何方法都可能成为瓶颈。

我倾向于省略任务的唯一情况是 TaskTask&lt;T&gt; 返回方法仅返回另一个异步方法的结果,而本身没有执行任何 I/O 或任何后处理。

YMMV.


  1. 在为 Release 构建时,编译器会生成结构。

    在为 Debug 构建时,编译器会生成类以允许对异步代码进行编辑并继续。

  2. 除非您使用 ConfigureAwait(false) 或 await 一些使用自定义调度的可等待对象。

【讨论】:

  • 状态机不是值类型。看一下简单异步程序的generated sourceprivate sealed class &lt;Main&gt;d__0 : IAsyncStateMachine。不过,此类的实例可能是可重用的。
  • 你说如果我只篡改一个任务的结果,用ContinueWith就可以了吗?
  • @TheodorZoulias,generated source 针对 Release 而不是 Debug 生成结构。
  • @SpiritBob,如果你真的想摆弄任务结果,我想没关系,.ContinueWith 是为了以编程方式使用。如果您正在处理 CPU 密集型任务而不是 I/O 任务,则尤其如此。但是,当您不得不处理Task 的属性、AggregateException 以及在调用更多异步方法时处理条件、循环和异常处理的复杂性时,几乎没有任何理由坚持下去.
  • @SpiritBob,对不起,我的意思是“循环”。是的,您可以使用Task.FromResult("...") 创建一个静态只读字段。在异步代码中,如果您通过键缓存需要 I/O 获取的值,您可以使用值是任务的字典,例如ConcurrentDictionary&lt;string, Task&lt;T&gt;&gt; 而不是 ConcurrentDictionary&lt;string, T&gt;,使用带有工厂函数的 GetOrAdd 调用,并等待其结果。这保证了只发出一个 I/O 请求来填充缓存的键,它会在任务完成后立即唤醒等待者,并在之后作为完成的任务。
【解决方案2】:

通过使用ContinueWith,您使用的工具是在 2012 年通过 C# 5 引入 async/await 功能之前可用的工具。作为一个工具,它很冗长,不容易组合,它有一个可能会混淆默认 scheduler¹,并且需要额外的工作来解开 AggregateExceptions 和 Task&lt;Task&lt;TResult&gt;&gt; 返回值(当您将异步委托作为参数传递时会得到这些)。作为回报,它提供的优势很少。当您想将多个延续附加到同一个 Task 时,或者在某些极少数情况下由于某种原因无法使用 async/await 时,您可以考虑使用它(例如,当您使用方法 @987654321 时) @)。

¹ 如果未提供scheduler 参数,则默认为TaskScheduler.Current,而不是人们可能期望的TaskScheduler.Default。这意味着by default 附加ContinueWith 时,环境TaskScheduler.Current 被捕获,并用于调度延续。这有点类似于await 如何捕获环境SynchronizationContext.Current,并在此上下文中安排await 之后的继续。为了防止await 的这种行为,您可以使用ConfigureAwait(false),为了防止ContinueWith 的这种行为,您可以结合使用TaskContinuationOptions.ExecuteSynchronously 标志和传递TaskScheduler.Default。大多数专家suggest 每次使用ContinueWith 时始终指定scheduler 参数,而不是依赖环境TaskScheduler.Current。专业的TaskSchedulers 通常比专业的SynchronizationContexts 做更多时髦的事情。例如,环境调度程序可以是limited concurrency 调度程序,在这种情况下,延续可能会被放入不相关的长时间运行任务的队列中,并在相关任务完成后执行很长时间。

【讨论】:

    猜你喜欢
    • 2013-06-28
    • 2014-12-19
    • 1970-01-01
    • 2014-02-09
    • 1970-01-01
    • 2016-08-08
    • 2013-06-04
    • 2019-08-20
    • 1970-01-01
    相关资源
    最近更新 更多