c# - 重温 Task.ConfigureAwait(continueOnCapturedContext : false)

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

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

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

例如,有一个 3rd 方库实现了 SomeAsyncApi应用程序接口(interface)。请注意 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");
}

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

Here, the logical execution flow goes through 4 thread switches. 2 of them are redundant and caused by SomeAsyncApi().ConfigureAwait(false). It happens because ConfigureAwait(false) pushes the continuation to ThreadPool from a thread with synchronization context (in this case, the UI thread).

In this particular case, MethodAsync is better off without ConfigureAwait(false). Then it only takes 2 thread switches vs 4:

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

However, the author of MethodAsync uses ConfigureAwait(false) with all good intentions and following the best practices, and she knows nothing about internal implementation of SomeAsyncApi. It wouldn't be a problem if ConfigureAwait(false) was used "all the way" (i.e., inside SomeAsyncApi too), but that's beyond her control.

That's how it goes with WindowsFormsSynchronizationContext (or DispatcherSynchronizationContext), where we might be not caring about extra thread switches at all. However, a similar situation could happen in ASP.NET, where AspNetSynchronizationContext.Post essentially does this:

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

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

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

到目前为止,我所看到的 :
  • 卸载 async lambda 与 Task.Run并不理想,因为它引入了至少一个额外的线程切换(尽管它可能会节省许多其他线程):
    await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false);
    
  • 另一种骇人听闻的解决方案可能是暂时从当前线程中删除同步上下文,因此它不会被内部调用链中的任何后续等待捕获(我之前提到过 here ):
    async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false);
        TaskExt.Log("B2");
    }
    
    { step = A1, thread = 8 }
    { step = B1, thread = 8 }
    { step = X1, thread = 8 }
    { step = X1.5, thread = 10 }
    { step = X2, thread = 10 }
    { step = B2, thread = 10 }
    { step = A2, thread = 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 :

    I would say that it's the other way around because as Stephen said in his answer "The purpose of ConfigureAwait(false) is not to induce a thread switch (if necessary), but rather to prevent too much code running on a particular special context." which you disagree with and is the root of your compliant.



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

    ConfigureAwait(false) goal is to reduce, as much as possible, the work the "special" (e.g. UI) threads need to process in spite of the thread switches it requires.



    我也不同意你的 current version那个声明。我会向您介绍主要来源,Stephen Toub 的 blog post :

    Avoid Unnecessary Marshaling

    If at all possible, make sure the async implementation you’re calling doesn’t need the blocked thread in order to complete the operation (that way, you can just use normal blocking mechanisms to wait synchronously for the asynchronous work to complete elsewhere). In the case of async/await, this typically means making sure that any awaits inside of the asynchronous implementation you’re calling are using ConfigureAwait(false) on all await points; this will prevent the await from trying to marshal back to the current SynchronizationContext. As a library implementer, it’s a best practice to always use ConfigureAwait(false) on all of your awaits, unless you have a specific reason not to; this is good not only to help avoid these kinds of deadlock problems, but also for performance, as it avoids unnecessary marshaling costs.



    它确实说目标是为了性能避免不必要的编码成本。线程切换(流经 ExecutionContext 等)是一个很大的编码(marshal)成本。

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

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

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

    最佳答案

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

    The reasoning behind this pattern was that "it helps to avoid deadlocks".



    和堆栈潜水。

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

    关于c# - 重温 Task.ConfigureAwait(continueOnCapturedContext : false),我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/28410046/

    相关文章:

    c# - 将流作为文件添加到 ISO

    c# - MVC : Problems to pass currency in decimal parameter

    c# - 如何让一个方法接受两种类型的数据作为参数?

    c# - TryExecuteTask(task) 总是阻塞吗?

    c# - 无法使用接口(interface)反序列化类型

    C# 正则表达式问题

    .net - .NET 中的虚拟驱动器?

    c# - Freezable.IsFrozen 和 DependencyObject.IsSealed

    c# - 如何更改异步方法调用以防止强制调用堆栈异步

    asynchronous - F# 中的 F# 连续循环