并发编程-4.用户界面响应能力和线程
利用后台线程
在第一章中,我们学习了如何创建后台线程并讨论了它们的一些用途。 后台线程的优先级低于进程的主线程和其他线程池线程。此外,活动的后台线程不会阻止用户或系统终止应用程序。
这意味着后台线程非常适合执行以下任务:
• 写入日志和分析数据
• 监控网络或文件系统资源
• 将数据读入应用程序
不要将后台线程用于关键应用程序操作,如下所示:
• 保存应用程序状态
• 执行数据库事务
• 应用程序数据处理
在决定某些工作是否可以由后台线程处理时,要遵循的一个好规则是问问自己,突然中断工作以关闭应用程序是否会危及系统的数据完整性。 那么,您如何知道您正在创建后台线程还是前台线程呢?
哪些线程是后台线程?
我们了解到,可以通过将线程的 IsBackground
属性设置为 true
来将其显式创建为后台线程。 默认情况下,通过调用 Thread
构造函数创建的所有其他线程都是前台线程。 应用程序的主线程是前台线程。所有 ThreadPool
线程都是后台线程。 这包括由任务并行库 (TPL) 启动的所有异步操作。
因此,如果所有基于任务的操作(例如异步方法)都在后台线程上执行,您是否应该避免使用它们来保存重要的应用程序数据? 当这些异步/等待操作正在进行时,.NET 是否允许您的应用程序关闭? 如果有前台线程正在等待异步操作,则应用程序在操作完成之前不会终止。 如果不使用await,或者使用Task.Run
在线程池上启动操作,则应用程序可能会在操作完成之前正常终止。
将await
与异步方法结合使用的好处是,您可以灵活地控制执行流程,同时保持UI 响应。 让我们讨论客户端应用程序中的异步和等待,并创建一个从多个源加载数据的 Windows Presentation Foundation (WPF) 应用程序示例。
使用 async、await、tasks 和 WhenAll
在代码中使用 async
和 wait
是使用 ThreadPool
引入一些后台工作的最简单方法。 异步方法必须使用 async
关键字修饰,并且将返回 System.Threading.Tasks.Task
类型而不是 void 返回。
异步方法返回
Task
,以便调用方法可以等待该方法的结果。 如果要创建具有void
返回类型的异步方法,则无法等待它,并且调用代码将在异步方法完成之前继续处理后续代码。 需要注意的是,只有事件处理程序才应声明为具有void
返回类型的异步函数
如果该方法返回字符串,则异步等效项将返回 Task<string>
泛型类型。 让我们看一下每个例子:
private async Task ProcessDataAsync()
{
// Process data here
}
private async Task<string> GetStringDataAsync()
{
string stringData;
// Build string here
...
return stringData;
}
当您调用异步方法时,需要遵循两种常见模式。
• 首先,您可以等待调用并将返回类型设置为方法内返回类型的变量:
await ProcessDataAsync();
string data = await GetStringDataAsync();
• 第二个选项是在调用方法时使用任务变量并稍后等待它们:
Task dataTask = ProcessDataAsync();
Task<string> stringDataTask = GetStringDataAsync();
DoSomeOtherSynchronousWork();
string data = await stringDataTask;
await dataTask;
使用第二种方法,应用程序可以执行一些同步工作,同时两个异步方法继续在后台线程上运行。 一旦同步工作完成,应用程序将等待两个异步方法。
让我们将异步知识应用到一个更实际的示例项目中。 在此示例中,我们将使用 WPF 创建一个新的 Windows 客户端应用程序,该应用程序从两个异步方法加载数据。 我们将通过使用 Task.Delay
注入非阻塞延迟来模拟缓慢的服务调用以在这些方法中获取数据。 每个方法都需要几秒钟的时间来返回其数据,但 UI 将保持对用户输入的响应:
- 首先在 Visual Studio 中创建一个新的 WPF 项目。 将项目命名为
AwaitWithWpf
。 - 将两个新类添加到名为
Order
和MainViewModel
的项目中。 您的解决方案现在应该如下所示:
图 4.1 – Visual Studio 中的 AwaitWithWpf 解决方案
- 接下来,打开 NuGet Package Manager,在 Browse 选项卡上搜索 MVVM Toolkit,将
Microsoft.Toolkit.Mvvm
包的最新稳定版本添加到您的项目中:
图 4.2 – 将 Microsoft.Toolkit.Mvvm 包添加到项目中
我们将使用 MVVM 工具包将模型-视图-视图模型 (MVVM) 功能添加到我们的 MainViewModel
类中。
- 现在,打开
Order
类并添加以下实现:
public class Order
{
public int OrderId { get; set; }
public string? CustomerName { get; set; }
public bool IsArchived { get; set; }
}
当订单列表填充到主窗口上时,这将为每个订单提供一些要显示的属性。
5. 现在我们将开始构建 MainViewModel
实现。 第一步是添加要绑定到 UI 的订单列表以及要加载订单时要执行的命令:
public class MainViewModel : ObservableObject
{
private ObservableCollection<Order> _orders = new();
public MainViewModel()
{
LoadOrderDataCommand = new AsyncRelayCommand(LoadOrderDataAsync);
}
public ICommand LoadOrderDataCommand { get; set; }
public ObservableCollection<Order> Orders
{
get { return _orders; }
set
{
SetProperty(ref _orders, value);
}
}
private async Task LoadOrderDataAsync()
{
// TODO – Add code to load orders
}
}
在继续下一步之前,让我们回顾一下 MainViewModel
类的一些属性:
• MainViewModel
类继承自MVVM Toolkit 提供的ObservableObject
类型。
• 此基类实现 INotifyPropertyChanged
接口,WPF 数据绑定使用该接口在数据绑定属性值更改时通知 UI。
• Orders 属性将通过WPF 数据绑定向UI 提供订单列表。在ObservableObject
基础上调用SetProperty
可设置_orders
支持变量的值并触发属性更改通知。
• LoadOrderDataCommand
属性将由MainWindow
上的按钮执行。在构造函数中,该属性被初始化为新的AsyncRelayCommand
,当UI 调用该命令时,该属性将调用LoadOrderDataAsync
。
- 不要忘记向类中添加必要的
using
语句:
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows.Input;
- 接下来,我们创建两个异步方法来加载订单数据。 一个将创建当前订单,另一个将创建存档订单列表。 它们通过
Order
类的IsArchived
属性来区分。 每个方法都使用Task.Delay
来模拟通过慢速互联网或网络连接的服务调用:
private async Task<List<Order>> GetCurrentOrdersAsync()
{
var orders = new List<Order>();
await Task.Delay(4000);
orders.Add(new Order { OrderId = 55, CustomerName = "Tony", IsArchived = false });
orders.Add(new Order { OrderId = 56, CustomerName = "Peggy", IsArchived = false });
orders.Add(new Order { OrderId = 60, CustomerName = "Carol", IsArchived = false });
orders.Add(new Order { OrderId = 62, CustomerName = "Bruce", IsArchived = false });
return orders;
}
private async Task<List<Order>> GetArchivedOrdersAsync()
{
var orders = new List<Order>();
await Task.Delay(5000);
orders.Add(new Order { OrderId = 3, CustomerName ="Howard", IsArchived = true });
orders.Add(new Order { OrderId = 18, CustomerName= "Steve", IsArchived = true });
orders.Add(new Order { OrderId = 19, CustomerName = "Peter", IsArchived = true });
orders.Add(new Order { OrderId = 21, CustomerName = "Mary", IsArchived = true });
orders.Add(new Order { OrderId = 25, CustomerName= "Gwen", IsArchived = true });
orders.Add(new Order { OrderId = 34, CustomerName = "Harry", IsArchived = true });
orders.Add(new Order { OrderId = 36, CustomerName= "Bob", IsArchived = true });
orders.Add(new Order { OrderId = 49, CustomerName = "Bob", IsArchived = true });
return orders;
}
- 现在我们需要创建一个同步
ProcessOrders
方法,该方法组合两个订单列表并使用完整数据集更新Orders
属性:
private void ProcessOrders(List<Order> currentOrders,List<Order> archivedOrders)
{
List<Order> allOrders = new(currentOrders);
allOrders.AddRange(archivedOrders);
Orders = new ObservableCollection<Order>(allOrders);
}
- 构建
MainViewModel
类的最后一步是最重要的。 将以下实现添加到LoadOrderDataAsync
方法:
private async Task LoadOrderDataAsync()
{
Task<List<Order>> currentOrdersTask =GetCurrentOrdersAsync();
Task<List<Order>> archivedOrdersTask = GetArchivedOrdersAsync();
List<Order>[] results = await Task.WhenAll(new
Task<List<Order>>[] {currentOrdersTask, archivedOrdersTask})
.ConfigureAwait(false);
ProcessOrders(results[0], results[1]);
}
此方法调用 GetCurrentOrdersAsync
和 GetArchivedOrdersAsync
并捕获 Task<List<Order>>
变量中的每个值。 您可以简单地等待每个调用并将返回的订单存储在 List<Order>
变量中。 但是,这意味着在第一个方法完成之前,第二个方法不会开始执行。 通过等待 Task.WhenAll
,这些方法可以在后台线程上并行执行。
如果您的方法都返回相同的数据类型,则可以在返回类型的数组中捕获 Task.WhenAll
的结果。 在我们的例子中,我们接收 List<Order>
数组中的两个订单列表,并将这两个数组值传递给 ProcessOrders
。
- 现在,让我们继续讨论
MainWindow.xaml.cs
代码隐藏文件。 在调用InitializeComponent
之后,在构造函数中添加以下代码来设置MainWindow
的DataContext
:
public MainWindow()
{
InitializeComponent();
var vm = new MainViewModel();
DataContext = vm;
}
DataContext
是 MainWindow
的 XAML
中所有 Binding
引用的源。 我们将在下一步中为 UI 创建 XAML。
- 最后一个要更新的文件是
MainWindow.xaml
。 打开 XAML 文件并首先向Grid
添加两行。 第一行将包含另一个包含Button
和TextBox
的Grid
。第二行将包含ListView
以显示订单列表。 我们稍后将为订单创建一个模板:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Content="Load Data" Grid.Column="0" Margin="2" Width="200"
Command="{Binding Path=LoadOrderData Command}"/>
<TextBox Grid.Column="1" Margin="2"/>
</Grid>
<ListView Grid.Row="1" ItemsSource="{Binding Path=Orders}" Margin="4">
</ListView>
</Grid>
</Grid>
我在 XAML 标记中突出显示了两个数据绑定实例。 Button
的 Command
绑定到 LoadOrderDataCommand
属性,ListView
的 ItemsSource
绑定到 Orders
属性。 设置 ItemsSource
将使 Order 类的属性可供 ListView.ItemTemplate
的成员使用。
- 接下来我们将
ItemTemplate
添加到ListView
中。 在ItemTemplate
中定义DataTemplate
定义了ListView
中每个项目的结构:
<TextBox IsReadOnly="True"
Width="200"
Text="{Binding Path=OrderId}" Margin="2"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Customer:" Margin="2,2,0,2" Width="100"/>
<TextBox IsReadOnly="True" Width="200" Text="{Binding Path=CustomerName}" Margin="2"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Archived:" Margin="2,2,0,2" Width="100"/>
<TextBox IsReadOnly="True" Width="200"Text="{Binding Path=IsArchived}" Margin="2"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
每个 Order
实例将呈现为一个 StackPanel
,其中包含三个水平对齐的 StackPanel
元素,显示 OrderId
、CustomerName
和 IsArchived
数据绑定属性的标签和值。
- 我们已准备好运行该应用程序并查看其工作原理。 程序启动后,单击“加载数据”按钮。 将数据加载到
ListView
大约需要 5 秒。 等待期间,尝试在“加载数据”按钮右侧的框中输入一些文本。 您可以看到,由于async/await
和Task.WhenAll
方法,UI 仍然响应用户输入。数据加载完成后,您应该在可滚动列表中看到包含 12 个订单的列表:
图 4.2 – 在 AsyncWithWpf 应用程序中查看订单列表
在实际的生产应用程序中,两个异步方法的实现将被服务调用所取代,以从数据库或 Web 服务中获取数据。 无论需要多长时间才能返回
并填充数据,UI 的其他部分将保持对用户输入的响应。 您想要进行的一项更改是向 UI 添加一个指示器,以通知用户正在加载数据。 您还应该在数据加载过程处于活动状态时禁用“加载数据”按钮,以防止多次调用 LoadOrderDataAsync
。
使用线程池
还有其他方法可以在 .NET 应用程序中使用 ThreadPool
线程。 让我们讨论一种情况,您想要完成与上一示例中使用 async 和 wait 获得的相同结果,但获取订单数据的方法未标记为异步。 一种选择是将方法更新为异步。 如果您无法控制更改该代码,您还有其他一些选项可用。
ThreadPool
类有一个名为 QueueUserWorkItem
的方法。 此方法接受一个方法来调用并将其排队以在线程池上执行。 我们可以像这样在我们的项目中使用它:
ThreadPool.QueueUserWorkItem(GetCurrentOrders);
使用此方法存在一些问题。 主要问题是没有返回值来从方法调用中获取订单列表。 您可以使用一些更新共享线程安全集合(例如 BlockingCollection
)的包装方法来解决此问题。 这不是一个很好的设计,而且还有更好的选择。
在引入 TPL 之前,QueueUserWorkItem
方法更常用。在当今基于任务的世界中,您可以使用 Task.Run
将同步方法作为异步方法执行。 让我们更新 WPF 项目以使用 Task.Run
:
- 使用
Task.Run
唯一需要修改的文件是MainViewModel
。 首先将GetCurrentOrdersAsync
和GetArchivedOrdersAsync
更新为不再是异步方法。 它们还应该重命名为GetCurrentOrders
和GetArchivedOrders
,以便消费者知道它们不是异步方法:
private List<Order> GetCurrentOrders()
{
var orders = new List<Order>();
Thread.Sleep(4000);
orders.Add(new Order { OrderId = 55, CustomerName
= "Tony", IsArchived = false });
orders.Add(new Order { OrderId = 56, CustomerName
= "Peggy", IsArchived = false });
orders.Add(new Order { OrderId = 60, CustomerName
= "Carol", IsArchived = false });
orders.Add(new Order { OrderId = 62, CustomerName
= "Bruce", IsArchived = false });
return orders;
}
private List<Order> GetArchivedOrders()
{
var orders = new List<Order>();
Thread.Sleep(5000);
orders.Add(new Order { OrderId = 3, CustomerName =
"Howard", IsArchived = true });
orders.Add(new Order { OrderId = 18, CustomerName
= "Steve", IsArchived = true });
orders.Add(new Order { OrderId = 19, CustomerName
= "Peter", IsArchived = true });
orders.Add(new Order { OrderId = 21, CustomerName
= "Mary", IsArchived = true });
orders.Add(new Order { OrderId = 25, CustomerName
= "Gwen", IsArchived = true });
orders.Add(new Order { OrderId = 34, CustomerName
= "Harry", IsArchived = true });
orders.Add(new Order { OrderId = 36, CustomerName
= "Bob", IsArchived = true });
orders.Add(new Order { OrderId = 49, CustomerName
= "Bob", IsArchived = true });
return orders;
}
更改很小,我在前面的源代码中突出显示了它们。 async
修饰符已从方法声明中删除,方法已重命名并且不再返回任务,并且每个方法中的 Task.Delay
已更新为 Thread.Sleep
。
- 接下来,我们将更新
LoadOrderDataAsync
方法以使用Task.Run
调用同步方法:
private async Task LoadOrderDataAsync()
{
Task<List<Order>> currentOrdersTask =
Task.Run(GetCurrentOrders);
Task<List<Order>> archivedOrdersTask =
Task.Run(GetArchivedOrders);
List<Order>[] results = await Task.WhenAll(new
Task<List<Order>>[] {
currentOrdersTask, archivedOrdersTask
}).ConfigureAwait(false);
ProcessOrders(results[0], results[1]);
}
无需进行其他更改。 Task.Run
将返回相同的 Task<List<Order>>
类型,该类型仍然可以与 Task.WhenAll
一起使用以等待其完成。
- 运行程序,它应该像以前一样工作。 加载订单数据时,UI 保持响应。
这是开始将 async
和 wait
合并到现有代码中的绝佳方法,但在向应用程序添加线程时始终要小心。 在此应用程序中,被调用的两个方法不访问任何共享数据。 所以,没有必要考虑线程安全。 如果这些方法正在更新订单的私有集合,您将需要引入锁定机制或使用订单的线程安全集合。
在我们继续讨论 UI 线程之前,还需要讨论另一种 Task
方法。 Task.Factory.StartNew
方法的用法与Task.Run
类似。 事实上,您可以以同样的方式使用它们。 此代码使用 Task.Run
获取具有当前订单的任务:
Task<List<Order>> currentOrdersTask = Task.Run(GetCurrentOrders);
此代码与 Task.Factory.StartNew
执行相同的操作:
Task<List<Order>> currentOrdersTask = Task.Factory.StartNew(GetCurrentOrders);
在这种情况下,您应该使用Task.Run
。 这是一种较新的方法,只是一种旨在简化最常见用例的快捷方式。 Task.Factory.StartNew
方法有一些针对特定用途的额外重载。 此示例使用 StartNew
调用 GetCurrentOrders
并带有一些可选参数:
Task<List<Order>> currentOrdersTask =
Task.Factory.StartNew(GetCurrentOrders,
CancellationToken.None,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
我们在这里提供的有趣选项是 TaskCreationOptions.AttachedToParent
。 它的作用是将调用方法的任务完成情况链接到子方法 GetCurrentOrders
的任务完成情况。 默认行为是取消链接它们的完成。 有关可用重载及其用途的完整列表,您可以在此处查看 Microsoft 文档:https://docs.microsoft.com/dotnet/api/system.threading.tasks.taskfactory.startnew.
.NET 团队的 Stephen Toub 有一篇博客文章,其中讨论了
Task.Run
与Task.Factory.StartNew
以及您可能想要选择每个选项的原因。 您可以在此处阅读他在 .NET 并行编程博客上的文章:https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/。
更新UI线程无异常
在 .NET 应用程序中使用托管线程时,开发人员必须学会避免许多陷阱。 开发人员常犯的错误之一是编写从非 UI 线程更新 Windows 应用程序中的 UI 控件的代码。 编译器不会检测到这种错误。 开发人员将收到一个运行时错误,指示无法在另一个线程上修改在主线程上创建的控件。
那么,如何避免这些运行时错误呢? 避免它们的最佳方法是根本不从后台线程更新 UI 控件。 WPF 有助于避免 MVVM 模式和数据绑定的问题。 .NET 会自动将绑定更新编组到 UI 线程。 您可以从后台线程安全地更新 ViewModel
类中的属性,而不会在运行时引起错误。如果您直接在代码中更新 UI 控件,无论是在 WinForms 应用程序中还是在 WPF 控件的代码隐藏文件中,您可以使用 Invoke
调用将执行推至主线程。 WinForms 和 WPF 的实现略有不同。 让我们从一个 WPF 示例开始。 如果您有一个方法在后台线程上执行某些工作,并且需要更新 WPF 窗口上 TextBox
的 Text
属性,您可以将代码包装在一个操作中:
Application.Current.Dispatcher.Invoke(new Action(() => {
usernameTextBox.Text = "John Doe";
}));
Dispatcher.Invoke
会将执行推送到主线程。 请记住,如果主线程正忙于其他工作,您的后台线程将在这里等待此操作完成。 如果您的后台工作人员想要解雇并忘记此操作,您可以使用 Dispatcher.BeginInvoke
代替。
假设我们想要更新 usernameTextBox
,但这一次,我们正在使用 WinForms 项目。 通过使用 Form
或 UserControl
执行代码可以完成相同的调用。 此示例是一个带有两个按钮的 WinForms 应用程序。 单击一个按钮将调用 UpdateUsername
方法。 另一个按钮将调用 Task.Run(UpdateUsername)
,将其放在后台线程上。 要确定是否需要 Invoke
来访问主线程,请检查 Boolean
InvokeRequired
只读属性。 如果线程池选择在主线程上运行任务,则可能不需要:
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void btnRunInBackground_Click(object sender,
EventArgs e)
{
Task.Run(UpdateUsername);
}
private void btnRunOnMainThread_Click(object sender,
EventArgs e)
{
UpdateUsername();
}
private void UpdateUsername()
{
var updateAction = new Action(() =>
{
usernameTextBox.Text = "John Doe";
});
if (this.InvokeRequired)
{
this.Invoke(updateAction);
}
else
{
updateAction();
}
}
}
无论单击哪个按钮,usernameTextBox
都会成功显示 John Doe 的名字:
图 4.3 – 更新 WinForms 表单上的控件
与WPF一样,如果后台代码不需要等待主线程更新完成,WinForms有一个BeginInvoke
方法。 BeginInvoke
还可以接受 EndInvoke
委托,该委托将在主线程调用完成时接收回调。