Windows Presentation Foundation (WPF) 旨在将开发人员从线程处理困难中解脱出来。 因此,大多数 WPF 开发人员不会编写使用多个线程的界面。 由于多线程程序既复杂又难以调试,因此当存在单线程解决方案时,应避免使用多线程程序。
但是,无论构建得多好,没有任何 UI 框架能为每种问题都提供单线程解决方案。 WPF 虽然在这方面有近乎完美的表现,但某些情况下,仍需要使用多线程来改进用户界面 (UI) 响应能力或应用程序性能。 基于上文所述的背景材料,本文对上述情况进行探讨,然后通过对一些低级别的细节进行讨论作出总结。
备注
本主题介绍使用 InvokeAsync 方法进行异步调用的线程处理。 InvokeAsync 方法采用 Action 或 Func 作为参数,并返回具有 Task 属性的 DispatcherOperation 或 DispatcherOperation。 可以将 await 关键字与 DispatcherOperation 或相关联的 Task 配合使用。 如果需要同步等待 DispatcherOperation 或 DispatcherOperation 返回的 Task,请调用 DispatcherOperationWait 扩展方法。 调用 Task.Wait 将导致死锁。 有关使用 Task 执行异步操作的详细信息,请参阅基于任务的异步编程。
若要进行同步调用,请使用 Invoke 方法,该方法还具有采用委托、Action 或 Func 参数的重载。
通常,WPF 应用程序从两个线程开始:一个用于处理渲染,另一个用于管理 UI。 当 UI 线程接收输入、处理事件、绘制屏幕和运行应用程序代码时,呈现线程通过隐藏方式在后台高效运行。 大多数应用程序使用单个 UI 线程,不过在某些情况下,最好使用多个线程。 我们将稍后通过示例对此进行讨论。
UI 线程在称为 Dispatcher 的对象内对工作项进行排队。 Dispatcher 基于优先级选择工作项,并运行每一个工作项直到完成。 每个 UI 线程必须具有至少一个 Dispatcher,且每个 Dispatcher 都可精确地在一个线程中执行工作项。
若要生成响应迅速、用户友好的应用程序,诀窍在于通过保持工作项小型化来最大化 Dispatcher 吞吐量。 这样一来,工作项就不会停滞在 Dispatcher 队列中,因等待处理而过时。 输入和响应间任何可察觉的延迟都会让用户不满。
那么 WPF 应用程序应该如何处理大型操作呢? 如果代码涉及大型计算,或需要查询某些远程服务器上的数据库,应该怎么办? 通常情况下,解决方法是在单独的线程中处理大型操作,让 UI 线程自由地倾向于 Dispatcher 队列中的项。 大型操作完成后,它可以将其结果报告回 UI 线程以进行显示。
传统而言,Windows 允许 UI 元素仅由创造它们的线程访问。 这意味着,负责长时间运行任务的后台线程无法在任务完成时更新文本框。 Windows 这么做的目的是确保 UI 组件的完整性。 如果在绘制过程中后台线程更新了列表框的内容,则此列表框看起来可能会很奇怪。
WPF 具有内置互相排斥机制,此机制能强制执行这种协调。 WPF 中的大多数类都派生自 DispatcherObject。 构造时,DispatcherObject 会存储对 Dispatcher(它链接到当前正在运行的线程)的引用。 实际上,DispatcherObject 与创建它的线程相关联。 在程序执行期间,DispatcherObject 可以调用它的公共 VerifyAccess 方法。 VerifyAccess 检查与当前线程相关联的 Dispatcher,并将其与构造期间存储的 Dispatcher 引用相比较。 如果它们不匹配,VerifyAccess 会引发异常。 系统会在属于 DispatcherObject 的每个方法的开头调用 VerifyAccess。
如果可以修改 UI 的线程只有一个,后台线程将如何与用户进行交互? 后台线程可请求 UI 线程代表自己来执行操作。 它通过向 UI 线程的 Dispatcher 注册工作项来实现此目的。 Dispatcher 类提供了用于注册工作项的方法:Dispatcher.InvokeAsync、Dispatcher.BeginInvoke 和 Dispatcher.Invoke。 这些方法都计划一个用于执行的委托。 Invoke 是一个同步调用,也就是说,在 UI 线程真正执行完委托之前,它不会返回。 InvokeAsync 和 BeginInvoke 是异步的,并立即返回。
Dispatcher 按优先级对其队列中的元素排序。 向 Dispatcher 队列添加元素时,可以指定十个级别。 这些优先级均在 DispatcherPriority 枚举中维护。
在等待由响应用户交互而生成的事件时,大多数图形用户界面 (GUI) 在大多数时间处于空闲状态。 通过精心编程,可建设性地使用这些空闲时间,且不会影响 UI 的响应能力。 WPF 线程模型不允许输入中断 UI 线程中发生的操作。 这意味着,必须确保定期返回 Dispatcher,以便在过时之前处理挂起的输入事件。
演示本部分概念的适用于 C# 或 Visual Basic 的示例应用可从 GitHub 下载。
请考虑以下示例:
这个简单的应用程序从 3 开始向上计数以搜索质数。 用户单击“开始”按钮时,开始执行搜索。 当程序查找到一个质数时,它将根据其发现内容更新用户界面。 用户可随时停止搜索。
尽管十分简单,但对质数的搜索可以永远持续下去,这会带来一些问题。 如果在按钮的单击事件处理程序中处理整个搜索,UI 线程将永远没有机会处理其他事件。 UI 将无法响应输入,也无法处理消息。 它将永远不会重绘,也永远不会响应按钮单击。
可以在单独的线程中搜索质数,但这样的话,我们需要处理一些同步问题。 通过单线程方法,可以直接更新列出所找到的最大质数的标签。
如果将计算任务分解为可管理的多个区块,则可以定期返回 Dispatcher,并处理事件。 WPF 就有机会重绘和处理输入。
划分计算和事件处理之间的处理时间的最佳方式是从 Dispatcher 管理计算。 通过使用 InvokeAsync 方法,可以在从中绘制 UI 事件的同一队列中计划质数检查。 在我们的示例中,一次仅计划一个质数检查。 完成质数检查后,立即计划下一个检查。 仅当处理挂起的 UI 事件后,此检查才会继续。
Microsoft Word 通过此机制完成拼写检查。 拼写检查是在后台利用 UI 线程的空闲时间完成的。 我们来看一看代码。
下列示例显示了创建用户界面的 XAML。
x:Class="SDKSamples.PrimeNumber"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Prime Numbers" Width="360" Height="100">
"Horizontal" VerticalAlignment="Center" Margin="20" >
using System;
using System.Windows;
using System.Windows.Threading;
namespace SDKSamples
{
public partial class PrimeNumber : Window
{
// Current number to check
private long _num = 3;
private bool _runCalculation = false;
public PrimeNumber() =>
InitializeComponent();
private void StartStopButton_Click(object sender, RoutedEventArgs e)
{
_runCalculation = !_runCalculation;
if (_runCalculation)
{
StartStopButton.Content = "Stop";
StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
}
else
StartStopButton.Content = "Resume";
}
public void CheckNextNumber()
{
// Reset flag.
_isPrime = true;
for (long i = 3; i <= Math.Sqrt(_num); i++)
{
if (_num % i == 0)
{
// Set not a prime flag to true.
_isPrime = false;
break;
}
}
// If a prime number, update the UI text
if (_isPrime)
bigPrime.Text = _num.ToString();
_num += 2;
// Requeue this method on the dispatcher
if (_runCalculation)
StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
}
private bool _isPrime = false;
}
}
除更新 Button 上的文本外,StartStopButton_Click 处理程序还负责通过向 Dispatcher 队列添加委托,计划首个质数检查。 在此事件处理程序完成其工作后一段时间,Dispatcher 将选择用于执行的委托。
如前文所述,InvokeAsync 是用于计划委托执行的 Dispatcher 成员。 在这种情况下,选择 SystemIdle 优先级。 仅当没有要处理的重要事件时,Dispatcher 才会执行此委托。 UI 响应能力比数字检查更重要。 我们还传递了一个表示数字检查例程的新委托。
public void CheckNextNumber()
{
// Reset flag.
_isPrime = true;
for (long i = 3; i <= Math.Sqrt(_num); i++)
{
if (_num % i == 0)
{
// Set not a prime flag to true.
_isPrime = false;
break;
}
}
// If a prime number, update the UI text
if (_isPrime)
bigPrime.Text = _num.ToString();
_num += 2;
// Requeue this method on the dispatcher
if (_runCalculation)
StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
}
private bool _isPrime = false;
此方法检查下一个奇数是否是质数。 如果是质数,此方法将直接更新 bigPrimeTextBlock,以反映此发现。 可以如此操作的原因是,该计算发生在用于创建控件的相同线程中。 如果选择使用单独的线程来进行计算,将必须使用更复杂的同步机制,并在 UI 线程中执行更新。 我们将在下一步中演示这种情况。
某些 WPF 应用程序要求多个顶层窗口。 通过单个线程/Dispatcher 组合来管理多个窗口是完全可以接受的,但有时多线程可以做得更好。 尤其当这些窗口中的某一个将有可能要独占线程时,更是如此。
Windows 资源管理器以这种方式工作。 每个新资源管理器窗口都属于原始进程,但它是在独立线程的控件下创建的。 当资源管理器变得非响应时(例如在查找网络资源时),其他资源管理器窗口将继续响应且可用。
可以使用以下示例演示此概念。
此示例包含一个带以下内容的窗口:旋转的 ‼️ 字形、一个“暂停”按钮和另外两个用于在当前线程下或新线程中创建新窗口的按钮。 ‼️ 字形不断旋转,直到按下“暂停”按钮,该按钮会将线程暂停五秒。 在窗口的底部,将显示线程标识符。
当按下“暂停”按钮时,同一线程下的所有窗口都变得无响应。 不同线程下的任何窗口将继续正常工作。
以下示例是窗口的 XAML:
x:Class="SDKSamples.MultiWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Thread Hosted Window" Width="360" Height="180" SizeToContent="Height" ResizeMode="NoResize" Loaded="Window_Loaded">
"*" />
"Auto" />
"Auto" />
"Right" Margin="30,0" Text="‼️" FontSize="50" FontWeight="ExtraBold"
Foreground="Magenta" RenderTransformOrigin="0.5,0.5" Name="RotatedTextBlock">
"0" />
"Loaded">
"RotatedTextBlock"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
From="0" To="360" Duration="0:0:5" RepeatBehavior="Forever" />
"Horizontal" VerticalAlignment="Center" Margin="20" >
"1" Margin="10">
"2" VerticalAlignment="Bottom">
"Thread ID" Name="ThreadStatusItem" />
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace SDKSamples
{
public partial class MultiWindow : Window
{
public MultiWindow() =>
InitializeComponent();
private void Window_Loaded(object sender, RoutedEventArgs e) =>
ThreadStatusItem.Content = $"Thread ID: {Thread.CurrentThread.ManagedThreadId}";
private void PauseButton_Click(object sender, RoutedEventArgs e) =>
Task.Delay(TimeSpan.FromSeconds(5)).Wait();
private void SameThreadWindow_Click(object sender, RoutedEventArgs e) =>
new MultiWindow().Show();
private void NewThreadWindow_Click(object sender, RoutedEventArgs e)
{
Thread newWindowThread = new Thread(ThreadStartingPoint);
newWindowThread.SetApartmentState(ApartmentState.STA);
newWindowThread.IsBackground = true;
newWindowThread.Start();
}
private void ThreadStartingPoint()
{
new MultiWindow().Show();
System.Windows.Threading.Dispatcher.Run();
}
}
}
以下是要注意的一些详细信息:
private void PauseButton_Click(object sender, RoutedEventArgs e) =>
Task.Delay(TimeSpan.FromSeconds(5)).Wait();
private void SameThreadWindow_Click(object sender, RoutedEventArgs e) =>
new MultiWindow().Show();
private void NewThreadWindow_Click(object sender, RoutedEventArgs e)
{
Thread newWindowThread = new Thread(ThreadStartingPoint);
newWindowThread.SetApartmentState(ApartmentState.STA);
newWindowThread.IsBackground = true;
newWindowThread.Start();
}
private void ThreadStartingPoint()
{
new MultiWindow().Show();
System.Windows.Threading.Dispatcher.Run();
}
在图形应用程序中处理阻塞操作可能很困难。 我们不希望从事件处理程序调用阻塞方法,因为应用程序已经冻结。 前面的示例在其自己的线程中创建了新窗口,让每个窗口彼此独立运行。 虽然我们可以使用 System.Windows.Threading.Dispatcher 创建一个新线程,但工作完成后很难将新线程与主 UI 线程同步。 由于新线程无法直接修改 UI,因此我们必须使用 Dispatcher.InvokeAsync、Dispatcher.BeginInvoke 或 Dispatcher.Invoke 将委托插入到 UI 线程的 Dispatcher 中。 最终,将通过可修改 UI 元素的权限来执行这些委托。
有一种更简单的方法可以在新线程上运行代码并同步结果,即基于任务的异步模式 (TAP)。 它基于 System.Threading.Tasks 命名空间中的 Task 和 Task 类型,这些类型用于表示异步操作。 TAP 使用单个方法表示异步操作的开始和完成。 此模式有一些好处:
在本例中,我们模拟了一个检索天气预报的远程过程调用。 单击该按钮时,UI 将更新为指示正在提取数据,同时开始模拟一个提取天气预报的任务。 启动任务后,按钮事件处理程序代码将暂停,直到任务完成。 任务完成后,事件处理程序代码将继续运行。 代码暂停,不会阻止 UI 线程的其余部分。 WPF 的同步上下文处理暂停代码,从而允许 WPF 继续运行。
演示本部分概念的适用于 C# 或 Visual Basic 的示例应用可从 GitHub 下载。 此示例的 XAML 非常大,本文未提供。 使用前面的 GitHub 链接浏览 XAML。 XAML 使用单个按钮提取天气。
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Threading.Tasks;
namespace SDKSamples
{
public partial class Weather : Window
{
public Weather() =>
InitializeComponent();
private async void FetchButton_Click(object sender, RoutedEventArgs e)
{
// Change the status image and start the rotation animation.
fetchButton.IsEnabled = false;
fetchButton.Content = "Contacting Server";
weatherText.Text = "";
((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);
// Asynchronously fetch the weather forecast on a different thread and pause this code.
string weather = await Task.Run(FetchWeatherFromServerAsync);
// After async data returns, process it...
// Set the weather image
if (weather == "sunny")
weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];
else if (weather == "rainy")
weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];
//Stop clock animation
((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
//Update UI text
fetchButton.IsEnabled = true;
fetchButton.Content = "Fetch Forecast";
weatherText.Text = weather;
}
private async Task<string> FetchWeatherFromServerAsync()
{
// Simulate the delay from network access
await Task.Delay(TimeSpan.FromSeconds(4));
// Tried and true method for weather forecasting - random numbers
Random rand = new Random();
if (rand.Next(2) == 0)
return "rainy";
else
return "sunny";
}
private void HideClockFaceStoryboard_Completed(object sender, EventArgs args) =>
((Storyboard)Resources["ShowWeatherImageStoryboard"]).Begin(ClockImage);
private void HideWeatherImageStoryboard_Completed(object sender, EventArgs args) =>
((Storyboard)Resources["ShowClockFaceStoryboard"]).Begin(ClockImage, true);
}
}
以下是一些需要注意的详细信息。
按钮事件处理程序
private async void FetchButton_Click(object sender, RoutedEventArgs e)
{
// Change the status image and start the rotation animation.
fetchButton.IsEnabled = false;
fetchButton.Content = "Contacting Server";
weatherText.Text = "";
((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);
// Asynchronously fetch the weather forecast on a different thread and pause this code.
string weather = await Task.Run(FetchWeatherFromServerAsync);
// After async data returns, process it...
// Set the weather image
if (weather == "sunny")
weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];
else if (weather == "rainy")
weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];
//Stop clock animation
((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
//Update UI text
fetchButton.IsEnabled = true;
fetchButton.Content = "Fetch Forecast";
weatherText.Text = weather;
}
请注意,事件处理程序已声明为 async(或 Visual Basic 的 Async)。 “异步”方法允许在调用等待的方法(例如 FetchWeatherFromServerAsync)时暂停代码。 这是由 await(或 Visual Basic 的 Await)关键字指定的。 在 FetchWeatherFromServerAsync 完成之前,按钮的处理程序代码将暂停,并将控件返回到调用方。 这类似于同步方法,不同之处在于,同步方法等待方法中的每个操作完成,之后控件将返回到调用方。
等待的方法利用当前方法的线程上下文,对于按钮处理程序为 UI 线程。 这意味着调用 await FetchWeatherFromServerAsync();(或 Visual Basic 的 Await FetchWeatherFromServerAsync())会导致 FetchWeatherFromServerAsync 中的代码在 UI 线程上运行,但不在有时间运行该代码的调度程序上执行,与具有长时间运行的计算的单线程应用示例的操作方式类似。 但是,请注意使用了 await Task.Run。 这会在线程池中为指定任务创建一个新线程(而不是当前线程)。 因此 FetchWeatherFromServerAsync 在自己的线程上运行。
private async Task<string> FetchWeatherFromServerAsync()
{
// Simulate the delay from network access
await Task.Delay(TimeSpan.FromSeconds(4));
// Tried and true method for weather forecasting - random numbers
Random rand = new Random();
if (rand.Next(2) == 0)
return "rainy";
else
return "sunny";
}
为简便起见,本例中没有任何网络代码。 通过使新线程进入休眠状态四秒钟,模拟网络访问的延迟。 此时,原始 UI 线程仍在运行并响应 UI 事件,而按钮的事件处理程序一直处于暂停状态,直到新线程完成。 为了演示这一点,我们让动画继续运行,你可以调整窗口大小。 如果 UI 线程已暂停或延迟,则不会显示动画,并且你无法与窗口交互。
Task.Delay 完成后,我们已随机选择天气预报,天气状态将返回到调用方。
private async void FetchButton_Click(object sender, RoutedEventArgs e)
{
// Change the status image and start the rotation animation.
fetchButton.IsEnabled = false;
fetchButton.Content = "Contacting Server";
weatherText.Text = "";
((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);
// Asynchronously fetch the weather forecast on a different thread and pause this code.
string weather = await Task.Run(FetchWeatherFromServerAsync);
// After async data returns, process it...
// Set the weather image
if (weather == "sunny")
weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];
else if (weather == "rainy")
weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];
//Stop clock animation
((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
//Update UI text
fetchButton.IsEnabled = true;
fetchButton.Content = "Fetch Forecast";
weatherText.Text = weather;
}
当任务完成并且 UI 线程有时间时,按钮的事件处理程序 Task.Run 的调用方将继续。 该方法的其余部分停止时钟动画,并选择一个图像来描述天气。 它显示此图像并启用“提取预测”按钮。
以下部分介绍了多线程处理时可能会遇到的一些详细信息和疑难点。
有时无法完全锁定 UI 线程。 让我们考虑一下 MessageBox 类的 Show 方法。 在用户单击“确定”按钮之前,Show 不会返回。 但是,它却会创建一个窗口,该窗口为了获得交互性而必须具有消息循环。 在等待用户单击“确定”时,原始应用程序窗口将不会响应用户的输入。 但是,它将继续处理绘制消息。 当被覆盖和被显示时,原始窗口将重绘其本身。
一些线程必须负责消息框窗口。 WPF 可以为消息框窗口创建新线程,但此线程无法在原始窗口中绘制禁用的元素(请回忆之前所讨论的互相排斥)。 WPF 使用嵌套消息处理系统。 Dispatcher 类包括一个名为 PushFrame 的特殊方法,它存储应用程序的当前执行点,然后启动一个新的消息循环。 当嵌套消息循环结束后,将在原始 PushFrame 调用之后继续执行。
在此情况下,PushFrame 将在调用 MessageBox.Show 时维护程序上下文,并且它将启动一个新的消息循环,用于重绘后台窗口,并处理对消息框窗口的输入。 当用户单击“确定”并清除弹出窗口时,嵌套循环将退出,并在调用 Show 后继续控制。
引发事件时,WPF 中的路由事件系统会通知整个树。
<Canvas MouseLeftButtonDown="handler1"
Width="100"
Height="100"
>
<Ellipse Width="50"
Height="50"
Fill="Blue"
Canvas.Left="30"
Canvas.Top="50"
MouseLeftButtonDown="handler2"
/>
</Canvas>
在椭圆形上按下鼠标左键时,将执行 handler2。 handler2 完成后,事件将传递到 Canvas 对象,后者使用 handler1 对其进行处理。 仅当 handler2 没有显式标记事件对象为已处理时,才会发生这种情况。
handler2 可能会花费大量时间来处理此事件。 handler2 可能使用 PushFrame 来启动嵌套消息循环,并在数小时内不会返回任何内容。 如果在此消息循环完成时,handler2 尚未将事件标记为已处理,该事件将沿树向上传递(即使它很旧)。
公共语言运行时 (CLR) 的锁定机制与人们所设想的完全不同;可能有人以为在请求锁定时,线程将完全停止操作。 实际上,该线程将继续接收和处理高优先级的消息。 这样有助于防止死锁,并使接口最低限度地响应,但这样做有可能引入细微 bug。 绝大多数时间里,你无需知晓有关这点的任何情况,但在极少数情况下(通常涉及 Win32 窗口消息或 COM STA 组件),可能需要知道这一点。
大部分接口在生成过程中并未考虑线程安全问题,这是因为开发人员在开发过程中假定 UI 绝不会由一个以上的线程访问。 在此情况下,该单个线程可能在意外情况下更改环境,造成不良影响,这些影响应由 DispatcherObject 互相排斥机制来解决。 请看下面的伪代码:
大多数情况下这都没有问题,但在某些时候 WPF 中的异常重入确实会造成严重问题。 因此在某些关键时刻,WPF 调用 DisableProcessing,这会更改该线程的锁定指令,以使用 WPF 无重入锁定,而非常规 CLR 锁定。
那么,为何 CLR 团队选择这种行为? 它与 COM STA 对象和完成线程有关。 在对一个对象进行垃圾回收时,其 Finalize 方法运行在专用终结器线程之上,而非 UI 线程上。 这其中就存在问题,因为在 UI 线程上创建的 COM STA 对象只能在 UI 线程上释放。 CLR 相当于 BeginInvoke(在此例中使用 Win32 的 SendMessage)。 但如果 UI 线程正忙,终结器线程被停止,COM STA 对象无法被释放,这将造成严重的内存泄漏。 因此,CLR 团队通过严格的调用,使锁定以这种方式工作。
WPF 的任务是在不重新引入内存泄漏的情况下,避免异常的重入,因此我们不阻止各个位置的重入。