我们应该在调用异步回调的库中使用ConfigureAwait(false)吗?

时间:2015-05-04 17:42:57

标签: c# async-await task-parallel-library task synchronizationcontext

在C#中使用await / async时,有很多关于何时使用ConfigureAwait(false)的指南。

似乎一般建议在库代码中使用ConfigureAwait(false),因为它很少依赖于同步上下文。

但是,假设我们正在编写一些非常通用的实用程序代码,它将函数作为输入。一个简单的例子可能是以下(不完整的)功能组合器,以简化基于任务的简单操作:

地图:

public static async Task<TResult> Map<T, TResult>(this Task<T> task, Func<T, TResult> mapping)
{
    return mapping(await task);
}

FlatMap:

public static async Task<TResult> FlatMap<T, TResult>(this Task<T> task, Func<T, Task<TResult>> mapping)
{
    return await mapping(await task);
}

问题是,在这种情况下我们应该使用ConfigureAwait(false)吗?我不确定上下文捕获是如何工作的。闭包。

一方面,如果以功能方式使用组合器,则不需要同步上下文。另一方面,人们可能会滥用API,并在提供的函数中执行依赖于上下文的内容。

一种选择是为每个方案(MapMapWithContextCapture或其他东西)分别设置方法,但感觉很难看。

另一种选择可能是将地图/平面图的选项添加到ConfiguredTaskAwaitable<T>中,但是由于等待不必实现接口,这会导致大量冗余代码,在我看来更糟糕的是。

是否有一种将责任转交给调用者的好方法,这样实现的库就不需要对提供的映射函数中是否需要上下文做出任何假设?

或者只是一个事实,异步方法组合得不太好,没有各种假设?

修改

只是为了澄清一些事情:

  1. 问题确实存在。当您在效用函数中执行“回调”时,添加ConfigureAwait(false)将导致空同步。上下文。
  2. 主要问题是我们应该如何处理这种情况。我们是否应该忽略某人可能想要使用同步的事实。上下文,还是有一个很好的方法将责任转移到调用者,除了添加一些重载,标记等?
  3. 正如一些答案所提到的那样,可以在方法中添加一个bool-flag,但正如我所看到的,这也不是太漂亮,因为它必须一直传播到API中(因为有更多的“效用”功能,取决于上面显示的功能。

3 个答案:

答案 0 :(得分:14)

当您说await task.ConfigureAwait(false)时,您转换到线程池,导致mapping在空上下文中运行,而不是在前一个上下文中运行。这可能会导致不同的行为。所以如果来电者写道:

await Map(0, i => { myTextBox.Text = i.ToString(); return 0; }); //contrived...

然后,这将在以下Map实施中崩溃:

var result = await task.ConfigureAwait(false);
return await mapper(result);

但不是在这里:

var result = await task/*.ConfigureAwait(false)*/;
...

更可怕的是:

var result = await task.ConfigureAwait(new Random().Next() % 2 == 0);
...

翻转有关同步上下文的硬币!这看起来很有趣,但并不像看起来那么荒谬。一个更现实的例子是:

var result =
  someConfigFlag ? await GetSomeValue<T>() :
  await task.ConfigureAwait(false);

因此,根据某些外部状态,该方法的其余部分运行的同步上下文可能会发生变化。

这也可以通过非常简单的代码实现,例如:

await someTask.ConfigureAwait(false);

如果someTask已在等待时已完成,则不会切换上下文(这有利于性能原因)。如果需要切换,则该方法的其余部分将在线程池上恢复。

这种非确定性是await设计的弱点。这是绩效名称的权衡。

这里最令人烦恼的问题是,在调用API时不清楚会发生什么。这令人困惑并导致错误。

怎么做?

备选方案1:您可以争辩说,最好始终使用task.ConfigureAwait(false)来确保确定性行为。

lambda必须确保它在正确的上下文中运行:

var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext;
Map(..., async x => await Task.Factory.StartNew(
        () => { /*access UI*/ },
        CancellationToken.None, TaskCreationOptions.None, uiScheduler));

最好用实用方法隐藏其中的一部分。

备选方案2:您还可以认为Map函数应该与同步上下文无关。它应该不管它。然后上下文将流入lambda。当然,仅仅存在同步上下文可能会改变Map的行为(不是在这种特殊情况下,而是一般情况下)。因此Map必须设计为处理它。

备选3:您可以将一个布尔参数注入Map,指定是否流动上下文。这会使行为明确。这是合理的API设计,但它使API变得混乱。关注基本API(如Map与同步上下文问题似乎不合适。

采取哪条路线?我认为这取决于具体情况。例如,如果Map是UI辅助函数,那么流动上下文是有意义的。如果它是库函数(例如重试助手),我不确定。我可以看到所有选择都有意义。通常,建议在所有库代码中应用ConfigureAwait(false)。在我们称之为用户回调的情况下,我们应该例外吗?如果我们已经离开了正确的背景,例如:

,该怎么办?
void LibraryFunctionAsync(Func<Task> callback)
{
    await SomethingAsync().ConfigureAwait(false); //Drops the context (non-deterministically)
    await callback(); //Cannot flow context.
}

不幸的是,没有简单的答案。

答案 1 :(得分:7)

  

问题是,在这种情况下我们应该使用ConfigureAwait(false)吗?

是的,你应该。如果等待的内部Task是上下文感知的并且确实使用给定的同步上下文,那么即使调用它的人正在使用ConfigureAwait(false),它仍然能够捕获它。不要忘记,当忽略上下文时,您在更高级别的调用中执行此操作,不在提供的代理内部。代理正在Task内执行,如果需要,需要了解情境。

你,调用者,对上下文没兴趣,所以用ConfigureAwait(false)调用它是绝对正确的。这样可以有效地执行您想要的操作,从而可以选择内部代理是否将同步上下文包含在Map方法的调用者中。

修改

需要注意的重要一点是,一旦使用ConfigureAwait(false),之后的任何方法执行都将在任意线程池线程上执行。

@ i3arnon建议的一个好主意是接受一个可选的bool标志,指示是否需要上下文。虽然有点难看,但这将是一个不错的工作。

答案 2 :(得分:7)

我认为真正的问题来自于您在实际操作结果时向Task添加操作的事实。

没有任何理由将任务作为容器复制这些操作,而不是将它们保留在任务结果上。

这样,您就不需要在实用程序方法中决定如何await此任务,因为该决定保留在使用者代码中。

如果Map实现如下:

public static TResult Map<T, TResult>(this T value, Func<T, TResult> mapping)
{
    return mapping(value);
}

您可以轻松地使用或不使用Task.ConfigureAwait

var result = await task.ConfigureAwait(false)
var mapped = result.Map(result => Foo(result));

Map这里只是一个例子。关键是你在这里操纵什么。如果您正在操作任务,则不应该await它并将结果传递给使用者委托,您只需添加一些async逻辑,您的调用者就可以选择是否使用{{1 }} 或不。如果您对结果进行操作,则无需担心任务。

您可以将布尔值传递给其中每个方法,以表示您是否要继续捕获的上下文(或者更强大地传递选项Task.ConfigureAwait标志以支持其他enum配置) 。但这违反了关注点的分离,因为这与await(或其等价物)没有任何关系。