【问题标题】:Revisiting Task.ConfigureAwait(continueOnCapturedContext: false)重新访问 Task.ConfigureAwait(continueOnCapturedContext: false)
【发布时间】:2015-04-09 05:00:42
【问题描述】:

阅读时间过长。使用Task.ConfigureAwait(continueOnCapturedContext: false) 可能会引入冗余线程切换。我正在寻找一个一致的解决方案。

长版。ConfigureAwait(false) 背后的主要设计目标是尽可能减少await 的冗余SynchronizationContext.Post 延续回调。这通常意味着更少的线程切换和更少的 UI 线程工作。然而,它并不总是这样工作的。

例如,有一个实现SomeAsyncApi API 的第三方库。请注意,由于某种原因,ConfigureAwait(false) 并未在此库中的任何地方使用:

// some library, SomeClass class
public static async Task<int> SomeAsyncApi()
{
    TaskExt.Log("X1");

    // await Task.Delay(1000) without ConfigureAwait(false);
    // WithCompletionLog only shows the actual Task.Delay completion thread
    // and doesn't change the awaiter behavior

    await Task.Delay(1000).WithCompletionLog(step: "X1.5");

    TaskExt.Log("X2");

    return 42;
}

// logging helpers
public static partial class TaskExt
{
    public static void Log(string step)
    {
        Debug.WriteLine(new { step, thread = Environment.CurrentManagedThreadId });
    }

    public static Task WithCompletionLog(this Task anteTask, string step)
    {
        return anteTask.ContinueWith(
            _ => Log(step),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
}

现在,假设有一些客户端代码在 WinForms UI 线程上运行并使用 SomeAsyncApi

// another library, AnotherClass class
public static async Task MethodAsync()
{
    TaskExt.Log("B1");
    await SomeClass.SomeAsyncApi().ConfigureAwait(false);
    TaskExt.Log("B2");
}

// ... 
// a WinFroms app
private async void Form1_Load(object sender, EventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync();
    TaskExt.Log("A2");
}

输出:

{ 步骤 = A1,线程 = 9 } { 步骤 = B1,线程 = 9 } { 步 = X1,线程 = 9 } { 步 = X1.5,线程 = 11 } { 步 = X2,线程 = 9 } { 步骤 = B2,线程 = 11 } { 步骤 = A2,线程 = 9 }

这里,逻辑执行流程经过 4 个线程切换。 其中2个是多余的,由SomeAsyncApi().ConfigureAwait(false)引起。这是因为ConfigureAwait(false) pushes the continuation to ThreadPool 来自具有同步上下文的线程(在本例中为 UI 线程)。

在这种特殊情况下,MethodAsync 最好没有ConfigureAwait(false)。那么它只需要 2 个线程切换 vs 4 个:

{ 步骤 = A1,线程 = 9 } { 步骤 = B1,线程 = 9 } { 步 = X1,线程 = 9 } { 步 = X1.5,线程 = 11 } { 步 = X2,线程 = 9 } { 步骤 = B2,线程 = 9 } { 步骤 = A2,线程 = 9 }

但是,MethodAsync 的作者出于善意使用ConfigureAwait(false) 并关注the best practices,而她对SomeAsyncApi 的内部实现一无所知。 如果“一直”使用ConfigureAwait(false) 不会有问题(也就是在SomeAsyncApi 内部),但这超出了她的控制范围。

WindowsFormsSynchronizationContext(或DispatcherSynchronizationContext)就是这样,我们可能根本不关心额外的线程切换。但是,在 ASP.NET 中可能会发生类似的情况,AspNetSynchronizationContext.Post 本质上是这样做的:

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask;

整个事情可能看起来是一个人为的问题,但我确实看到了很多这样的生产代码,包括客户端和服务器端。我遇到的另一个有问题的模式:await TaskCompletionSource.Task.ConfigureAwait(false)SetResult 在与前一个 await 捕获的同步上下文中被调用。再一次,延续被多余地推送到ThreadPool。这种模式背后的原因是“它有助于避免死锁”。

问题:鉴于ConfigureAwait(false) 的描述行为,我正在寻找一种使用async/await 的优雅方式,同时仍尽量减少冗余线程/上下文切换。理想情况下,可以使用现有的 3rd 方库。

到目前为止我所看到的

  • 使用Task.Run 卸载async lambda 并不理想,因为它引入了至少一个额外的线程切换(尽管它可能会节省许多其他线程):

    await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false);
    
  • 另一种骇人听闻的解决方案可能是从当前线程中临时删除同步上下文,这样它就不会被内部调用链中的任何后续等待捕获(我之前提到过它here):

    async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false);
        TaskExt.Log("B2");
    }
    
    { 步骤 = A1,线程 = 8 } { 步骤 = B1,线程 = 8 } { 步 = X1,线程 = 8 } { 步 = X1.5,线程 = 10 } { 步 = X2,线程 = 10 } { 步骤 = B2,线程 = 10 } { 步骤 = A2,线程 = 8 }
    public static Task<TResult> WithNoContext<TResult>(Func<Task<TResult>> func)
    {
        Task<TResult> task;
        var sc = SynchronizationContext.Current;
        try
        {
            SynchronizationContext.SetSynchronizationContext(null);
            // do not await the task here, so the SC is restored right after
            // the execution point hits the first await inside func
            task = func();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(sc);
        }
        return task;
    }
    

    这可行,但我不喜欢它篡改线程当前同步上下文的事实,尽管范围很短。此外,这里还有另一个含义:在当前线程上没有SynchronizationContext 的情况下,环境TaskScheduler.Current 将用于await 延续。考虑到这一点,WithNoContext 可能会像下面这样进行更改,这将使这个 hack 更加奇特:

    // task = func();
    var task2 = new Task<Task<TResult>>(() => func());
    task2.RunSynchronously(TaskScheduler.Default); 
    task = task2.Unwrap();
    

如果有任何其他想法,我将不胜感激。

更新,地址@i3arnon's comment

我会说这是相反的,因为正如斯蒂芬在 他的回答“ConfigureAwait(false) 的目的不是诱导 线程切换(如有必要),而是为了防止代码过多 在特定的特殊上下文中运行。”您不同意并且 是您合规的根源。

由于您的答案已被编辑,here is your statement 为了清楚起见,我不同意:

ConfigureAwait(false) 目标是尽可能减少工作 尽管有线程,“特殊”(例如 UI)线程仍需要处理 它需要的开关。

我也不同意你的current version 的这种说法。我会把你推荐给主要来源,Stephen Toub 的blog post

避免不必要的编组

如果可能,请确保您正在调用的异步实现 不需要阻塞的线程来完成操作 (这样,您可以使用正常的阻塞机制等待 同步以使异步工作在其他地方完成)。在里面 在异步/等待的情况下,这通常意味着确保任何等待 在您正在调用的异步实现内部使用 在所有等待点上配置等待(假);这将防止等待 从试图编组回到当前的 SynchronizationContext。作为 一个库实现者,最好总是使用 ConfigureAwait(false) 在你的所有等待上,除非你有一个 不这样做的具体原因;这不仅有助于避免这些 各种死锁问题,也为了性能,因为它避免了 不必要的编组成本。

它确实表示目标是为了性能而避免不必要的编组成本。线程切换(其中包括ExecutionContext是一个很大的编组成本。

现在,它并没有说目标是减少在“特殊”线程或上下文上完成的工作量。

虽然这可能对 UI 线程有一定意义,但我仍然不认为这是 ConfigureAwait 背后的主要目标。还有其他更结构化的方法可以最小化 UI 线程上的工作,例如使用 await Task.Run(work) 块。

此外,最小化AspNetSynchronizationContext 上的工作毫无意义 - 它本身在线程之间流动,与 UI 线程不同。恰恰相反,一旦您使用AspNetSynchronizationContext,您希望尽可能多地工作,以避免在处理 HTTP 请求的过程中进行不必要的切换。尽管如此,在 ASP.NET 中使用ConfigureAwait(false) 仍然非常有意义:如果使用正确,它会再次减少服务器端线程切换。

【问题讨论】:

  • 当 TPL 团队需要为 await 定义默认行为时,他们必须做出一个非常艰难的决定。只有糟糕的选择。在 GUI 应用程序中,默认情况下 await 会失败,或者默认情况下所有库都会做错事。这可能是 await 最讨厌的方面。
  • @Pingpong,在answering your question 时,我在总结上尽了最大努力。 TLTR,我对此的看法:不要使用 ConfigureAwait(false) 并且 - 如果绝对必要 - 使用 TaskRun(() =&gt; SomethingAsync()) 以希望脱离同步上下文。
  • 非常有趣!我现在试图通过创建一个省略ConfigureAwait(false) 可能导致更多线程切换的场景来证明相反的情况。 :-)
  • 是的,我也喜欢TaskScheduler.SwitchTo() 概念的想法。顺便说一句,我放弃了尝试创建反例。这并不容易,甚至不可能。 :-)

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


【解决方案1】:

当您处理异步操作时,线程切换的开销太小而无需关心(一般来说)。 ConfigureAwait(false) 的目的不是诱导线程切换(如果需要),而是防止在特定的特殊上下文中运行太多代码。

这种模式背后的原因是“它有助于避免死锁”。

堆栈跳水。

但我确实认为这在一般情况下不是问题。当我遇到不能正确使用ConfigureAwait 的代码时,我只需将其包装在Task.Run 中并继续前进。线程切换的开销不值得担心。

【讨论】:

  • 在异步操作的整体范围内可以忽略不计,但并不理想。理想情况下,ConfigureAwait(false) 会按照您的描述行事:在进入上下文之前做尽可能多的工作。
  • @Noseratio:无论最初的意图是否是减少上下文中的代码,这就是它的实际行为方式。即,没有办法要求 SyncCtx 像 TaskSched 那样运行同步,因此 ConfigureAwait(false) 最终会特别处理“SyncCtx”(它会主动避免任何 SyncCtx)。对于 ASP.NET,尽可能避免 SyncCtx 可以为该请求启用少量并行性。
  • @Zoomzoom:堆栈潜水将大量条目放在单个线程的堆栈上。不一定会导致堆栈溢出,但更有可能发生。
  • 您“遇到的代码没有正确使用ConfigureAwait”可能只有在代码已经投入生产时才会清楚。如果没有来源并仔细研究它们,您将无法知道。
  • @TheodorZoulias:谢谢;我没有意识到这一点!
【解决方案2】:

ConfigureAwait(false) 背后的主要设计目标是尽可能减少等待的冗余 SynchronizationContext.Post 延续回调。这通常意味着更少的线程切换和更少的 UI 线程工作。

我不同意你的前提。 ConfigureAwait(false) 目标是尽可能减少需要编组回“特殊”(例如 UI)上下文的工作尽管线程切换可能需要关闭该上下文.

如果目标是减少线程切换,您可以在所有工作中保持在相同的特殊上下文中,然后不需要其他线程。

要实现这一点,您应该使用 ConfigureAwait everywhere 您不关心执行延续的线程。如果你举个例子并适当地使用ConfigureAwait,你只会得到一个开关(而不是没有它的2个):

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync().ConfigureAwait(false);
    TaskExt.Log("A2");
}

public class AnotherClass
{
    public static async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await SomeClass.SomeAsyncApi().ConfigureAwait(false);
        TaskExt.Log("B2");
    }
}

public class SomeClass
{
    public static async Task<int> SomeAsyncApi()
    {
        TaskExt.Log("X1");
        await Task.Delay(1000).WithCompletionLog(step: "X1.5").ConfigureAwait(false);
        TaskExt.Log("X2");
        return 42;
    }
}

输出:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

现在,如果您确实关心延续的线程(例如,当您使用 UI 控件时),您可以通过切换到该线程、将相关工作发布到该线程来“付费”。您仍然从不需要该线程的所有工作中获益。

如果您想更进一步,从 UI 线程中移除这些 async 方法的同步工作,您只需使用一次 Task.Run,然后添加另一个开关:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await Task.Run(() => AnotherClass.MethodAsync()).ConfigureAwait(false);
    TaskExt.Log("A2");
}

输出:

{ step = A1, thread = 9 }
{ step = B1, thread = 10 }
{ step = X1, thread = 10 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

这个使用ConfigureAwait(false) 的指南是针对库开发人员的,因为这才是它真正重要的地方,但重点是尽可能使用它,在这种情况下,您可以减少这些特殊上下文的工作,同时保持线程切换在最低限度。


使用WithNoContext 与在任何地方使用ConfigureAwait(false) 的结果完全相同。然而,缺点是它与线程的SynchronizationContext 混淆了,并且您在async 方法中没有意识到这一点。 ConfigureAwait直接影响到现在的await,所以前因后果在一起。

正如我所指出的,同样使用Task.Run 与在任何地方使用ConfigureAwait(false) 具有完全相同的结果,其附加值是将async 方法的同步部分卸载到ThreadPool。如果需要,那么Task.Run 是合适的,否则ConfigureAwait(false) 就足够了。


现在,如果您在处理错误的库时 ConfigureAwait(false) 使用不当,您可以通过删除 SynchronizationContext 来解决它,但使用 Thread.Run 更简单、更清晰,并且可以将工作卸载到ThreadPool 的开销可以忽略不计。

【讨论】:

  • ASP.NET 中没有“特殊”线程,但是使用ConfigureAwait(false) 仍然可以提高性能,因为await 延续会立即在同一个线程上执行前面的任务结束的地方。而不是与ContinueWith 一起排队到另一个池线程。参考AspNetSynchronizationContext.Post实现,我的回答中有链接。
  • @Noseratio 是的,有。它们之所以特别,是因为它们具有例如 HttpContext.Current 的上下文(我更新以明确表示)。此外,与ContinueWith 一起使用的延续也可以在同一线程上同步运行。
  • 所以,如果我已经不关心HttpContext.Current 等(因为我首先在我的库中使用ConfigureAwait(false)),那么问题仍然存在。如何在我的库中正确使用ConfigureAwait(false),以免在 ASP.NET 中导致冗余线程切换,这可能会损害服务器端性能(并避免我描述的场景)?
  • @Noseratio 正如我在回答中所解释的那样。一直使用ConfigureAwait(false)(就像你一直使用async一样),所以每个根async操作最多只有一个开关(假设所有延续同步运行`)
  • @Noseratio ConfigureAwait(false) 的有效使用不是在捕获的上下文中恢复,因为它不受除您编写代码之外的任何人的摆布。如果某些库确实不必要地捕获了上下文并且降低了性能,我会称之为错误,但它不会否定 yourConfigureAwait(false) 的使用
【解决方案3】:

显然,内置ConfigureAwait(false) 的行为是在ThreadPool 上调用await 的延续。这样做的原因,I assume,是为了防止多个异步工作流等待同一个不完整的任务,然后在同一个线程上以序列化的方式调用它们的延续。如果一个工作流的继续被阻塞并等待来自另一个工作流的信号,这种情况可能会导致死锁。另一个工作流将永远没有机会发送信号,因为它的延续将位于同一(阻塞)线程的等待队列中。

如果您没有预料到这种情况会在您的应用程序中发生(如果您确定一个任务永远不会被两个工作流等待),那么您可以尝试使用下面的自定义 ConfigureAwait2 方法:

public static ConfiguredTaskAwaitable2 ConfigureAwait2(this Task task,
    bool continueOnCapturedContext)
    => new ConfiguredTaskAwaitable2(task, continueOnCapturedContext);

public struct ConfiguredTaskAwaitable2 : INotifyCompletion
{
    private readonly Task _task;
    private readonly bool _continueOnCapturedContext;

    public ConfiguredTaskAwaitable2(Task task, bool continueOnCapturedContext)
    {
        _task = task; _continueOnCapturedContext = continueOnCapturedContext;
    }
    public ConfiguredTaskAwaitable2 GetAwaiter() => this;
    public bool IsCompleted { get { return _task.IsCompleted; } }
    public void GetResult() { _task.GetAwaiter().GetResult(); }
    public void OnCompleted(Action continuation)
    {
        var capturedContext = _continueOnCapturedContext ?
            SynchronizationContext.Current : null;
        _ = _task.ContinueWith(_ =>
        {
            if (capturedContext != null)
                capturedContext.Post(_ => continuation(), null);
            else
                continuation();
        }, default, TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
}

在您的示例中(在方法MethodAsync 内),我用.ConfigureAwait2(false) 替换了.ConfigureAwait(false),得到了以下输出:

{ step = A1, thread = 1 }
{ step = B1, thread = 1 }
{ step = X1, thread = 1 }
{ step = X1.5, thread = 4 }
{ step = X2, thread = 1 }
{ step = B2, thread = 1 }
{ step = A2, thread = 1 }

【讨论】:

    猜你喜欢
    • 2014-12-01
    • 2020-03-24
    • 2014-02-15
    • 1970-01-01
    • 1970-01-01
    • 2018-08-31
    • 1970-01-01
    • 1970-01-01
    • 2019-04-07
    相关资源
    最近更新 更多