ConfigureAwait(false)导致错误而非死锁的情况分析

6
假设我编写了一个依赖于异步方法的库:
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, Object value)中 在System.Windows.Controls.ContentControl.set_Content(Object value)中 在C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 56的MainWindow.ActionToProcessNewValue(String s)中 在C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 77的MyLibrary1.ClassFromMyLibrary1.d__0.MoveNext()中 --- 在上一个位置引发异常的堆栈跟踪结束 --- 在System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)中 在System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)中 在System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()中 在C:\dev\tests\4\WpfApplication1\WpfApplication1\MainWindow.xaml.cs:line 39的MainWindow.d__1.MoveNext()中

显然,我的库中的等待者丢弃了当前的WPF上下文,因此出现了错误。
另一方面,如果我在库中的每个地方都删除ConfigureAwait(false),显然会导致死锁。
这里有一个更详细的代码示例,解释了我必须处理的一些限制。
那么我该如何解决这个问题?在这里最好的方法是什么?我是否仍需要遵循关于ConfigureAwait的最佳实践?
PS,在真实场景中,我有许多类和方法,因此我的库中有大量这样的异步调用。找出某个特定的异步调用是否需要上下文以进行修复几乎是不可能的(请参见对@Alisson响应的评论)。虽然我不关心性能,但至少在这一点上,我正在寻找一些通用的方法来解决这个问题。

1
你不需要从所有地方删除 ConfigureAwait(false),唯一需要删除它的地方是 await GetValueByKey(key).ConfigureAwait(false);,这样就可以正常工作了。详细解释请参见 Allision 的回答 - Scott Chamberlain
1
你的要求是不可能实现的。你希望你的UI线程一直闲置,禁止它在操作完成之前执行任何操作,同时又要求该操作必须在UI线程上执行一些代码才能完成。你的要求会导致死锁。这两个要求中必须有一个被取消才能解决问题。 - Servy
你说得对。我简化了客户端代码以演示同步调用。在实际代码中,我无法更改库方法的调用方式,因为它是从同步方法调用的,而该同步方法又被其他同步方法调用,依此类推。最终,这个链条来自GUI事件处理程序。 - neleus
我唯一能做的就是 Task.Run(async () => c.MethodFromMyLibrary1) 并且不等待完成。但这会引起其他严重问题,如错误处理和返回结果。嗯,这变得非常复杂。 - neleus
为了更好地理解我的要求,请查看此详细的代码示例 https://dotnetfiddle.net/I4VBJs - neleus
显示剩余3条评论
3个回答

4
实际上,在你的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)可以解决你的问题。此外,你可以使你的按钮单击处理程序异步,并等待调用MyLibrary1类的方法。

2
@ScottChamberlain 如果那个类库方法调用另一个方法,然后再调用另一个方法,以此类推,它们本身不需要捕获上下文。但是,如果在类库的某个地方,其中一个方法执行回调函数,并尝试更新“Label”,就像他正在做的那样,会怎么样呢?我知道在按钮事件处理程序中没有使用ConfigureAwait的第一次调用后GUI操作可以正常工作,但是我不确定那个回调是否仍然有效。如果您愿意,您可以改进我的答案,我会很高兴。 - Alisson Reinaldo Silva
问题的根源在于库开发人员不知道某些异步调用是否需要上下文。await GetValueByKey(key)await actionToProcessNewValue(newValue)两个调用可能需要上下文,也可能不需要。 - neleus
1
如果在一个方法中有许多异步调用,其中每个方法都会调用另一个方法,依此类推,那么这样的代码路径中可能会导致回调到GUI上下文。通常情况下,考虑所有可能的代码路径以就ConfigureAwait(false)做出决策是不可能的。 - neleus
该库不使用任何回调函数,而是使用接口。接口实现可以更新标签。这将导致相同的问题,但这次没有回调函数。我很快会提供代码。 - neleus
@neleus,你可以在构造函数中使用DI注入一个接口,但是当你尝试使用该接口方法时,你会遇到同样的问题。我建议你暂时删除ConfigureAwait,并进行一些性能测试。 - Alisson Reinaldo Silva
显示剩余6条评论

4

通常,库会记录回调是否保证在调用它的同一线程上,如果没有记录,则最安全的选择就是假设它不会。从您的评论中可以看出,您的代码示例(以及您正在使用的第三方)属于“不保证”类别。在这种情况下,您只需要检查是否需要在回调方法内执行 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;
    }
}

重要提示:如果您不在按钮点击处理程序中删除 .Result,则所有这些方法都会导致您的程序死锁。回调中的 Dispatcher.InvokeDispatcher.InvokeAsync 将无法在等待 .Result 返回时启动,而 .Result 将永远不会返回,同时它正在等待回调返回。您必须将单击处理程序更改为 async void,并使用 await 代替 .Result


一旦您的库不再保证它,我猜在回调中更新标签就不安全了,但在您在按钮单击事件处理程序中进行的调用之后更新它是安全的(因为您只是在那里删除了 ConfigureAwait)。 - Alisson Reinaldo Silva
@neleus 另外一个选择是,将 MethodFromMyLibrary1 的第一行保存 SynchronizationContext.Current 的值,然后使用 SynchronizationContext.Send 进行回调(如果上下文不为空),并捕获结果,一旦 send 完成,您就可以继续进行代码。 - Scott Chamberlain
@Scott Chamberlain,在库代码中捕获“SynchronizationContext”不是一个好主意。理想情况下,库开发人员不应该关心同步上下文。我喜欢你建议的从客户端代码将操作发布到UI上下文的方法。 - neleus
2
@neleus 如果库是专门用于处理UI的,则库开发人员通常会这样做,但是我同意,大多数库不应该这样做,而应该依赖客户端来调用或使用类似 IProgress<T> 这样的东西,如果您想要报告操作的进度(包含在.NET中的实现 Progress<T> 将捕获上下文并为您调用)。 - Scott Chamberlain
@ScottChamberlain,我在SO上发布了一个关于.Result死锁的新问题http://stackoverflow.com/questions/39209885/sync-to-async-dispatch-how-can-i-avoid-deadlock - neleus
显示剩余3条评论

1
在我看来,你应该重新设计你的库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在其构造函数中接受了它,怎么办?无论如何代码可能会更加复杂,回调可能会导致注入类的长链(例如使用依赖注入)。这就是为什么重新设计可能不可能的原因。 - neleus
顺便说一句,我已经纠正了“Button1_OnClick”方法,并将其改为同步,因为这是一个要求。 - neleus

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