在处理异步任务时使用 await 与使用 ContinueWith 有何不同?

How does using await differ from using ContinueWith when processing async tasks?

我的意思是:

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-based Asynchronous 的方法非常相似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 关键字 - 那么与之前编写的方法相比,这有何变化?我知道它也可以 - 等待。 returning Task 的任何方法都可以解决这个问题,除非我弄错了。

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

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

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

通过使用 ContinueWith,您使用的工具是在 2012 年 C# 5 引入 async/await 功能之前可用的工具。作为一种工具,它是冗长的,不容易组合,它有一个可能令人困惑的默认值 scheduler¹,并且需要额外的工作来解包 AggregateExceptions 和 Task<Task<TResult>> return 值(当你通过异步传递时你会得到这些委托作为参数)。它在 return 中几乎没有优势。当您想要将多个延续附加到同一个 Task 时,或者在极少数情况下由于某种原因您无法使用 async/await 时(例如当您在方法 with out parameters).

¹ 如果未提供 scheduler 参数,则默认为 TaskScheduler.Current,而不是人们可能期望的 TaskScheduler.Default。这意味着 by default when the ContinueWith is attached, the ambient TaskScheduler.Current is captured, and used for scheduling the continuation. This is somewhat similar with how the await captures the ambient SynchronizationContext.Current, and schedules the continuation after the await on this context. To prevent this behavior of await you can use the ConfigureAwait(false), and to prevent this behavior of ContinueWith you can use the TaskContinuationOptions.ExecuteSynchronously flag in combination with passing the TaskScheduler.Default. Most experts suggest to specify always the scheduler argument every time you use the ContinueWith, and not rely on the ambient TaskScheduler.Current. Specialized TaskSchedulers are generally doing more funky stuff than specialized SynchronizationContexts. For example the ambient scheduler could be a limited concurrency 调度程序,在这种情况下,延续可能会被放入不相关的长 运行 任务队列中,并在相关任务完成后执行很长时间。

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

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

请注意,如果 Taskawait 表达式中使用的可等待类型,则这不会避免分配 Task

使用 ContinueWith,如果您的延续没有 closure 并且您不使用状态对象或你尽可能重用状态对象(例如从池中)。

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

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

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

  • async/await 将在 SynchronizationContext.Current 中安排续集(如果有的话),否则在 TaskScheduler.Current 2[=93= 中]

  • 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> 返回方法只是 returns 另一个异步方法的结果,而其本身没有执行任何 I/O 或任何 post-processing.

YMMV.


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

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

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