重温Task.ConfigureAwait(continueOnCapturedContext:false)

nos*_*tio 19 .net c# task-parallel-library async-await

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

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

例如,有一个实现SomeAsyncApiAPI的第三方库.请注意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);
    }
}
Run Code Online (Sandbox Code Playgroud)

现在,假设有一些客户端代码在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");
}
Run Code Online (Sandbox Code Playgroud)

输出:

{ 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 }

这里,逻辑执行流程经过4个线程切换.其中2个是多余的并且由它引起SomeAsyncApi().ConfigureAwait(false).这是因为从具有同步上下文的线程(在本例中是UI线程)ConfigureAwait(false)推送延续ThreadPool.

在这种特殊情况下,MethodAsync最好没有ConfigureAwait(false).然后它只需要2个线程开关和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 }

但是,作者MethodAsync使用ConfigureAwait(false)所有善意并遵循最佳实践,她对内部实施一无所知SomeAsyncApi.如果ConfigureAwait(false)"一直"使用(即内部SomeAsyncApi也是如此),这不会是一个问题,但这超出了她的控制范围.

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

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask;
Run Code Online (Sandbox Code Playgroud)

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

问题:根据所描述的行为ConfigureAwait(false),我正在寻找一种优雅的使用方式,async/await同时仍然最小化冗余线程/上下文切换.理想情况下,可以使用现有的第三方库.

到目前为止我看过的内容:

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

    await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false);
    
    Run Code Online (Sandbox Code Playgroud)
  • 另一个hackish解决方案可能是暂时从当前线程中删除同步上下文,因此它不会被内部调用链中的任何后续等待捕获(我之前在此处提到):

    async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false);
        TaskExt.Log("B2");
    }
    
    Run Code Online (Sandbox Code Playgroud)
    { 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;
    }
    
    Run Code Online (Sandbox Code Playgroud)

    这有效,但我不喜欢它篡改线程的当前同步上下文的事实,尽管范围非常短.此外,还有另一个含义:在SynchronizationContext当前线程没有的情况下,环境TaskScheduler.Current将用于await延续.考虑到这一点,WithNoContext可能会像下面一样改变,这将使这个黑客更加异国情调:

    // task = func();
    var task2 = new Task<Task<TResult>>(() => func());
    task2.RunSynchronously(TaskScheduler.Default); 
    task = task2.Unwrap();
    
    Run Code Online (Sandbox Code Playgroud)

我很欣赏任何其他想法.

更新,以解决@ i3arnon的评论:

我会说这是另一种方式,因为斯蒂芬在他的回答中说:"ConfigureAwait(false)的目的不是诱导线程切换(如果需要),而是防止在特定的特殊上下文上运行太多代码. " 您不同意并且是您的合规的根源.

由于你的答案已被编辑,为了清楚起见,这是我不同意的陈述:

ConfigureAwait(false)目标是尽可能地减少"特殊"(例如UI)线程需要处理的工作,尽管它需要线程切换.

我也不同意你对该声明的当前版本.我会把你推荐给主要来源,Stephen Toub的博客文章:

避免不必要的封送

如果可能的话,请确保您调用的异步实现不需要被阻塞的线程来完成操作(这样,您可以使用常规阻塞机制同步等待异步工作在其他地方完成).在async/await的情况下,这通常意味着确保您正在调用的异步实现中的任何等待都在所有等待点上使用ConfigureAwait(false); 这将阻止await尝试编组回当前的SynchronizationContext.作为一个库实现者,最好总是在所有等待中使用ConfigureAwait(false),除非你有特殊的理由不这样做; 这不仅有助于避免这些类型的死锁问题,而且还有助于提高性能,因为它可以避免不必要的编组成本.

它的确表示目标是避免不必要的编组成本,以提高性能.线程切换(其中包括流动ExecutionContext)一个很大的编组成本.

现在,它没有说任何地方的目标是减少在"特殊"线程或上下文上完成的工作量.

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

而且,AspNetSynchronizationContext与UI线程不同,最小化工作是没有意义的- 它本身就是从线程流向线程.相反,一旦你开启AspNetSynchronizationContext,你想做尽可能多的工作,以避免在处理HTTP请求的过程中不必要的切换.尽管如此,ConfigureAwait(false)在ASP.NET中使用仍然很有意义:如果使用得当,它会再次减少服务器端的线程切换.

Ste*_*ary 16

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

这种模式背后的原因是"它有助于避免死锁".

并叠加潜水.

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

  • 它在异步操作的整体范围内可以忽略不计,但并不理想.理想情况下,`ConfigureAwait(false)`将按您的描述行事:在进入上下文之前尽可能多地完成工作. (2认同)
  • @Noseratio:初始意图是否是为了减少上下文中的代码,这就是它实际上的行为方式.即,没有办法要求SyncCtx像TaskSched一样运行同步,因此ConfigureAwait(false)最终会特意处理"SyncCtx"(它主动避免任何SyncCtx).关于ASP.NET,尽可能避免使用SyncCtx可以为该请求启用少量并行性. (2认同)
  • 您"遇到没有正确使用`ConfigureAwait`的代码"可能只有在代码已经生产时才会变得清晰.没有资料并仔细研究它们就无法知道. (2认同)

i3a*_*non 7

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

我不同意你的前提.ConfigureAwait(false)目标是尽可能地减少需要编组回"特殊"(例如UI)上下文的工作,尽管它可能需要脱离该上下文的线程切换.

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

要实现这一点,你应该在ConfigureAwait 任何地方使用你不关心执行延续的线程.如果您采用您的示例并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;
    }
}
Run Code Online (Sandbox Code Playgroud)

输出:

{ 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 }
Run Code Online (Sandbox Code Playgroud)

现在,在你关心continuation的线程的地方(例如,当你使用UI控件时),通过切换到该线程,通过将相关工作发布到该线程来"支付".你仍然从所有不需要该线程的工作中获益.

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

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

输出:

{ 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 }
Run Code Online (Sandbox Code Playgroud)

这个使用指南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有一个非常微不足道的开销.