通过使用ViewModel-first(绑定)方法进行奇怪的竞争条件

时间:2016-01-19 19:17:24

标签: wpf mvvm data-binding command race-condition

我正在尝试一个简单的基于ViewModel的WPF应用程序和一些原始导航逻辑。该应用程序包含两个视图(屏幕)。一个屏幕包含一个按钮“前进”,另一个屏幕包含一个“前进”按钮。通过按下其中一个按钮,可以调用委托命令,从而使shell视图模型切换活动屏幕。屏幕1切换到屏幕2,而屏幕2切换到屏幕1。

这种方法的问题在于,它引入了竞争条件。当单击足够快时,有可能执行相应的操作(前进/后退)两次,从而导致应用程序失败。有趣的是,屏幕已经更改,但UI不会立即反映状态变化。到目前为止,我从来没有经历过这种差距,我做了这个实验只是为了证明单线程(调度)WPF应用程序是自动线程安全的。

有人对这种奇怪的行为有解释吗? WPF绑定机制是否太慢,以便可以再次按下按钮,直到UI更新自身以表示新的屏幕状态?

根据开发mvvm应用程序的建议,我不知道如何解决这个问题。没有办法同步代码,因为只有一个线程。我希望你能帮助我,因为现在依靠WPF数据绑定和模板系统我感到非常不安全。

Zip archive containing the project files

Screen 1 switches to Screen 2, whereas Screen 2 switches to Screen 1. By clicking fast enough it is possible to introduce a race condition (that is going forward/backward twice).

MainWindow.xaml:

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApplication1"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Window.Resources>
        <DataTemplate DataType="{x:Type local:Screen1}">
            <local:View1/>
        </DataTemplate>
        <DataTemplate DataType="{x:Type local:Screen2}">
            <local:View2/>
        </DataTemplate>
    </Window.Resources>
    <Window.DataContext>
        <local:ShellViewModel/>
    </Window.DataContext>
    <Grid>
        <ContentControl Content="{Binding CurrentScreen}"/>
    </Grid>
</Window>

包含“前进”和“后退”方法的 ShellViewModel

Public Class ShellViewModel
    Inherits PropertyChangedBase

    Private _currentScreen As Object
    Public Property Screens As Stack(Of Object) = New Stack(Of Object)()

    Public Sub New()
        Me.Screens.Push(New Screen1(Me))
        Me.GoForward()
    End Sub

    Property CurrentScreen As Object
        Get
            Return _currentScreen
        End Get
        Set(value)
            _currentScreen = value
            RaisePropertyChanged()
        End Set
    End Property

    Public Sub GoBack()
        Log("Going backward.")
        If (Me.Screens.Count > 2) Then
            Throw New InvalidOperationException("Race condition detected.")
        End If
        Log("Switching to Screen 1")
        Me.Screens.Pop()
        Me.CurrentScreen = Me.Screens.Peek()
    End Sub

    Public Sub GoForward()
        Log("Going forward.")
        If (Me.Screens.Count > 1) Then
            Throw New InvalidOperationException("Race condition detected.")
        End If
        Log("Switching to Screen 2.")

        Me.Screens.Push(New Screen2(Me))
        Me.CurrentScreen = Me.Screens.Peek()
    End Sub

End Class

Screen 类只包含一个用于启动操作的委托命令:

Public Class Screen1
    Inherits PropertyChangedBase

    Private _clickCommand As ICommand
    Private _shellViewModel As ShellViewModel

    Public Sub New(parent As ShellViewModel)
        _shellViewModel = parent
    End Sub


    Public ReadOnly Property ClickCommand As ICommand
        Get
            If _clickCommand Is Nothing Then
                _clickCommand = New RelayCommand(AddressOf ExecuteClick, AddressOf CanExecute)
            End If
            Return _clickCommand
        End Get
    End Property

    Private Function CanExecute(arg As Object) As Boolean
        Return True
    End Function

    Private Sub ExecuteClick(obj As Object)
        Threading.Thread.SpinWait(100000000)
        _shellViewModel.GoForward()
    End Sub

End Class

5 个答案:

答案 0 :(得分:5)

没有奇怪的竞争条件

我已经运行了您的代码。有一个主题。主要的一个。

One thread = no race condition.

为什么要证明以下内容?

  

我做了这个实验只是为了证明,那是一个单线程   (调度)WPF应用程序是自动线程安全的。

这是一个防弹事实。 一个主题=线程安全(只要您不在进程范围内共享数据,但它不再是线程安全)。

Binding和Method不支持连续调用

实际上,你的方法GoBack和GoForward不支持连续调用。应该一个接一个地调用它们。

此处的线程安全并不意味着您的方法不能连续调用两次。如果进程中有任何任务队列,则可以调用两次方法。

您可能想要证明的内容可能如下: <击>

<击>
<击>   

点击被捕获并在线处理,两者之间没有任何任务排队   点击,属性改变事件提出,调度员   调用,绑定/显示刷新。   

显然错了!

当您调用Dispatcher.BeginInvoke或Invoke时,它会在内部使用任务队列。并且没有什么能阻止您排除来自两次类似点击两次相同任务

坦率地说,我无法重现你的错误。我认为它是捕获点击的相同线程,点击将其发送到您的代码然后在屏幕上显示。但是,由于点击事件的任务,显示刷新位于同一队列中,理论上可以在屏幕更改之前将两次点击排队。但是:

  • 我无法快速点击以击败我的CPU。
  • 我不认为需要SpinWait。
  • 我的配置可能会遗漏。

为什么不让您的方法支持连续调用?

GoBack和GoBackward可以检查状态,如果当前状态无效,则不执行任何操作。

您可以使用:

<强> 1。两个屏幕都从头开始实例化。

<强> 2。一个bool表示当前状态(前进或后退)。

第3。 enum代码更清晰。

<强> 4。状态机.. 不!我开个玩笑。

注意:为什么使用堆栈来推送和弹出屏幕(顺便说一句)?和... 如果你添加另一个线程: 堆栈弹出/推送不是线程安全的。 而是使用 ConcurrentStack<T>

答案 1 :(得分:2)

模拟

即使UI线程被冻结或做某事,也会收集另一个输入。试试这个(抱歉C#,但你明白了):

private void ButtonClick(object sender, EventArgs args)
    {
        Debug.WriteLine("start");
        Thread.Sleep(6000);
        Debug.WriteLine("End");
    }

单击按钮,然后在“开始”行上放置断点,在UI线程解冻之前再次单击该按钮。你会在第一次点击后看到断裂点被击中后的6秒钟。

解释

UI线程显然一次只能执行1个操作,但必须针对多线程进行优化 - 这意味着它会对其进行排队操作。因此,任何PropertyChanged(或任何处理程序,包括OnClick)都只是为UI线程排队操作。它不会跳出您的代码来更新您的setter中间的UI元素。如果在setter之后调用Thread.Sleep,您将看到没有看到任何更改 - 因为UI线程还没有调用更新。

为什么这很重要

在您的代码中,首先按一个屏幕,然后设置为当前,调用propertyChanged。这不会立即更改屏幕,只是将其排队等待更新。无法保证在此之前不会安排其他点击。

您还可以通过调用PropertyChanged一百万次来冻结您的UI线程,从而在更新过程中冻结它。然而,同时收集点击次数。

所以你的“安全点” - 安全的地方,现在没有其他点击可以安排 - 不是在Setter完成之后,而是在新窗口上调用Loaded Event之后。

如何修复

Fab 的答案:))

不要以为只是因为UI线程在没有任何进入时被阻止。如果你想在计算某些东西时禁用输入,你需要手动禁用输入。

可能的解决方案

  1. 设置IsEnabled,Visibility,IsHitTestVisible
  2. 一些叠加或其他
  3. 布尔参数,可以全局允许或禁止所有方法(基本上是锁定)

答案 2 :(得分:1)

我无法重现所描述的行为 - 双击导致应用首先退回&#34;然后退出&#34;前进&#34;在我身边。尽管如此,我认为在用户第二次点击它之前期望按钮消失并不是一个好的设计(特别是在设备例如有一个单独的&#34;双击&#34;按钮的情况下),我个人从不依赖它。

我认为在这种情况下继续进行的最佳方法是正确实施CanExecute方法,这样它不仅会返回true(顺便提一下,这很可能是多余的),但是而是查询_shellViewModel它是否处于允许调用ExecuteClick调用的方法的状态(如果GoForward,它应该返回true如果只有CanExecute堆栈中的一个元素)。我无法测试(因为我无法重现有问题的行为),但我很确定即使用户点击两次相同的按钮,第二次调用ExecuteClick也会在第一次调用后发生false,因此该模型将保证是最新的&#34; (结果将为GoForward,{{1}}将不会再次调用。)

答案 3 :(得分:1)

@ Pavel Kalandra

虽然简单的单击事件处理程序可能会多次排队,但即使UI线程被阻止,我也无法使用委托命令重现此行为。因此,我假设与简单的单击事件处理程序相比,WPF框架确实处理命令的调用有点不同。此外,在您的示例中,click事件已在事件处理程序执行完成之前排队。在我的情况下,情况并非如此。

为了证明这个假设,我做了一个进一步的实验:通过使用阻止UI线程几秒钟然后显示消息的命令,您可以看到在调用期间无法多次调用它。我相信WPF框架在某种程度上阻止了这种情况的发生。因此,这两种情况都不能与一种情况相比。

但我认为你的解释仍然正确。按下屏幕会导致触发PropertyChanged事件,但屏幕不会立即更新。实际上,相关联的作业被推送到调度程序队列并被调度。因此,存在短时间跨度,在此期间可以第二次调用命令。

@的的Fab

如果你强烈依赖于竞争条件的公认定义,那么我的示例应用程序中不应该有一个。但为了简单起见,我想称之为竞争条件,因为作业的安排使执行变得不确定。

然而,我打算证明的假设是错误的。我想证明它是因为我们目前面临的线程问题。我们的应用程序同时模拟多个模态方法,因此它依赖于多个线程。因为允许用户进行交互,所以没有正确同步,竞争条件很有可能发生(由于其他原因,同步它们不是一种选择)。

目前我正在制作一个没有过多使用线程的原型。我的希望是,通过执行调度员线程上的所有内容,竞争条件(或类似问题)不可能实现。至少对于ViewModel-first方法,由于WPF调度绑定更新的方式,这似乎是错误的。

我使用了一个简单的场景,很容易为潜在的竞争条件提供解决方案。但总的来说,编写Bulletproof WPF应用程序并不容易。在处理多个屏幕时,指示方向(前进/后退)的标志不够。但是命令委托可以检查它是否从活动屏幕调用。

PS:只要我完全依赖调度程序线程来执行操作,我认为不需要使用ConcurrentStack; - )

答案 4 :(得分:0)

我遇到了另一个类似的问题,证明即使应用程序是单线程的,UI调度实际上也可以引入竞争条件。 在该示例中,一段代码被称为原子代码。由于调度使用不同的优先级,因此代码可能在执行过程中被中断。

这是我在生产代码中以类似形式找到的示例。用户提到了一个自发发生的问题。那时我发现,SelectionChanged事件正在中断一段本应作为块执行的代码。

   public partial class MainWindow : Window
    {
        private bool inBetweenMethod;

        public MainWindow()
        {
            InitializeComponent();
            this.timer = new DispatcherTimer(DispatcherPriority.Loaded);
            this.timer.Interval = TimeSpan.FromMilliseconds(10);
            this.timer.Tick += Timer_Tick;
            this.timer.Start();
            this.MethodThatIsSupposedToBeAtomic();

        }


        private void Timer_Tick(object sender, EventArgs e)
        {
            if (inBetweenMethod)
            {
                throw new Exception("Method was interrupted in the middle of execution".);
            }
        }


        private void MethodThatIsSupposedToBeAtomic()
        {
            inBetweenMethod = true;
            Dispatcher.Invoke(new Action(() =>
            {
                for (int i = 0; i < 100; i++)
                {
                    Console.WriteLine("iterating");
                }
            }), DispatcherPriority.ContextIdle);


            inBetweenMethod = false;

        }
}