【问题标题】:A case when ConfigureAwait(false) causes an error instead of deadlockConfigureAwait(false) 导致错误而不是死锁的情况
【发布时间】:2016-08-23 16:05:43
【问题描述】:

假设我编写了一个依赖于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 帖子)的建议。然后我从我的测试应用中以 同步 方式使用它并失败:

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 错误:0: System.InvalidOperationException:调用线程无法访问 这个对象,因为不同的线程拥有它。在 System.Windows.Threading.Dispatcher.VerifyAccess() 在 System.Windows.DependencyObject.SetValue(DependencyProperty dp,对象 值)在 System.Windows.Controls.ContentControl.set_Content(对象 值)在 WpfApplication1.MainWindow.ActionToProcessNewValue(字符串 s) 在 C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 56 在 MyLibrary1.ClassFromMyLibrary1.d__0.MoveNext() 在 C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 77 --- 从先前抛出异常的位置结束堆栈跟踪 --- 在 System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(任务 任务)在 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(任务 任务)在 System.Runtime.CompilerServices.TaskAwaiter`1.GetResult() 在 WpfApplication1.MainWindow.d__1.MoveNext() 中 C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 39 抛出异常:“System.InvalidOperationException”在 WpfApplication1.exe

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

另一方面,在库中的所有位置删除 ConfigureAwait(false) 后,我显然会陷入死锁。

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

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

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

【问题讨论】:

  • 你不需要从任何地方删除ConfigureAwait(false),你需要删除它的唯一地方是await GetValueByKey(key).ConfigureAwait(false);,它会起作用。有关原因的完整说明,请参阅 Allision's answer
  • 您的要求可能无法满足。您希望您的 UI 线程坐在那里什么都不做,在您的操作完成之前禁止它做任何事情,同时还需要在 UI 线程上执行一些代码才能完成相同的操作。您的要求要求陷入僵局。这两个要求之一将需要消失才能解决问题。
  • 你是对的。我已经简化了客户端代码来演示 同步 调用。在实际代码中,我无法更改调用库方法的方式,因为它是从 同步 方法调用的,而该方法又被其他 同步 方法调用,依此类推。最终这条链来自 GUI 事件处理程序。
  • 我唯一能做的就是Task.Run(async () =&gt; c.MethodFromMyLibrary1),不要等待完成。但这会导致其他严重问题,例如错误处理和返回结果。嗯,这真的很复杂。
  • 为了更好地理解我的要求,看看这个详细的代码示例dotnetfiddle.net/I4VBJs

标签: c# .net async-await synchronizationcontext


【解决方案1】:

通常一个库会记录回调是否保证在调用它的同一线程上,如果没有记录,最安全的选择是假设它没有。您的代码示例(以及我从您的 cmets 得知的与您合作的第 3 方)属于“不保证”类别。在这种情况下,您只需要检查是否需要从回调方法内部执行Invoke 并执行此操作,您可以调用Dispatcher.CheckAccess(),如果您需要在使用控件之前调用,它将返回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;
    }
}

重要提示:如果您不删除按钮单击处理程序中的.ResultDispatcher.InvokeDispatcher.InvokeAsync,所有这些方法都会导致您的程序死锁回调函数在等待.Result 返回时永远不会启动,.Result 在等待回调函数返回时永远不会返回。您必须将点击处理程序更改为 async void 并执行 await 而不是 .Result

【讨论】:

  • 因此解决方案是更改客户端代码以便在正确的上下文中进行更新。我假设这个。不清楚的地方,我需要从我的库中删除所有 ConfigureAwait(false) 吗?
  • 不,保留它们,但在库中记录回调可能会或可能不会在调用线程上执行。这会警告库的用户,他们既不应该在回调中做长时间运行的工作,也不应该假设在不检查的情况下访问 UI 是安全的。
  • 一旦你的库不再保证它,我想像你正在做的那样在你的回调中更新一个标签是不安全的,但是在你在你的按钮中进行调用之后更新它是安全的单击事件处理程序(因为您只是删除了 ConfigureAwait 那里)。
  • @neleus 另一个选项是将SynchronizationContext.Current 的值保存为MethodFromMyLibrary1 的第一行,然后如果上下文不为空,则使用SynchronizationContext.Send 进行回调并捕获结果,然后发送完成后继续使用您的代码。
  • @neleus 库开发人员通常会在库专门用于处理 UI 时执行此操作,但是我同意,大多数库不应该并且应该依赖客户端来执行调用或使用类似IProgress&lt;T&gt; 如果您想报告操作的进度(.NET 中包含的实现 Progress&lt;T&gt; 将捕获上下文并为您调用)。
【解决方案2】:

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

随着异步 GUI 应用程序变得越来越大,您可能会发现很多 一小部分异步方法都使用 GUI 线程作为它们的 语境。这可能会导致反应迟缓,因为响应能力会受到影响 “成千上万的剪纸”。

要缓解这种情况,请尽可能等待 ConfigureAwait 的结果。

通过使用 ConfigureAwait,您可以启用少量并行性: 一些异步代码可以与 GUI 线程并行运行 不断纠缠于它的一些工作要做。

现在在这里阅读:

当你在等待之后有代码时,你不应该使用 ConfigureAwait 在需要上下文的方法中。对于 GUI 应用程序,这包括任何 操作 GUI 元素、编写数据绑定属性或 取决于特定于 GUI 的类型,例如 Dispatcher/CoreDispatcher。

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

var c = new ClassFromMyLibrary1();

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

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

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

【讨论】:

  • 您可能要提到您不需要在任何地方删除.ConfigureAwait(false) 来解决问题,唯一需要删除它的地方是await GetValueByKey(key).ConfigureAwait(false);,它会起作用,因为您只需要上下文直到您调用await actionToProcessNewValue(newValue).ConfigureAwait(false);.ConfigureAwait(false) 可以保留在那里和所有其他地方。
  • @ScottChamberlain 如果该类库方法调用另一个,该方法调用另一个等等,它们自己不需要捕获上下文。但是,如果在该类库中的某个时刻,其中一个方法执行了一个回调函数,该函数试图更新一个Label,就像他正在做的那样?我知道在按钮事件处理程序中没有ConfigureAwait 的第一次调用后的GUI 操作有效,但我不确定该回调。它还会起作用吗?如果你愿意,你可以改进我的答案,我很高兴。
  • 对,问题的根源在于库开发人员不知道某些异步调用是否需要上下文。两个调用 await GetValueByKey(key)await actionToProcessNewValue(newValue) 可能需要也可能不需要上下文。
  • 如果我在一个方法中有很多异步调用,其中每个 方法调用另一个,然后调用另一个,依此类推,那么其中一个代码路径可能会导致回调GUI 上下文。通常不可能考虑所有可能的代码路径来决定ConfigureAwait(false)
  • @neleus 库开发人员可以在没有收到回调时立即使用ConfigureAwait,因为他们确信他们不需要上下文。由于在这种情况下他们无法确定,所以不使用ConfigureAwait 会更安全,我认为这里对性能的影响应该很小。如果由于您调用回调数千次或由于硬件不足而影响很大,那么由您决定何时更新Label
【解决方案3】:

在我看来,您应该重新设计您的库 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));
    }

【讨论】:

  • 如果ActionToProcessNewValue 在某个接口中声明并且ClassFromMyLibrary1 在构造函数中接受它怎么办?无论如何,代码可能要复杂得多,回调可能会导致注入类的长链(例如使用 DI)。这就是为什么它可能无法重新设计。
  • 顺便说一句,我已经更正了 Button1_OnClick 方法并使其同步,因为这是一个要求。
猜你喜欢
  • 2016-02-22
  • 1970-01-01
  • 2022-11-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-02-14
  • 1970-01-01
相关资源
最近更新 更多