更新:2007 年 11 月
Windows Presentation Foundation (WPF) 旨在帮助开发人员解决线程处理的难题。这样,大多数 WPF 开发人员就不必编写使用多个线程的接口。由于多线程程序很复杂且难以调试,因此只要存在单线程解决方案,就应避免使用多个线程。
但是,无论体系结构多么完善,没有任何 UI 框架能够为每一类问题提供单线程解决方案。WPF 接近这一目标,但是在某些情况下,仍然可通过采用多个线程来提高 用户界面 (UI) 响应度或应用程序性能。在讨论了一些背景材料后,本文将探讨其中一些情况,最后从较低层次进行一些详细讨论。
本主题包括下列各节。
通常,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 将引发异常。VerifyAccess 用于在每个属于 DispatcherObject 的方法的开头调用。
如果只有一个线程可以修改 UI,那么后台线程如何与用户交互呢?后台线程可以请求 UI 线程代表它执行操作。这是通过向 UI 线程的 Dispatcher 注册工作项来完成的。Dispatcher 类提供两个注册工作项的方法:Invoke 和 BeginInvoke。这两个方法均调度一个委托来执行。Invoke 是同步调用,也就是说,直到 UI 线程实际执行完该委托它才返回。BeginInvoke 是异步的,将立即返回。
Dispatcher 按优先级对其队列中的元素进行排序。向 Dispatcher 队列中添加元素时可指定 10 个级别。这些优先级在 DispatcherPriority 枚举中维护。有关 DispatcherPriority 级别的详细信息可以在 Windows SDK 文档中找到。
具有长时间运行计算的单线程应用程序
大多数图形用户界面 (GUI) 的大部分空闲时间都是因为等待响应用户交互而生成的事件而造成的。通过仔细地编程,可以积极地利用这一空闲时间,而不影响 UI 的响应度。WPF 线程模型不允许输入中断 UI 线程中正在进行的操作。这意味着您必须定期返回到 Dispatcher 来处理挂起的输入事件,以防止它们停滞。
请看下面的示例:
这是一个简单的应用程序,从 3 开始往上数,搜索质数。当用户单击“Start”(开始)按钮时,搜索开始。当程序找到一个质数时,则会用它的搜索结果更新用户界面。用户可以随时停止搜索。
尽管此应用程序非常简单,但质数搜索可以无限地继续下去,这带来了一定的困难。如果在按钮的 click 事件处理程序中处理了整个搜索,UI 线程将永远没有机会处理其他事件。UI 将无法响应输入或处理消息。它永远不会重新绘制,也永远不会响应按钮单击。
我们可以在一个单独的线程中执行质数搜索,但之后需要处理同步问题。使用单线程方法,可以直接更新列出找到的最大质数的标签。
如果将计算任务分成多个易管理的块,就可以定期返回到 Dispatcher 来处理事件。可以给 WPF 提供一个机会来重新绘制和处理输入。
在计算与事件处理之间划分处理时间的最佳方法是从 Dispatcher 中管理计算。通过使用 BeginInvoke 方法,可以在从中提取 UI 事件的同一队列中安排质数检查。在本示例中,一次仅安排一个质数检查。完成该质数检查后,将立即安排下一次检查。此检查仅在挂起的 UI 事件经过处理后才会继续。
Microsoft Word 就是使用这一机制来完成拼写检查。拼写检查是利用 UI 线程的空闲时间在后台执行的。我们来看一看代码。
下面的示例演示创建用户界面的 XAML。
<Window x:Class="SDKSamples.Window1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Prime Numbers" Width="260" Height="75" > <StackPanel Orientation="Horizontal" VerticalAlignment="Center" > <Button Content="Start" Click="StartOrStop" Name="startStopButton" Margin="5,0,5,0" /> <TextBlock Margin="10,5,0,0">Biggest Prime Found:</TextBlock> <TextBlock Name="bigPrime" Margin="4,5,0,0">3</TextBlock> </StackPanel> </Window>
下面的示例演示代码隐藏。
using System; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; using System.Threading; namespace SDKSamples { public partial class Window1 : Window { public delegate void NextPrimeDelegate(); //Current number to check private long num = 3; private bool continueCalculating = false; public Window1() : base() { InitializeComponent(); } private void StartOrStop(object sender, EventArgs e) { if (continueCalculating) { continueCalculating = false; startStopButton.Content = "Resume"; } else { continueCalculating = true; startStopButton.Content = "Stop"; startStopButton.Dispatcher.BeginInvoke( DispatcherPriority.Normal, new NextPrimeDelegate(CheckNextNumber)); } } public void CheckNextNumber() { // Reset flag. NotAPrime = false; for (long i = 3; i <= Math.Sqrt(num); i++) { if (num % i == 0) { // Set not a prime flag to ture. NotAPrime = true; break; } } // If a prime number. if (!NotAPrime) { bigPrime.Text = num.ToString(); } num += 2; if (continueCalculating) { startStopButton.Dispatcher.BeginInvoke( System.Windows.Threading.DispatcherPriority.SystemIdle, new NextPrimeDelegate(this.CheckNextNumber)); } } private bool NotAPrime = false; } }
下面的示例演示 Button 的事件处理程序。
private void StartOrStop(object sender, EventArgs e) { if (continueCalculating) { continueCalculating = false; startStopButton.Content = "Resume"; } else { continueCalculating = true; startStopButton.Content = "Stop"; startStopButton.Dispatcher.BeginInvoke( DispatcherPriority.Normal, new NextPrimeDelegate(CheckNextNumber)); } }
除了更新 Button 上的文本外,此处理程序还负责通过向 Dispatcher 队列添加委托来调度第一次质数检查。在此事件处理程序完成其工作后的一段时间内,Dispatcher 会选择此委派来执行。
前面已提到,BeginInvoke 是用于调度委托来执行的 Dispatcher 成员。在这种情况下,选择 SystemIdle 优先级。仅当没有重要的事件要处理时,Dispatcher 才执行此委托。UI 响应度比数字检查更重要。我们还要传递一个表示数字检查例程的新委托。
public void CheckNextNumber() { // Reset flag. NotAPrime = false; for (long i = 3; i <= Math.Sqrt(num); i++) { if (num % i == 0) { // Set not a prime flag to ture. NotAPrime = true; break; } } // If a prime number. if (!NotAPrime) { bigPrime.Text = num.ToString(); } num += 2; if (continueCalculating) { startStopButton.Dispatcher.BeginInvoke( System.Windows.Threading.DispatcherPriority.SystemIdle, new NextPrimeDelegate(this.CheckNextNumber)); } } private bool NotAPrime = false;
此方法检查下一个奇数是否是质数。如果是质数,此方法将直接更新 bigPrime TextBlock 来反映搜索结果。由于计算发生在用于创建组件的同一线程中,因此可以执行此操作。如果选择对计算使用单独的线程,则必须使用一种更复杂的同步机制,并在 UI 线程中执行更新。接下来我们将演示这一情况。
有关此示例的完整源代码,请参见具有长时间运行计算的单线程应用程序示例。
用后台线程处理阻止操作
在图形应用程序中处理阻止操作很困难。我们不希望从事件处理程序中调用阻止方法,因为这样应用程序看上去好像已冻结。可以使用一个单独的线程来处理这些操作,但完成后必须与 UI 线程同步,因为不能从辅助线程直接修改 GUI。可以使用 Invoke 或 BeginInvoke 向 UI 线程的 Dispatcher 中插入委托。最终,这些委托将以修改 UI 元素的权限来执行。
在本示例中,模拟检索天气预报的远程过程调用。使用一个单独的辅助线程来执行此调用,并在完成后在 UI 线程的 Dispatcher 中调度一个更新方法。
using System; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using System.Windows.Shapes; using System.Windows.Threading; using System.Threading; namespace SDKSamples { public partial class Window1 : Window { // Delegates to be used in placking jobs onto the Dispatcher. private delegate void NoArgDelegate(); private delegate void OneArgDelegate(String arg); // Storyboards for the animations. private Storyboard showClockFaceStoryboard; private Storyboard hideClockFaceStoryboard; private Storyboard showWeatherImageStoryboard; private Storyboard hideWeatherImageStoryboard; public Window1(): base() { InitializeComponent(); } private void Window_Loaded(object sender, RoutedEventArgs e) { // Load the storyboard resources. showClockFaceStoryboard = (Storyboard)this.Resources["ShowClockFaceStoryboard"]; hideClockFaceStoryboard = (Storyboard)this.Resources["HideClockFaceStoryboard"]; showWeatherImageStoryboard = (Storyboard)this.Resources["ShowWeatherImageStoryboard"]; hideWeatherImageStoryboard = (Storyboard)this.Resources["HideWeatherImageStoryboard"]; } private void ForecastButtonHandler(object sender, RoutedEventArgs e) { // Change the status image and start the rotation animation. fetchButton.IsEnabled = false; fetchButton.Content = "Contacting Server"; weatherText.Text = ""; hideWeatherImageStoryboard.Begin(this); // Start fetching the weather forecast asynchronously. NoArgDelegate fetcher = new NoArgDelegate( this.FetchWeatherFromServer); fetcher.BeginInvoke(null, null); } private void FetchWeatherFromServer() { // Simulate the delay from network access. Thread.Sleep(4000); // Tried and true method for weather forecasting - random numbers. Random rand = new Random(); String weather; if (rand.Next(2) == 0) { weather = "rainy"; } else { weather = "sunny"; } // Schedule the update function in the UI thread. tomorrowsWeather.Dispatcher.BeginInvoke( System.Windows.Threading.DispatcherPriority.Normal, new OneArgDelegate(UpdateUserInterface), weather); } private void UpdateUserInterface(String weather) { //Set the weather image if (weather == "sunny") { weatherIndicatorImage.Source = (ImageSource)this.Resources[ "SunnyImageSource"]; } else if (weather == "rainy") { weatherIndicatorImage.Source = (ImageSource)this.Resources[ "RainingImageSource"]; } //Stop clock animation showClockFaceStoryboard.Stop(this); hideClockFaceStoryboard.Begin(this); //Update UI text fetchButton.IsEnabled = true; fetchButton.Content = "Fetch Forecast"; weatherText.Text = weather; } private void HideClockFaceStoryboard_Completed(object sender, EventArgs args) { showWeatherImageStoryboard.Begin(this); } private void HideWeatherImageStoryboard_Completed(object sender, EventArgs args) { showClockFaceStoryboard.Begin(this, true); } } }
下面给出了一些值得注意的细节。
-
创建按钮处理程序
C#private void ForecastButtonHandler(object sender, RoutedEventArgs e) { // Change the status image and start the rotation animation. fetchButton.IsEnabled = false; fetchButton.Content = "Contacting Server"; weatherText.Text = ""; hideWeatherImageStoryboard.Begin(this); // Start fetching the weather forecast asynchronously. NoArgDelegate fetcher = new NoArgDelegate( this.FetchWeatherFromServer); fetcher.BeginInvoke(null, null); }
当单击按钮时,显示时钟图并开始显示它的动画效果。禁用该按钮,在一个新线程中调用 FetchWeatherFromServer 方法,然后返回,这样 Dispatcher 就可以在我们等待收集天气预报时处理事件。
-
获取天气预报
C#private void FetchWeatherFromServer() { // Simulate the delay from network access. Thread.Sleep(4000); // Tried and true method for weather forecasting - random numbers. Random rand = new Random(); String weather; if (rand.Next(2) == 0) { weather = "rainy"; } else { weather = "sunny"; } // Schedule the update function in the UI thread. tomorrowsWeather.Dispatcher.BeginInvoke( System.Windows.Threading.DispatcherPriority.Normal, new OneArgDelegate(UpdateUserInterface), weather); }
为简单起见,此示例中实际没有任何网络代码。通过使新线程休眠四秒钟来模拟网络访问的延迟。此时,原始的 UI 线程仍然正在运行并响应事件。为了对此进行说明,我们使一个动画保持运行,并使最小化和最大化按钮也继续工作。
-
更新 UI
C#private void UpdateUserInterface(String weather) { //Set the weather image if (weather == "sunny") { weatherIndicatorImage.Source = (ImageSource)this.Resources[ "SunnyImageSource"]; } else if (weather == "rainy") { weatherIndicatorImage.Source = (ImageSource)this.Resources[ "RainingImageSource"]; } //Stop clock animation showClockFaceStoryboard.Stop(this); hideClockFaceStoryboard.Begin(this); //Update UI text fetchButton.IsEnabled = true; fetchButton.Content = "Fetch Forecast"; weatherText.Text = weather; }
当 UI 线程中的 Dispatcher 有时间时,会对 UpdateUserInterface 执行预定调用。此方法停止时钟动画并选择一个图像来描述天气。它显示此图像并还原“fetch forecast”(获取预报)按钮。
有关此示例的完整源代码,请参见通过调度程序模拟天气预报服务的示例。
多个窗口,多个线程
一些 WPF 应用程序需要多个高级别窗口。一个线程/Dispatcher 组合管理多个窗口是完全可以接受的,但有时使用多个线程可以更出色地完成工作。如果其中一个窗口有可能独占该线程,那么采用多个线程就更有必要。
Windows 资源管理器就是以这种方式工作的。每个新的资源管理器窗口都属于原始进程,但是在独立线程的控制下创建的。
使用 WPF Frame 控件可以显示网页。我们可以轻松地创建一个简单的 Internet Explorer 替代控件。从一个重要的功能开始,即打开新的资源管理器窗口。当用户单击“new window”(新建窗口)按钮时,便在一个单独的线程中启动了窗口的一个副本。这样,一个窗口中的长时间运行或阻止操作就不会锁定所有其他窗口。
事实上,Web 浏览器模型有它自己的复杂线程模型。我们选择它是因为大多数读者对它都很熟悉。
下面的示例演示代码。
<Window x:Class="SDKSamples.Window1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MultiBrowse" Height="600" Width="800" Loaded="OnLoaded" > <StackPanel Name="Stack" Orientation="Vertical"> <StackPanel Orientation="Horizontal"> <Button Content="New Window" Click="NewWindowHandler" /> <TextBox Name="newLocation" Width="500" /> <Button Content="GO!" Click="Browse" /> </StackPanel> <Frame Name="placeHolder" Width="800" Height="550"></Frame> </StackPanel> </Window>
using System; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Threading; using System.Threading; namespace SDKSamples { public partial class Window1 : Window { public Window1() : base() { InitializeComponent(); } private void OnLoaded(object sender, RoutedEventArgs e) { placeHolder.Source = new Uri("http://www.msn.com"); } private void Browse(object sender, RoutedEventArgs e) { placeHolder.Source = new Uri(newLocation.Text); } private void NewWindowHandler(object sender, RoutedEventArgs e) { Thread newWindowThread = new Thread(new ThreadStart(ThreadStartingPoint)); newWindowThread.SetApartmentState(ApartmentState.STA); newWindowThread.IsBackground = true; newWindowThread.Start(); } private void ThreadStartingPoint() { Window1 tempWindow = new Window1(); tempWindow.Show(); System.Windows.Threading.Dispatcher.Run(); } } }
在本语境中,这些代码中的以下线程片段最有意义:
private void NewWindowHandler(object sender, RoutedEventArgs e) { Thread newWindowThread = new Thread(new ThreadStart(ThreadStartingPoint)); newWindowThread.SetApartmentState(ApartmentState.STA); newWindowThread.IsBackground = true; newWindowThread.Start(); }
当单击“new window”(新建窗口)按钮时,调用此方法。它创建一个新线程并以异步方式启动它。
private void ThreadStartingPoint() { Window1 tempWindow = new Window1(); tempWindow.Show(); System.Windows.Threading.Dispatcher.Run(); }
此方法是新线程的起点。我们在此线程的控制下创建一个新窗口。WPF 自动创建一个新的 Dispatcher 来管理新线程。要使该窗口工作,只需启动 Dispatcher。
有关此示例的完整源代码,请参见多线程 Web 浏览器示例。
使用线程编写组件
《Microsoft .NET Framework 开发人员指南》介绍了组件向其客户端公开异步行为的一种模式(请参见基于事件的异步模式概述)。例如,假定我们希望将 FetchWeatherFromServer 方法打包到一个可重用的非图形组件中。如果采用标准的 Microsoft .NET Framework 模式,那么代码应与下面的内容类似。
public class WeatherComponent : Component { //gets weather: Synchronous public string GetWeather() { string weather = ""; //predict the weather return weather; } //get weather: Asynchronous public void GetWeatherAsync() { //get the weather } public event GetWeatherCompletedEventHandler GetWeatherCompleted; } public class GetWeatherCompletedEventArgs : AsyncCompletedEventArgs { public GetWeatherCompletedEventArgs(Exception error, bool canceled, object userState, string weather) : base(error, canceled, userState) { _weather = weather; } public string Weather { get { return _weather; } } private string _weather; } public delegate void GetWeatherCompletedEventHandler(object sender, GetWeatherCompletedEventArgs e);
GetWeatherAsync 将使用前面介绍的一种技术(如创建后台线程)来异步执行工作,同时不阻止调用线程。
此模式的最重要部分之一是最初在调用方法名称 Async 方法的线程上调用方法名称 Completed 方法。通过存储 CurrentDispatcher,您可以使用 WPF 轻松地实现这一点。但是,之后只能在 WPF 应用程序中使用该非图形组件,而不能在 Windows 窗体或 ASP.NET 程序中使用该组件。
DispatcherSynchronizationContext 类可满足这一需求。可以将该类视为还使用其他 UI 框架的 Dispatcher 的简化版本。
public class WeatherComponent2 : Component { public string GetWeather() { return fetchWeatherFromServer(); } private DispatcherSynchronizationContext requestingContext = null; public void GetWeatherAsync() { if (requestingContext != null) throw new InvalidOperationException("This component can only handle 1 async request at a time"); requestingContext = (DispatcherSynchronizationContext)DispatcherSynchronizationContext.Current; NoArgDelegate fetcher = new NoArgDelegate(this.fetchWeatherFromServer); // Launch thread fetcher.BeginInvoke(null, null); } private void RaiseEvent(GetWeatherCompletedEventArgs e) { if (GetWeatherCompleted != null) GetWeatherCompleted(this, e); } private string fetchWeatherFromServer() { // do stuff string weather = ""; GetWeatherCompletedEventArgs e = new GetWeatherCompletedEventArgs(null, false, null, weather); SendOrPostCallback callback = new SendOrPostCallback(DoEvent); requestingContext.Post(callback, e); requestingContext = null; return e.Weather; } private void DoEvent(object e) { //do stuff } public event GetWeatherCompletedEventHandler GetWeatherCompleted; public delegate string NoArgDelegate(); }
嵌套泵
有时锁定 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 对象和终止进程。当对对象进行垃圾回收时,会在专门的终结器线程(而不是 UI 线程)上运行它的 Finalize 方法。问题就在这里:在 UI 线程上创建的 COM STA 对象只能在 UI 线程上释放。CLR 执行 BeginInvoke 的等效功能(在这种情况下使用 Win32 的 SendMessage)。但是,如果 UI 线程繁忙,则终结器线程将停止,COM STA 对象无法释放,这样会导致严重的内存泄漏。因此,CLR 团队进行严格的调用来使锁按预期的方式工作。
WPF 的任务是避免意外的重新进入,不重新引入内存泄漏。这就是我们在任何位置都不阻止重新进入的原因。