线程模型

Windows Presentation Foundation (WPF) 旨在将开发人员从线程处理困难中解脱出来。 因此,大多数 WPF 开发人员不会编写使用多个线程的界面。 由于多线程程序既复杂又难以调试,因此当存在单线程解决方案时,应避免使用多线程程序。

但是,无论构建得多好,没有任何 UI 框架能为每种问题都提供单线程解决方案。 WPF 虽然在这方面有近乎完美的表现,但某些情况下,仍需要使用多线程来改进用户界面 (UI) 响应能力或应用程序性能。 基于上文所述的背景材料,本文对上述情况进行探讨,然后通过对一些低级别的细节进行讨论作出总结。

注意

本主题介绍使用 InvokeAsync 方法进行异步调用的线程处理。 InvokeAsync 方法采用 ActionFunc<TResult> 作为参数,并返回具有 DispatcherOperation 属性的 DispatcherOperation<TResult>Task。 可以将 await 关键字与 DispatcherOperation 或相关联的 Task 配合使用。 如果需要同步等待 TaskDispatcherOperation 返回的 DispatcherOperation<TResult>,请调用 DispatcherOperationWait 扩展方法。 调用 Task.Wait 将导致死锁。 有关使用 Task 执行异步操作的详细信息,请参阅基于任务的异步编程

若要进行同步调用,请使用 Invoke 方法,该方法还具有采用委托、ActionFunc<TResult> 参数的重载。

概述和调度程序

通常,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 类提供了用于注册工作项的方法:Dispatcher.InvokeAsyncDispatcher.BeginInvokeDispatcher.Invoke。 这些方法都计划一个用于执行的委托。 Invoke 是一个同步调用,也就是说,在 UI 线程真正执行完委托之前,它不会返回。 InvokeAsyncBeginInvoke 是异步的,并立即返回。

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。

重要

本文中显示的 XAML 来自 C# 项目。 为 XAML 声明后备类时,Visual Basic XAML 略有不同。

<Window 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">
    <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="20" >
        <Button Content="Start"  
                Click="StartStopButton_Click"
                Name="StartStopButton"
                Margin="5,0,5,0" Padding="10,0" />
        
        <TextBlock Margin="10,0,0,0">Biggest Prime Found:</TextBlock>
        <TextBlock Name="bigPrime" Margin="4,0,0,0">3</TextBlock>
    </StackPanel>
</Window>

以下示例显示了代码隐藏。

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;
    }
}
Imports System.Windows.Threading

Public Class PrimeNumber
    ' Current number to check
    Private _num As Long = 3
    Private _runCalculation As Boolean = False

    Private Sub StartStopButton_Click(sender As Object, e As RoutedEventArgs)
        _runCalculation = Not _runCalculation

        If _runCalculation Then
            StartStopButton.Content = "Stop"
            StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
        Else
            StartStopButton.Content = "Resume"
        End If

    End Sub

    Public Sub CheckNextNumber()
        ' Reset flag.
        _isPrime = True

        For i As Long = 3 To Math.Sqrt(_num)
            If (_num Mod i = 0) Then

                ' Set Not a prime flag to true.
                _isPrime = False
                Exit For
            End If
        Next

        ' If a prime number, update the UI text
        If _isPrime Then
            bigPrime.Text = _num.ToString()
        End If

        _num += 2

        ' Requeue this method on the dispatcher
        If (_runCalculation) Then
            StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
        End If
    End Sub

    Private _isPrime As Boolean
End Class

除更新 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;
Public Sub CheckNextNumber()
    ' Reset flag.
    _isPrime = True

    For i As Long = 3 To Math.Sqrt(_num)
        If (_num Mod i = 0) Then

            ' Set Not a prime flag to true.
            _isPrime = False
            Exit For
        End If
    Next

    ' If a prime number, update the UI text
    If _isPrime Then
        bigPrime.Text = _num.ToString()
    End If

    _num += 2

    ' Requeue this method on the dispatcher
    If (_runCalculation) Then
        StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
    End If
End Sub

Private _isPrime As Boolean

此方法检查下一个奇数是否是质数。 如果是质数,此方法将直接更新 bigPrimeTextBlock,以反映其发现。 可以如此操作的原因是,该计算发生在用于创建控件的相同线程中。 如果选择使用单独的线程来进行计算,将必须使用更复杂的同步机制,并在 UI 线程中执行更新。 我们将在下一步中演示这种情况。

多窗口、多线程

某些 WPF 应用程序要求多个顶层窗口。 通过单个线程/Dispatcher 组合来管理多个窗口是完全可以接受的,但有时多线程可以做得更好。 尤其当这些窗口中的某一个将有可能要独占线程时,更是如此。

Windows 资源管理器以这种方式工作。 每个新资源管理器窗口都属于原始进程,但它是在独立线程的控件下创建的。 当资源管理器变得非响应时(例如在查找网络资源时),其他资源管理器窗口将继续响应且可用。

可以使用以下示例演示此概念。

WPF 窗口的屏幕截图,该窗口复制了四次。其中三个窗口表示它们使用的是同一线程,而其他两个窗口位于不同的线程上。

此图的前三个窗口共享相同的线程标识符:1。 另外两个窗口具有不同的线程标识符:9 和 4。 每个窗口的右上角都有一个品红色的旋转 ‼字形。

此示例包含一个带以下内容的窗口:旋转的 ‼️ 字形、一个“暂停”按钮和另外两个用于在当前线程下或新线程中创建新窗口的按钮。 ‼️ 字形不断旋转,直到按下“暂停”按钮,该按钮会将线程暂停五秒。 在窗口的底部,将显示线程标识符。

当按下“暂停”按钮时,同一线程下的所有窗口都变得无响应。 不同线程下的任何窗口将继续正常工作。

以下示例是窗口的 XAML:

<Window 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">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <TextBlock HorizontalAlignment="Right" Margin="30,0" Text="‼️" FontSize="50" FontWeight="ExtraBold"
                   Foreground="Magenta" RenderTransformOrigin="0.5,0.5" Name="RotatedTextBlock">
            <TextBlock.RenderTransform>
                <RotateTransform Angle="0" />
            </TextBlock.RenderTransform>
            <TextBlock.Triggers>
                <EventTrigger RoutedEvent="Loaded">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation Storyboard.TargetName="RotatedTextBlock"
                                Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                                From="0" To="360" Duration="0:0:5" RepeatBehavior="Forever" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </TextBlock.Triggers>
        </TextBlock>

        <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="20" >
            <Button Content="Pause" Click="PauseButton_Click" Margin="5,0" Padding="10,0" />
            <TextBlock Margin="5,0,0,0" Text="<-- Pause for 5 seconds" />
        </StackPanel>

        <StackPanel Grid.Row="1" Margin="10">
            <Button Content="Create 'Same Thread' Window" Click="SameThreadWindow_Click" />
            <Button Content="Create 'New Thread' Window" Click="NewThreadWindow_Click" Margin="0,10,0,0" />
        </StackPanel>

        <StatusBar Grid.Row="2" VerticalAlignment="Bottom">
            <StatusBarItem Content="Thread ID" Name="ThreadStatusItem" />
        </StatusBar>

    </Grid>
</Window>

以下示例显示了代码隐藏。

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();
        }
    }
}
Imports System.Threading

Public Class MultiWindow
    Private Sub Window_Loaded(sender As Object, e As RoutedEventArgs)
        ThreadStatusItem.Content = $"Thread ID: {Thread.CurrentThread.ManagedThreadId}"
    End Sub

    Private Sub PauseButton_Click(sender As Object, e As RoutedEventArgs)
        Task.Delay(TimeSpan.FromSeconds(5)).Wait()
    End Sub

    Private Sub SameThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim window As New MultiWindow()
        window.Show()
    End Sub

    Private Sub NewThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim newWindowThread = New Thread(AddressOf ThreadStartingPoint)
        newWindowThread.SetApartmentState(ApartmentState.STA)
        newWindowThread.IsBackground = True
        newWindowThread.Start()
    End Sub

    Private Sub ThreadStartingPoint()
        Dim window As New MultiWindow()
        window.Show()

        System.Windows.Threading.Dispatcher.Run()
    End Sub
End Class

以下是要注意的一些详细信息:

  • Task.Delay(TimeSpan) 任务用于在按下“暂停”按钮时使当前线程暂停五秒钟。

    private void PauseButton_Click(object sender, RoutedEventArgs e) =>
        Task.Delay(TimeSpan.FromSeconds(5)).Wait();
    
    Private Sub PauseButton_Click(sender As Object, e As RoutedEventArgs)
        Task.Delay(TimeSpan.FromSeconds(5)).Wait()
    End Sub
    
  • SameThreadWindow_Click 事件处理程序立即在当前线程下显示一个新窗口。 NewThreadWindow_Click 事件处理程序创建一个新线程,该线程开始执行 ThreadStartingPoint 方法,该方法又显示一个新窗口,如下一个要点所述。

    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 Sub SameThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim window As New MultiWindow()
        window.Show()
    End Sub
    
    Private Sub NewThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim newWindowThread = New Thread(AddressOf ThreadStartingPoint)
        newWindowThread.SetApartmentState(ApartmentState.STA)
        newWindowThread.IsBackground = True
        newWindowThread.Start()
    End Sub
    
  • ThreadStartingPoint 方法是新线程的起点。 新窗口是在此线程的控制下创建的。 WPF 自动创建新的 System.Windows.Threading.Dispatcher 来管理新线程。 若要使窗口功能化,我们要做的是启动 System.Windows.Threading.Dispatcher

    private void ThreadStartingPoint()
    {
        new MultiWindow().Show();
    
        System.Windows.Threading.Dispatcher.Run();
    }
    
    Private Sub ThreadStartingPoint()
        Dim window As New MultiWindow()
        window.Show()
    
        System.Windows.Threading.Dispatcher.Run()
    End Sub
    

演示本部分概念的适用于 C#Visual Basic 的示例应用可从 GitHub 下载。

使用 Task.Run 处理阻塞操作

在图形应用程序中处理阻塞操作可能很困难。 我们不希望从事件处理程序调用阻塞方法,因为应用程序已经冻结。 前面的示例在其自己的线程中创建了新窗口,让每个窗口彼此独立运行。 虽然我们可以使用 System.Windows.Threading.Dispatcher 创建一个新线程,但工作完成后很难将新线程与主 UI 线程同步。 由于新线程无法直接修改 UI,因此我们必须使用 Dispatcher.InvokeAsyncDispatcher.BeginInvokeDispatcher.Invoke 将委托插入到 UI 线程的 Dispatcher 中。 最终,将通过可修改 UI 元素的权限来执行这些委托。

有一种更简单的方法可以在新线程上运行代码并同步结果,即基于任务的异步模式 (TAP)。 它基于 Task 命名空间中的 Task<TResult>System.Threading.Tasks 类型,这些类型用于表示异步操作。 TAP 使用单个方法表示异步操作的开始和完成。 此模式有一些好处:

  • Task 的调用方可以选择异步或同步运行代码。
  • Task 可以报告进度。
  • 调用代码可以暂停执行并等待操作的结果。

Task.Run 示例

在本例中,我们模拟了一个检索天气预报的远程过程调用。 单击该按钮时,UI 将更新为指示正在提取数据,同时开始模拟一个提取天气预报的任务。 启动任务后,按钮事件处理程序代码将暂停,直到任务完成。 任务完成后,事件处理程序代码将继续运行。 代码暂停,不会阻止 UI 线程的其余部分。 WPF 的同步上下文处理暂停代码,从而允许 WPF 继续运行。

演示示例应用的工作流的示意图。

演示示例应用的工作流的关系图。 该应用有一个按钮,其中包含文本“提取预测”。 按下按钮后,有一个箭头指向应用的下一阶段,这是放置在应用中心处的时钟图像,指示应用正在忙于提取数据。 一段时间后,应用会返回太阳或雨云的图像,具体取决于数据的结果。

演示本部分概念的适用于 C#Visual Basic 的示例应用可从 GitHub 下载。 此示例的 XAML 非常大,本文未提供。 使用前面的 GitHub 链接浏览 XAML。 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);
    }
}
Imports System.Windows.Media.Animation

Public Class Weather

    Private Async Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)

        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)

        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)

        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)

        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)

        End If

        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)

        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub

    Private Async Function FetchWeatherFromServerAsync() As Task(Of String)

        ' Simulate the delay from network access
        Await Task.Delay(TimeSpan.FromSeconds(4))

        ' Tried and true method for weather forecasting - random numbers
        Dim rand As New Random()

        If rand.Next(2) = 0 Then
            Return "rainy"
        Else
            Return "sunny"
        End If

    End Function

    Private Sub HideClockFaceStoryboard_Completed(sender As Object, e As EventArgs)
        DirectCast(Resources("ShowWeatherImageStoryboard"), Storyboard).Begin(ClockImage)
    End Sub

    Private Sub HideWeatherImageStoryboard_Completed(sender As Object, e As EventArgs)
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Begin(ClockImage, True)
    End Sub
End Class

以下是一些需要注意的详细信息。

  • 按钮事件处理程序

    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 Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)
    
        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)
    
        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)
    
        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)
    
        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)
    
        End If
    
        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)
    
        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub
    

    请注意,事件处理程序已声明为 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";
    }
    
    Private Async Function FetchWeatherFromServerAsync() As Task(Of String)
    
        ' Simulate the delay from network access
        Await Task.Delay(TimeSpan.FromSeconds(4))
    
        ' Tried and true method for weather forecasting - random numbers
        Dim rand As New Random()
    
        If rand.Next(2) = 0 Then
            Return "rainy"
        Else
            Return "sunny"
        End If
    
    End Function
    

    为简便起见,本例中没有任何网络代码。 通过使新线程进入休眠状态四秒钟,模拟网络访问的延迟。 此时,原始 UI 线程仍在运行并响应 UI 事件,而按钮的事件处理程序一直处于暂停状态,直到新线程完成。 为了演示这一点,我们让动画继续运行,你可以调整窗口大小。 如果 UI 线程已暂停或延迟,则不会显示动画,并且你无法与窗口交互。

    Task.Delay 完成后,我们已随机选择天气预报,天气状态将返回到调用方。

  • 更新 UI

    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 Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)
    
        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)
    
        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)
    
        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)
    
        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)
    
        End If
    
        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)
    
        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub
    

    当任务完成并且 UI 线程有时间时,按钮的事件处理程序 Task.Run 的调用方将继续。 该方法的其余部分停止时钟动画,并选择一个图像来描述天气。 它显示此图像并启用“提取预测”按钮。

演示本部分概念的适用于 C#Visual Basic 的示例应用可从 GitHub 下载。

技术详细信息和疑难点

以下部分介绍了多线程处理时可能会遇到的一些详细信息和疑难点。

嵌套泵

有时无法完全锁定 UI 线程。 让我们考虑一下 Show 类的 MessageBox 方法。 在用户单击“确定”按钮之前,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>

在椭圆形上按下鼠标左键时,将执行 handler2handler2 完成后,事件将传递到 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 的任务是在不重新引入内存泄漏的情况下,避免异常的重入,因此我们不阻止各个位置的重入。

另请参阅