使用await和使用ContinueWith处理异步任务有何不同?

11

这是我的意思:

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;
            });
        }
    }

上述方法可以被等待,我认为它与基于任务的异步模式所建议的方式非常相似?(我知道的其他模式是APM和EAP模式。)
现在,下面的代码怎么样:
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关键字 - 那么与先前编写的方法相比,这会有什么变化?我知道它也可以-被等待。任何返回任务的方法都可以,除非我弄错了。
我知道每当将方法标记为async时,使用那些switch语句创建状态机,并且我知道await本身不使用线程-它根本不阻塞,线程只是去做其他事情,直到被调用回来继续执行上面的代码。
但是,当我们使用await关键字调用这两种方法时,它们之间的根本区别是什么?是否有任何区别,如果有-哪个更好?
编辑:我觉得第一个代码片段更好,因为我们有效地省略了async/await关键字,没有任何后果-我们返回一个将继续同步执行或者在热路径上已经完成的任务(可以缓存)。

3
在这种情况下,差别很小。在你的第一个例子中,result.IsAuthorized = true 将在线程池上运行,而在第二个例子中,它可能会在调用 GetSomeObjectByToken 的同一线程上运行(如果它有安装了SynchronizationContext,例如它是一个UI线程)。如果 GetSomeObjectByTokenAsync 抛出异常,行为也会稍有不同。通常情况下,相对于 ContinueWithawait 更容易阅读,因此更受欢迎。 - canton7
1
这篇文章中,它被称为 "eliding",这似乎是一个非常好的词。Stephen 绝对知道他的业务。文章的结论基本上是,在性能方面并不重要,但如果不使用 await 会有某些编程陷阱。你的第二个示例是一种更安全的模式。 - John Wu
1
你可能会对微软ASP.NET团队的合作伙伴软件架构师在Twitter上的讨论感兴趣:https://twitter.com/davidfowl/status/1044847039929028608?lang=en - Remy
它被称为“状态机”,而不是“虚拟机”。 - Enigmativity
@Enigmativity 谢谢您,智者。我忘记编辑了! - SpiritBob
2个回答

11
async/await机制使编译器将您的代码转换为状态机。在第一个等待未完成的可等待对象的await命中之前,您的代码将同步运行(如果有的话)。
在Microsoft C#编译器中,此状态机是值类型,这意味着当所有await都完成可等待对象时,它的成本非常小,因为它不会分配对象,因此不会生成垃圾。当任何可等待对象未完成时,此值类型必然被装箱。1 请注意,如果await表达式中使用的是Task类型的可等待对象,则无法避免分配Tasks。
对于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;
}

在我的经验中,我发现在应用程序代码中添加这种复杂性的地方非常少,实际上开发、审查和测试这些方法所需的时间并不值得,而在库代码中,任何方法都可能成为瓶颈。
唯一的情况是当一个返回TaskTask<T>的方法仅仅返回另一个异步方法的结果时,而自身没有执行任何I/O或后处理时,我倾向于省略任务。
可能因人而异。
  1. 在 Release 模式下编译时,编译器会生成结构体。

    在 Debug 模式下编译时,编译器会生成类来允许在异步代码上进行编辑并继续调试。

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


1
状态机不是值类型。看一下一个简单异步程序的生成源代码private sealed class <Main>d__0 : IAsyncStateMachine。虽然这个类的实例可能是可重用的。 - Theodor Zoulias
3
@TheodorZoulias,以Release为目标的生成源代码生成结构体。 - acelent
2
@SpiritBob,如果你真的想要玩弄任务结果,我觉得没关系,.ContinueWith本来就是为编程而设计的。特别是当你处理CPU密集型任务而不是I/O任务时。但是当你到达必须处理Task属性、AggregateException和处理条件、循环和异常处理等复杂性时,当进一步调用异步方法时,就几乎没有理由坚持使用它了。 - acelent
谢谢您提供宝贵的信息!我有一个非常愚蠢的问题要问,我找不到一个确切的答案,但是您似乎知道怎么回事!如果我要缓存一个返回空字符串的简单任务,我是否只需创建一个由Task.FromResult生成的静态只读字段以缓存它并在必要时返回它,或者如果我这样做,我实际上将与多个线程共享该对象,这只会导致麻烦 - 那么如何缓存它呢?如果您还可以分享一些关于您所说的“循环”的资源。第一次听到这个术语。 - SpiritBob
2
@SpiritBob,抱歉,我是指“循环”。是的,您可以使用Task.FromResult("...")创建一个静态只读字段。在异步代码中,如果您通过键缓存需要进行I/O获取的值,则可以使用字典,其中值为任务,例如ConcurrentDictionary<string, Task<T>>而不是ConcurrentDictionary<string, T>,使用GetOrAdd调用工厂函数,并等待其结果。这保证仅向缓存的键发出一次I/O请求,它会在任务完成后唤醒等待者并作为已完成的任务提供服务。 - acelent
显示剩余8条评论

4
通过使用ContinueWith,您正在使用在2012年引入C# 5之前可用的工具。作为一种工具,它很冗长,不易组合,具有潜在混乱的默认scheduler¹,并且需要额外的工作来取消包装AggregateExceptions和Task<Task<TResult>>返回值(当您将异步委托传递为参数时会得到这些值)。它提供了很少的优势。您可能考虑在要附加多个继续到同一个Task时使用它,或者在某些罕见情况下无法出于某种原因使用async/await(例如,当您处于带有out参数的方法中)。

¹ 如果未提供scheduler参数,则默认为TaskScheduler.Current,而不是像人们所期望的那样为TaskScheduler.Default。这意味着默认情况下,当附加ContinueWith时,会捕获环境中的TaskScheduler.Current,并用于调度继续执行。这与await如何捕获环境中的SynchronizationContext.Current并在此上下文中计划继续执行非常相似。要防止await的这种行为,您可以使用ConfigureAwait(false),要防止ContinueWith的这种行为,您可以使用组合的TaskContinuationOptions.ExecuteSynchronously标志和传递TaskScheduler.Default。大多数专家建议每次使用ContinueWith时始终指定scheduler参数,并且不依赖环境中的TaskScheduler.Current。专业的TaskScheduler通常比专业的SynchronizationContext做更多有趣的事情。例如,环境调度程序可能是受限制的并发性调度程序,在这种情况下,继续执行可能会被放置在无关的长时间运行任务队列中,并在关联任务完成之后很长时间执行。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接