一尘不染

重访Task.ConfigureAwait(continueOnCapturedContext:false)

c#

阅读时间太长。 使用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);
    }
}

现在,假设有一些客户端代码在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)将继续操作ThreadPool从具有同步上下文的线程(在本例中为UI线程)推入到

在这种情况下,MethodAsync最好不要使用ConfigureAwait(false)。然后只需要2个线程切换与4个线程切换:

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

但是,作者MethodAsync出于ConfigureAwait(false)所有良好意图并遵循最佳做法进行使用,她对的内部实现一无所知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同时仍然尽量减少冗余线程/上下文切换。理想情况下,可以使用现有的第三方库的东西。

到目前为止,我所看的是

  • 用卸载asynclambda Task.Run是不理想的,因为它会引入至少一个额外的线程开关(尽管它可以潜在地节省许多其他线程):

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

    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}

    public static Task<TResult> WithNoContext<TResult>(Func<Task<TResult>> func)
    

    {
    Task 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的评论

我会说这是另一回事,因为正如Stephen在回答中所说的那样:“
ConfigureAwait(false)的目的不是要引起线程切换(如有必要),而是防止在特定的特殊上下文上运行过多的代码。 ”
您不同意并且是合规性的根源。

由于您的答案已被编辑,为清晰起见,以下我不同意的声明

ConfigureAwait(false)的目标是尽可能减少“特殊”(例如,UI)线程尽管需要进行线程切换也需要处理的工作。

我也不同意您当前的说法。我将带您参考主要资源,Stephen
Toub的博客文章

避免不必要的封送处理

如果有可能,请确保您正在调用的异步实现不需要阻塞线程即可完成操作(这样,您就可以使用常规的阻塞机制来同步等待异步工作在其他地方完成)。对于async /
await,这通常意味着确保您正在调用的异步实现内的所有await在所有await点上都使用ConfigureAwait(false);这将防止等待尝试编组回当前的SynchronizationContext。作为库实现者,最佳实践是始终在所有等待中使用ConfigureAwait(false),除非有特殊原因不这样做;这不仅有助于避免此类死锁问题,
而且还可以提高性能,因为它避免了不必要的封送处理成本。

它确实说过,目标是避免 不必要的封送成本,以提高性能 。线程切换(其流动的ExecutionContext,除其他事项外)
一个大的编组成本。

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

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

此外,最小化工作量根本没有意义AspNetSynchronizationContext-与UI线程不同,它本身在线程之间流动。相反,
一旦打开AspNetSynchronizationContext,您就想做尽可能多的工作,以避免在处理HTTP请求的过程中进行不必要的切换。但是,ConfigureAwait(false)在ASP.NET中使用仍然是很有意义的:如果使用正确,它将再次减少服务器端线程切换。


阅读 868

收藏
2020-05-19

共1个答案

一尘不染

当您处理异步操作时,线程切换的开销太小而无法关心(通常而言)。的目的ConfigureAwait(false)不是要引起线程切换(如有必要),而是要防止在特定的特殊上下文中运行过多的代码。

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

和堆栈潜水。

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

2020-05-19