Home > Back-end >  Why do WPF tabs become "unresponsive" when `Dispatcher.Invoke()` is called?
Why do WPF tabs become "unresponsive" when `Dispatcher.Invoke()` is called?

Time:03-17

Unresponsive Tabs Video

As I understand it, WPF "messages" (e.g. a button click handler) are added to an internal prioritized queue. A single UI thread is then responsible for processing the queued messages.

Unfortunately my knowledge of WPF is not deep enough to understand the internal working of the framework. So my question is, given that there is only 1 thread processing messages...

  • What is the internal sequence of events (high level) that is leading to the tabs becoming unresponsive?

Observed Behavior

  1. If you click slowly, the TabControl behaves as expected.
    • To reproduce: click 1 tab every 4 seconds.
    • It appears that if you give the TabControl.SelectedIndex data binding an opportunity to complete, the control will behave as designed.
  2. If you click tabs quickly, then some of the tabs will become unresponsive.
    • To reproduce: click as many tabs as you can within 3 seconds.

Additional Reading

Sample Code

The following code can be used to reproduce the behavior, whereby, WPF tabs become permanently selected.

Paste into MainWindow.xaml:

<Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="30" />
            <RowDefinition Height="*" />
            <RowDefinition Height="30" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <WrapPanel Grid.Row="0">
            <TextBlock>
                1. Click on as many tabs as possible within 3 seconds.<LineBreak/>
                2. Wait until multiple tabs are selected.<LineBreak/>
                3. Uncheck the `Simulate Bug` checkbox.<LineBreak/>
            </TextBlock>
        </WrapPanel>
        <CheckBox Grid.Row="1" IsChecked="{Binding CanSimulateBug}" Content="Simulate Bug"/>
        <TabControl x:Name="ColorWorkspaces" Grid.Row="2" SelectedIndex="{Binding SelectedTab, Mode=TwoWay}">
            <TabItem x:Name="RedTab" Header="Red"/>
            <TabItem x:Name="OrangeTab" Header="Orange"/>
            <TabItem x:Name="YellowTab" Header="Yellow"/>
            <TabItem x:Name="GreenTab" Header="Green"/>
            <TabItem x:Name="BlueTab" Header="Blue"/>
            <TabItem x:Name="VioletTab" Header="Violet"/>
        </TabControl>
        <TextBlock Grid.Row="3" Text="{Binding Status}"/>
    </Grid>

Paste into MainWindow.xaml.cs:

public partial class MainWindow : Window, INotifyPropertyChanged
    {
        private int _selectedTab;
        private string _status;
        private bool _canSimulateBug;

        public MainWindow()
        {
            this.CanSimulateBug = true;
            this.Status = String.Empty;
            this.DataContext = this;
            InitializeComponent();
        }


        protected void RaisePropertyChanged([CallerMemberName] string propertyName = "")
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public event PropertyChangedEventHandler PropertyChanged;

        public bool CanSimulateBug
        {
            get
            {
                return _canSimulateBug;
            }
            set
            {
                _canSimulateBug = value;
                RaisePropertyChanged();
            }
        }

        public string Status
        {
            get
            {
                return _status;
            }
            set
            {
                _status = value ?? string.Empty;
                RaisePropertyChanged();
            }
        }

        public int SelectedTab
        {
            get
            {
                return _selectedTab;
            }
            set
            {
                UpdateStatus($"SelectedTab changing... Value={value}");

                if (this.CanSimulateBug)
                {
                    SimulateBug(value);
                }
                _selectedTab = value;
                UpdateStatus($"SelectedTab changed. Value={value}");
                // This missing line was added as per 
Felix's comment
                RaisePropertyChanged();
            }
        }

        private void UpdateStatus(string message)
        {
            var formattedMessage = $"[{Thread.CurrentThread.ManagedThreadId}] {DateTime.Now.ToLongTimeString()}: {message}";
            this.Status = formattedMessage;
            Debug.WriteLine(formattedMessage);
        }

        private void SimulateBug(int id)
        {
            var delay = TimeSpan.FromSeconds(3);
            UpdateStatus($"Bug simulation started... ID={id}, Delay={delay}");

            // IMPORTANT: If you comment out this following line
            // ... the application will behave as expected.
            Application.Current.Dispatcher.Invoke( // blocking call
                DispatcherPriority.Background, // tells UI thread to execute this as lowest priority job
                new Action(delegate { /* do nothing */ }));

            Thread.Sleep(delay);
            UpdateStatus($"Bug simulation complete. ID={id}");
        }
    }

CodePudding user response:

Your complete code is executing on a single thread. You can't execute concurrent operations using a single thread. What you are currently doing is to block the main thread twice (too long) by invoking two potentially long-running operations synchronously:

  1. Synchronous Dispatcher invocation using Dispatcher.Invoke:
Application.Current.Dispatcher.Invoke(() => {}, DispatcherPriority.Background);
  1. Synchronous thread sleep:
Thread.Sleep(TimeSpan.FromSeconds(3));

While executing these synchronous operations, the main thread is not free to execute the logic (which is in this case part of the Selector, which is a superclass of TabControl) that manages the selection state of the hosted items.
The main thread is blocked by waiting for the Dispatcher to return and then by sending it to sleep i.e. suspending it.
As a result the Selector is not able to unselect the previously selected TabItem.
The Selector is able to cancel the selection procedure, which involves handling the selected item and unselecting every other item (in case multi-select is not supported). Obviously, the Selector cancels the unselection/processing of the pending items.
You should be able to test this by listening to the Selector.Unselected event which you attach to the TabItem. It should not be raised. Apparently the blocking of the main thread creates a race condition for the internal item validation of the Selector.
To fix this race condition it should be sufficient to increase the DispatcherPriority of the queued dispatcher messages to at least DispatcherPriority.DataBind (above DispatcherPriority.Input):

Application.Current.Dispatcher.Invoke(() => {}, DispatcherPriority.DataBind);

This is not the recommended fix, although it fixes the race condition and therefore the issue of multiple selected tabs as the critical code can now execute in time. The real underlying problem is the blocked main thread (which actually is a blocked Dispatcher).

You never want to block the main thread. Now you understand why.
For this reason .NET introduced the TPL. Additionally, the compiler/runtime environment allows true asynchronous execution: by delegating execution to the OS, .NET can use kernel level features like interrupts. This way .NET can allow the main thread to continue e.g., to process essential UI related events like device input.

Part of the interface between OS level and framework level is the Dispatcher and the InputManager. The Dispatcher basically manages the associated thread. In an STA application this is the main thread. Now, when you block the main thread using Thread.Sleep, the Dispatcher can't continue to work on the message queue that contains handlers that are executed on the associated dispatcher thread (main thread).
The Dispatcher is now unable to execute the input events posted by the InputManager. Since the dependency property system (on which routed events and the data binding engine is based on) and usually the code of UI controls are also executed on the Dispatcher, they are also suspended.

The combination of the very low dispatcher priority DispatcherPriority.Background in conjunction with the long Thread.Sleep makes the problem even worse.


The solution is to not block the main thread:

  1. Post work to the Dispatcher asynchronously and allow the application to continue while the job is enqueued and pending by calling Dispatcher.InvokeAsync:
Application.Current.Dispatcher.InvokeAsync(() => {}, DispatcherPriority.Background);
  1. Execute blocking I/O bound operations asynchronously using async/await:
await Task.Delay(TimeSpan.FromSeconds(3));
  1. Execute blocking CPU bound operations concurrently:
Task.Run(() => {});

Your fixed code would look as followed:

private async Task SimulateNoBugAsync(int id)
{
  var delay = TimeSpan.FromSeconds(3);
  UpdateStatus($"Bug simulation started... ID={id}, Delay={delay}");

  // If you need to wait for a result or for completion in general,
  // await the Dispatcher.InvokeAsync call.
  Application.Current.Dispatcher.InvokeAsync(() => {}, DispatcherPriority.Background);
  await Task.Delay(delay);

  UpdateStatus($"Bug simulation complete. ID={id}");
}
  • Related