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
- 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.
- 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
- Two selected tabs in tabcontroller
- While the behavior is similar, this article is different because the symptom is the result of using a
Tab
MessageBox
.
- While the behavior is similar, this article is different because the symptom is the result of using a
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:
- Synchronous
Dispatcher
invocation usingDispatcher.Invoke
:
Application.Current.Dispatcher.Invoke(() => {}, DispatcherPriority.Background);
- 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:
- Post work to the
Dispatcher
asynchronously and allow the application to continue while the job is enqueued and pending by callingDispatcher.InvokeAsync
:
Application.Current.Dispatcher.InvokeAsync(() => {}, DispatcherPriority.Background);
- Execute blocking I/O bound operations asynchronously using
async
/await
:
await Task.Delay(TimeSpan.FromSeconds(3));
- 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}");
}