ConfigureAwait(false) 导致错误而不是死锁的情况

A case when ConfigureAwait(false) causes an error instead of deadlock

假设我写了一个依赖于async方法的库:

namespace MyLibrary1
{
    public class ClassFromMyLibrary1
    {
        public async Task<string> MethodFromMyLibrary1(string key, Func<string, Task<string>> actionToProcessNewValue)
        {
            var remoteValue = await GetValueByKey(key).ConfigureAwait(false);

            //do some transformations of the value
            var newValue = string.Format("Remote-{0}", remoteValue);

            var processedValue = await actionToProcessNewValue(newValue).ConfigureAwait(false);

            return string.Format("Processed-{0}", processedValue);
        }

        private async Task<string> GetValueByKey(string key)
        {
            //simulate time-consuming operation
            await Task.Delay(500).ConfigureAwait(false);

            return string.Format("ValueFromRemoteLocationBy{0}", key);
        }
    }
}

我遵循了在我的图书馆中到处使用 ConfigureAwait(false)(如 this post)的建议。然后我从我的测试应用程序中以 同步 方式使用它并失败:

namespace WpfApplication1
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Button1_OnClick(object sender, RoutedEventArgs e)
        {
            try
            {
                var c = new ClassFromMyLibrary1();

                var v1 = c.MethodFromMyLibrary1("test1", ActionToProcessNewValue).Result;

                Label2.Content = v1;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Trace.TraceError("{0}", ex);
                throw;
            }
        }

        private Task<string> ActionToProcessNewValue(string s)
        {
            Label1.Content = s;
            return Task.FromResult(string.Format("test2{0}", s));
        }
    }
}

失败是:

WpfApplication1.vshost.exe Error: 0 : System.InvalidOperationException: The calling thread cannot access this object because a different thread owns it. at System.Windows.Threading.Dispatcher.VerifyAccess() at System.Windows.DependencyObject.SetValue(DependencyProperty dp, Object value) at System.Windows.Controls.ContentControl.set_Content(Object value) at WpfApplication1.MainWindow.ActionToProcessNewValue(String s) in C:\dev\tests\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 56 at MyLibrary1.ClassFromMyLibrary1.d__0.MoveNext() in C:\dev\tests\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 77 --- End of stack trace from previous location where exception was thrown --- at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task) at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult() at WpfApplication1.MainWindow.d__1.MoveNext() in C:\dev\tests\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 39 Exception thrown: 'System.InvalidOperationException' in WpfApplication1.exe

显然错误发生是因为我的库中的等待者丢弃了当前的 WPF 上下文。

另一方面,在删除库中所有地方的 ConfigureAwait(false) 之后,我显然遇到了死锁。

There is more detailed example of code 这解释了我必须处理的一些限制。

那么我该如何解决这个问题呢?这里最好的方法是什么?我还需要遵循有关 ConfigureAwait 的最佳做法吗?

PS,在实际场景中,我有很多 类 和方法,因此我的库中有大量此类异步调用。几乎不可能找出某个特定的异步调用是否需要上下文(请参阅对@Alisson 回复的评论)来修复它。不过,我不关心性能,至少在这一点上是这样。我正在寻找解决此问题的通用方法。

在我看来,您应该重新设计您的库 API,不要将基于回调的 API 与基于任务的 API 混合使用。至少在您的示例代码中,没有令人信服的案例可以做到这一点,并且您已经找到了不这样做的一个原因 - 很难控制回调运行的上下文。

我会将您的图书馆 API 改成这样:

namespace MyLibrary1
{
    public class ClassFromMyLibrary1
    {
        public async Task<string> MethodFromMyLibrary1(string key)
        {
            var remoteValue = await GetValueByKey(key).ConfigureAwait(false);
            return remoteValue;
        }

        public string TransformProcessedValue(string processedValue)
        {
            return string.Format("Processed-{0}", processedValue);
        }

        private async Task<string> GetValueByKey(string key)
        {
            //simulate time-consuming operation
            await Task.Delay(500).ConfigureAwait(false);

            return string.Format("ValueFromRemoteLocationBy{0}", key);
        }
    }
}

然后这样称呼它:

   private async void Button1_OnClick(object sender, RoutedEventArgs e)
    {
        try
        {
            var c = new ClassFromMyLibrary1();

            var v1 = await c.MethodFromMyLibrary1("test1");
            var v2 = await ActionToProcessNewValue(v1);
            var v3 = c.TransformProcessedValue(v2);

            Label2.Content = v3;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Trace.TraceError("{0}", ex);
            throw;
        }
    }

    private Task<string> ActionToProcessNewValue(string s)
    {
        Label1.Content = s;
        return Task.FromResult(string.Format("test2{0}", s));
    }

实际上,您在 ClassFromMyLibrary1 中收到回调,您无法假设它会做什么(例如更新标签)。您的 class 库中不需要 ConfigureAwait(false),因为您提供的 link 给了我们这样的解释:

As asynchronous GUI applications grow larger, you might find many small parts of async methods all using the GUI thread as their context. This can cause sluggishness as responsiveness suffers from "thousands of paper cuts".

To mitigate this, await the result of ConfigureAwait whenever you can.

By using ConfigureAwait, you enable a small amount of parallelism: Some asynchronous code can run in parallel with the GUI thread instead of constantly badgering it with bits of work to do.

现在阅读这里:

You should not use ConfigureAwait when you have code after the await in the method that needs the context. For GUI apps, this includes any code that manipulates GUI elements, writes data-bound properties or depends on a GUI-specific type such as Dispatcher/CoreDispatcher.

你做的恰恰相反。您正尝试在两点更新 GUI,一个在您的回调方法中,另一个在此处:

var c = new ClassFromMyLibrary1();

var v1 = c.MethodFromMyLibrary1("test1", ActionToProcessNewValue).Result;

Label2.Content = v1; // updating GUI...

这就是为什么删除 ConfigureAwait(false) 可以解决您的问题。此外,您可以使按钮单击处理程序异步并等待您的 ClassFromMyLibrary1 方法调用。

通常情况下,库会记录回调是否保证在调用它的同一线程上,如果没有记录,最安全的选择是假设它没有。您的代码示例(以及我从您的评论中可以看出的与您合作的第 3 方)属于 "Not guaranteed" 类别。在那种情况下,您只需要检查是否需要从回调方法内部执行 Invoke 并执行它,您可以调用 Dispatcher.CheckAccess() 并且它将 return false 如果您需要在使用控件之前调用。

private async Task<string> ActionToProcessNewValue(string s)
{
    //I like to put the work in a delegate so you don't need to type 
    // the same code for both if checks
    Action work = () => Label1.Content = s;
    if(Label1.Dispatcher.CheckAccess())
    {
        work();
    }
    else
    {
        var operation = Label1.Dispatcher.InvokeAsync(work, DispatcherPriority.Send);

        //We likely don't need .ConfigureAwait(false) because we just proved
        // we are not on the UI thread in the if check.
        await operation.Task.ConfigureAwait(false);
    }

    return string.Format("test2{0}", s);
}

这是一个带有同步回调而不是异步回调的替代版本。

private string ActionToProcessNewValue(string s)
{
    Action work = () => Label1.Content = s;
    if(Label1.Dispatcher.CheckAccess())
    {
        work();
    }
    else
    {
        Label1.Dispatcher.Invoke(work, DispatcherPriority.Send);
    }

    return string.Format("test2{0}", s);
}

如果你想从 Label1.Content 中获取值而不是分配它,这是另一个版本,这也不需要在回调中使用 async/await。

private Task<string> ActionToProcessNewValue(string s)
{
    Func<string> work = () => Label1.Content.ToString();
    if(Label1.Dispatcher.CheckAccess())
    {
        return Task.FromResult(work());
    }
    else
    {
        return Label1.Dispatcher.InvokeAsync(work, DispatcherPriority.Send).Task;
    }
}

重要说明:如果您不删除按钮单击处理程序中的 .Result,所有这些方法都会导致您的程序死锁,Dispatcher.Invoke 或回调中的 Dispatcher.InvokeAsync 在等待 .Result 到 return 时永远不会开始,而 .Result 在等待时永远不会 return回调到 return。您必须将点击处理程序更改为 async void 并执行 await 而不是 .Result.