ConfigureAwait(false)与将同步上下文设置为null的区别

12
我经常看到有人建议在异步库代码中使用ConfigureAwait(false),以避免我们的调用返回被安排在UI线程或Web请求同步上下文上,从而导致死锁等问题。但是使用ConfigureAwait(false)的一个问题是,你不能只在库调用的入口点上这样做。为了使其有效,必须在整个库代码的堆栈中都这样做。
在我看来,一个可行的替代方案是在库的顶层公共入口点将当前同步上下文设置为null,并忘记ConfigureAwait(false)。然而,我没有看到很多人采取或推荐这种方法。
在库入口点上简单地将当前同步上下文设置为null是否有问题?除了await发布到默认同步上下文可能会导致微不足道的性能损失之外,还有什么潜在的问题吗?
(编辑#1)添加一些我所指的示例代码:
   public class Program
    {
        public static void Main(string[] args)
        {
            SynchronizationContext.SetSynchronizationContext(new LoggingSynchronizationContext(1));

            Console.WriteLine("Executing library code that internally clears synchronization context");
            //First try with clearing the context INSIDE the lib
            RunTest(true).Wait();
            //Here we again have the context intact
            Console.WriteLine($"After First Call Context in Main Method is {SynchronizationContext.Current?.ToString()}");


            Console.WriteLine("\nExecuting library code that does NOT internally clear the synchronization context");
            RunTest(false).Wait();
            //Here we again have the context intact
            Console.WriteLine($"After Second Call Context in Main Method is {SynchronizationContext.Current?.ToString()}");

        }

        public async static Task RunTest(bool clearContext)
        {
            Console.WriteLine($"Before Lib call our context is {SynchronizationContext.Current?.ToString()}");
            await DoSomeLibraryCode(clearContext);
            //The rest of this method will get posted to my LoggingSynchronizationContext

            //But.......
            if(SynchronizationContext.Current == null){
                //Note this will always be null regardless of whether we cleared it or not
                Console.WriteLine("We don't have a current context set after return from async/await");
            }
        }


        public static async Task DoSomeLibraryCode(bool shouldClearContext)
        {
            if(shouldClearContext){
                SynchronizationContext.SetSynchronizationContext(null);
            }
            await DelayABit();
            //The rest of this method will be invoked on the default (null) synchronization context if we elected to clear the context
            //Or it should post to the original context otherwise
            Console.WriteLine("Finishing library call");
        }

        public static Task DelayABit()
        {
            return Task.Delay(1000);
        }

    }

    public class LoggingSynchronizationContext : SynchronizationContext
    {

        readonly int contextId;
        public LoggingSynchronizationContext(int contextId)
        {
            this.contextId = contextId;
        }
        public override void Post(SendOrPostCallback d, object state)
        {
            Console.WriteLine($"POST TO Synchronization Context (ID:{contextId})");
            base.Post(d, state);
        }

        public override void Send(SendOrPostCallback d, object state)
        {
            Console.WriteLine($"Post Synchronization Context (ID:{contextId})");
            base.Send(d, state);
        }

        public override string ToString()
        {
            return $"Context (ID:{contextId})";
        }
    }

执行此操作将输出以下内容:
Executing library code that internally clears synchronization context
Before Lib call our context is Context (ID:1) 
Finishing library call 
POST TO Synchronization Context (ID:1)
We don't have a current context set after return from async/await
After First Call Context in Main Method is Context (ID:1)

Executing library code that does NOT internally clear the synchronization context 
Before Lib call our context is Context (ID:1) POST TO Synchronization Context (ID:1) 
Finishing library call
POST TO Synchronization Context (ID:1) 
We don't have a current context set after return from async/await
After Second Call Context in Main Method is Context (ID:1)

这一切都像我预期的那样运作,但我没有遇到有人建议库在内部执行此操作。我发现要求每个内部等待点都调用 ConfigureAwait(false) 很烦人,即使一个 ConfigureAwait() 被忽略也会在整个应用程序中引起问题。这似乎可以通过在库的公共入口处添加一行代码来简单地解决问题。我错过了什么吗?
(编辑#2)
根据 Alexei 的答案反馈,似乎我没有考虑到任务可能不会立即被等待的情况。由于执行上下文是在 await 时捕获的(而不是异步调用时),这意味着对 SynchronizationContext.Current 的更改不会仅限于库方法。基于此,似乎将库的内部逻辑包装在强制等待的调用中以强制捕获上下文就足够了。例如:
    async void button1_Click(object sender, EventArgs e)
    {
        var getStringTask = GetStringFromMyLibAsync();
        this.textBox1.Text = await getStringTask;
    }

    async Task<string> GetStringFromMyLibInternal()
    {
        SynchronizationContext.SetSynchronizationContext(null);
        await Task.Delay(1000);
        return "HELLO WORLD";
    }

    async Task<string> GetStringFromMyLibAsync()
    {
        //This forces a capture of the current execution context (before synchronization context is nulled
        //This means the caller's context should be intact upon return
        //even if not immediately awaited.
        return await GetStringFromMyLibInternal();          
    }

(第三次编辑)

基于Stephen Cleary的回答中的讨论,这种方法存在一些问题。但我们可以通过在非异步方法中包装库调用来实现类似的方法,该方法仍然返回一个任务,但在结束时会处理重置同步上下文。(请注意,这使用了Stephen的AsyncEx库中的SynchronizationContextSwitcher.)

    async void button1_Click(object sender, EventArgs e)
    {
        var getStringTask = GetStringFromMyLibAsync();
        this.textBox1.Text = await getStringTask;
    }

    async Task<string> GetStringFromMyLibInternal()
    {
        SynchronizationContext.SetSynchronizationContext(null);
        await Task.Delay(1000);
        return "HELLO WORLD";
    }

    Task<string> GetStringFromMyLibAsync()
    {
        using (SynchronizationContextSwitcher.NoContext())
        {
            return GetStringFromMyLibInternal();          
        } 
        //Context will be restored by the time this method returns its task.
    }

如果你能展示出正确地设置和恢复上下文(特别是在代码作为await返回时),那么这个问题会更好一些...我还怀疑当你试着编写这样的代码时,你会得到你的答案 :) - Alexei Levenkov
我不确定你的意思。据我所知,在等待点处,同步上下文被捕获但未恢复。它仅被等待者用于发布继续委托,但如果在await之后立即执行 SynchronizationContext.Current,则它将始终为空(除非上下文本身做些什么来恢复它)。 - Daniel Tabuenca
你应该明白,你的提议看起来像是想要更改当前线程的同步上下文(即从UI更改为null),而不是恢复它,因此在调用你的库后,所有与你的库无关的其他调用都将使用null上下文(除非调用者显式地使用await保护他们的上下文,这不是必需的)。 - Alexei Levenkov
我更新了我的问题,并附上了示例代码,以展示我的意思。希望现在更加清晰明了。我越想越觉得这种方法没有任何缺点(即使是性能方面的缺点)。但在我大规模使用之前,我想请有更多经验的人来验证这种方法的可行性。 - Daniel Tabuenca
SynchronizationContext 在调用点被捕获,因此我的库的调用者仍然控制着同步上下文,他们的 await 将使用该上下文来执行从库代码返回后的继续操作。我只在我的库中修改了 SynchronizationContext - Daniel Tabuenca
1
我已经将代码添加为答案 - 您似乎期望每个async调用立即被await,但事实并非如此。也就是说,以并行方式运行代码的标准方法是首先收集任务,然后使用WhenAll进行await - Alexei Levenkov
2个回答

10
我经常看到在异步库代码中推荐使用ConfigureAwait(false)来避免调用返回被安排在UI线程或Web请求同步上下文中引起死锁等问题。我推荐使用ConfigureAwait(false),因为它(正确地)指出不需要调用上下文。它也给您带来了一些性能上的好处。虽然ConfigureAwait(false)可以防止死锁,但这并不是它的预期目的。
似乎对我来说,一个可行的替代方案是在库的顶层公共入口点将当前同步上下文设置为null,然后忘记ConfigureAwait(false)。是的,这是一个选择。但它并不能完全避免死锁,因为await将尝试在TaskScheduler.Current上恢复执行,如果没有当前的SynchronizationContext。此外,让库替换框架级组件感觉不太对。

但如果您想要这样做,可以。只是不要忘记在最后将其设置回原始值。

哦,还有一个陷阱:有一些API会假定当前的SyncCtx是提供给该框架的。某些ASP.NET帮助器API就是这样的。因此,如果您调用终端用户代码,可能会出现问题。但在这种情况下,您应该明确记录他们的回调在哪个上下文中调用。

然而,我并没有看到很多人采取或推荐这种方法。

它正在慢慢变得更加流行。足够流行,以至于我在我的AsyncEx库中添加了一个API来实现这个:

using (SynchronizationContextSwitcher.NoContext())
{
  ...
}

我自己没有使用过这种技术。

除了可能对默认同步上下文进行的等待发布的微不足道的性能影响之外,这种方法还存在其他潜在问题吗?

实际上,这只会带来微不足道的性能提升。

2
旁注:由于“调用上下文”包括ASP.Net的当前区域设置,因此大多数人并不意识到他们的代码多么依赖它。确实,正确编写的代码将以一种独立于同步上下文的方式沿着调用传递所有上下文,但这样的代码很少见... - Alexei Levenkov
关于一个库替换框架级组件的问题。根据我所进行的测试,如果编写得当,这种更改将在从库调用返回时自动恢复(前提是在上下文设置为null之前至少有一个async/await点(请参见EDIT#2))。我的理解是否不正确? - Daniel Tabuenca
2
这里有一个非常有趣的替代方法:https://blogs.msdn.microsoft.com/benwilli/2017/02/07/alternative-to-configure-await-false-everywhere/ - Stephen Cleary
1
从@StephenCleary的最后一条评论中更正了链接:https://blogs.msdn.microsoft.com/benwilli/2017/02/09/an-alternative-to-configureawaitfalse-everywhere/ - cremor
1
@BornToCode:是的,我的意思是如果它被设置为其他的TaskScheduler,比如这些之一 - Stephen Cleary
显示剩余3条评论

1

同步上下文类似于静态变量,如果在控制离开方法之前不恢复它并进行更改,则会导致意外行为。

我不认为你可以在任何await的库函数内安全地设置当前线程的同步上下文,因为在编译器生成的代码中间恢复上下文实际上是不可能的。

示例:

 async Task<int> MyLibraryMethodAsync()
 {
    SynchronizationContext.SetSynchronizationContext(....);
    await SomeInnerMethod(); // note that method returns at this point

    // maybe restore synchronization context here...
    return 42;
 }

 ...
 // code that uses library, runs on UI thread

 void async OnButtonClick(...)
 {
    // <-- context here is UI-context, code running on UI thread
    Task<int> doSomething = MyLibraryMethodAsync(); 
    // <-- context here is set by MyLibraryMethod - i.e. null, code running on UI thread
    var textFromFastService = await FastAsync();
    // <-- context here is set by MyLibraryMethod, code running on pool thread (non-UI)
    textBox7.Text = textFromFastService; // fails...

    var get42 = await doSomething;
}

非常有趣。这绝对是我没有考虑过的问题。我已经通过一个可能的解决方案更新了我的问题。 - Daniel Tabuenca

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接