C#/.NET 基础教程:多线程系列第五篇






4.88/5 (124投票s)
本文将介绍如何对不同类型的 UI 进行多线程处理。
引言
我恐怕得说,我就是那种如果没有事做就会感到无聊的人。所以现在我终于觉得我掌握了 WPF 的基础知识,是时候转向其他事情了。
我有许多事情需要关注,例如《WCF/WF/CLR Via C# 第二版》这本书,但最近我找了一份新工作(并得到了它,但最终还是拒绝了),这份工作需要我了解很多关于多线程的知识。虽然我认为自己在多线程方面做得不错,但我想,嗯,我处理多线程还可以,但总能做得更好。因此,我决定专门撰写一系列关于 .NET 多线程的文章。
本系列文章无疑将受益于我购买的一本出色的 Visual Basic .NET 多线程手册,这本书很好地填补了我(以及你)在 MSDN 中遇到的空白。
我猜测这个主题将涵盖从简单到中等到高级的内容,并且会涉及很多 MSDN 上的内容,但我希望也能给出我自己的见解。所以,如果它看起来有点像 MSDN,请原谅我。
我不知道确切的时间表,但它最终可能会是这样的:
- C#/.NET 线程简介
- 线程生命周期/线程机会/陷阱
- 同步
- 线程池(上一篇文章)
- UI 中的多线程(WinForms / WPF / Silverlight)(本文)
- 多线程的未来(任务并行库)
我想最好的方式就是直接开始。不过在开始之前有一点需要注意,我将使用 C# 和 Visual Studio 2008。
我将在本文中尝试涵盖的内容是:
本文将介绍如何对不同类型的 UI 进行多线程处理。
为何要对 UI 进行多线程处理
我想我们都曾经在使用过一些非常棒的 UI,也见过一些非常糟糕的 UI。我知道,当我使用一个 UI 时,最让我想要卸载某个东西的,就是应用程序无响应。我有一个原则:如果某个东西无响应,就卸载它,无需多问。就这么定了。
那么,那些让我卸载东西的软件开发者还能做些什么呢?嗯,稍加思考和一点多线程知识,就可以避免这种情况的发生。
我希望至少你们中的一些人读过本系列的其余文章。如果读过了,当我说明这些无响应的 UI 问题可以通过在后台线程中运行后台任务来避免,从而让 UI 响应用户的进一步操作时,你们应该不会感到惊讶。
只有当我们允许后台工作继续进行,并在适当的时候(例如工作完成后)更新 UI 时,才能构建出一个响应式的 UI。
本文旨在向你展示一些处理技术,以创建能够处理单个或多个后台任务,同时保持 UI 响应的 UI。我将主要介绍 WinForms 和 WPF 的技术,但也会提供一些关于 Silverlight 处理的提示。
WinForms 中的多线程
在本节中,我将向你展示如何在 WinForms 环境中使用线程。这通常通过 .NET 2.0 中提供的 `BackgroundWorker` 组件来实现。这是在 UI 中创建和管理后台线程的最简单方法。不过,也应该提到的是,它不像自己创建和管理线程那样灵活。但有了本系列其他文章中的信息,你应该能够轻松地创建和管理自己的线程。
`BackgroundWorker` 不如自己创建线程灵活的原因在于,它被设计用于特定的使用模式。`BackgroundWorker` 提供了以下功能:
- 执行后台工作的能力
- 根据输入参数执行后台工作的能力
- 显示进度的能力
- 报告完成情况的能力
- 被取消的能力
如果这些功能符合你的需求,那就很棒了,但对于更精细的控制,你应该自己启动和管理线程。不过,在本篇文章的这一部分,我将只使用 `BackgroundWorker`,因为它是当今在 UI 中创建和管理后台任务的最常用方法。正如我所说,本系列的其他文章为你提供了所需的所有工具,以防你需要更奇特的用法。
但现在,让我们继续,看看一些使用 `BackgroundWorker` 的示例。
首先,一个糟糕的例子
为了理解本文的其余部分,看到一个不工作的例子很重要,因此作为本文提供的代码的一部分,我提供了一个糟糕的 WinForms 应用程序示例。
当你尝试运行它时,你会看到类似这样的内容:
现在,让我们看看创建这个已处理 `Exception` 的代码。嗯,它很简单(我们稍后将深入研究 `BackgroundWorker` 的内部工作原理)。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;
namespace Threading.UI.Winforms
{
public partial class BackgroundWorkerBadExample : Form
{
public BackgroundWorkerBadExample()
{
InitializeComponent();
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
}
private void backgroundWorker1_RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
MessageBox.Show("Completed background task");
}
private void btnGo_Click(object sender, EventArgs e)
{
backgroundWorker1.RunWorkerAsync(100);
}
}
}
这里需要注意的重要部分是 `backgroundWorker1_DoWork()` 方法。请注意,我们捕获了 `InvalidOperationException`。原因是,在 .NET Windows 编程中,有一个首要规则,那就是所有控件都必须使用创建它们的线程来访问。在此示例中,我们没有采取任何措施将后台线程的工作封送到 UI 线程上执行,因此我们得到了 `InvalidOperationException`。
幸运的是,我们可以通过多种方式解决这个问题,我将在下面进行描述。但在我向你展示如何解决这个问题之前,请允许我简单谈谈如何使用 `BackgroundWorker`;这真的很非常非常简单。
可以通过一些参数更改和事件来连接 `BackgroundWorker`。
下表概述了如何使用 `BackgroundWorker` 完成各种操作:
任务 | 需要设置的内容 |
---|---|
报告进度 | `WorkerReportProgress = True`,并连接 `ProgressChangedEvent` |
支持取消 | WorkerSupportsCancellation = True |
无参数运行 | 无 |
带参数运行 | 无 |
通过检查附加的代码,你会看到更多内容。
现在是一些更好的选项
因此,现在我想向你展示一些将后台工作封送到 UI 线程的选项。我包含了三个选项:
选项 1:使用 `BeginInvoke`(适用于所有 .NET 版本)
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
if (this.InvokeRequired)
{
this.Invoke(new EventHandler(delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}));
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
这可能是封送到 UI 线程的最古老的方法,但也是最明确的方法,它真正展示了正在发生的事情,并且我认为有助于提高可读性。
选项 2:使用 `SynchronizationContext`(适用于 .NET 2.0 及以上版本)
private SynchronizationContext context;
.....
.....
//set up the SynchronizationContext
context = SynchronizationContext.Current;
if (context == null)
{
context = new SynchronizationContext();
}
.....
.....
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
context.Send(new SendOrPostCallback(delegate(object state)
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}), null);
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
此版本使用 .NET 2.0 可用的对象 `SynchronizationContext`,该对象允许我们使用 `Send()` 方法封送到 UI 线程。在内部,`SynchronizationContext` 本质上只是一个匿名委托的包装器。想知道证据吗?启动 Reflector 看看。这里还有 Leslie Sanford 的一个很棒的 CP 文章,链接,如果您想了解更多关于 `SynchronizationContext` 的信息,可以仔细阅读。
选项 3:使用 lambda 表达式(适用于 .NET 3.0 及以上版本)
现在我们也可以彻底疯狂一把,用 lambda 表达式替换匿名委托的使用,这将得到类似这样的结果:
private SynchronizationContext context;
.....
.....
//set up the SynchronizationContext
context = SynchronizationContext.Current;
if (context == null)
{
context = new SynchronizationContext();
}
.....
.....
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
context.Send(new SendOrPostCallback((s) =>
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString())
), null);
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
我想这真的取决于你对 lambda 表达式有多满意。我认为对于小任务来说它们还可以,但相信我,我见过它们被过度使用,那可不好看。下一篇文章将大量使用 lambda,因为任务并行库(TPL)似乎使用了大量的 lambda。
当你运行这些选项中的任何一个在演示代码中,你会得到一个非常简单的窗体,显示类似这样的内容:
如何报告进度
当然,在使用 `BackgroundWorker` 时,你可能想报告已完成的进度;幸运的是,这也易如反掌。这段代码展示了设置 `BackgroundWorker` 来报告进度的最重要部分:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.Threading;
namespace Threading.UI.Winforms
{
public partial class BackgroundWorkerReportingProgress : Form
{
private int factor = 0;
private SynchronizationContext context;
public BackgroundWorkerReportingProgress()
{
InitializeComponent();
//set up the SynchronizationContext
context = SynchronizationContext.Current;
if (context == null)
{
context = new SynchronizationContext();
}
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
BackgroundWorker worker = sender as BackgroundWorker;
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
if (worker.CancellationPending)
{
e.Cancel = true;
return;
}
context.Send(new SendOrPostCallback( (s) =>
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString())
), null);
//report progress
Thread.Sleep(1000);
worker.ReportProgress((100 / factor) * i + 1);
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
}
private void btnGo_Click(object sender, EventArgs e)
{
factor = 100;
backgroundWorker1.RunWorkerAsync(factor);
}
private void backgroundWorker1_ProgressChanged(object sender,
ProgressChangedEventArgs e)
{
progressBar1.Value = e.ProgressPercentage;
}
private void backgroundWorker1_RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
MessageBox.Show("Completed background task");
}
private void btnCancel_Click(object sender, EventArgs e)
{
backgroundWorker1.CancelAsync();
}
}
}
这非常简单;我们只需连接 `BackgroundWorker.ProgressChanged` 事件处理程序(在此例中为 `backgroundWorker1_ProgressChanged`),并在 `BackgroundWorker.DoWork` 事件处理程序中设置进度。
要取消操作,我们只需调用 `BackgroundWorker.CancelAsync()` 方法。
我附带了一个小型演示项目,运行后看起来如下:
WPF 中的多线程
WPF 是 .NET 3.0 的新功能,我不知道有多少人正在使用它(我个人很喜欢)。需要注意的是,它仍然生成 .NET 代码,并且虽然 WPF 应用程序看起来可能与 WinForms 应用程序不同,但一些底层机制是相同的。多线程是底层思想与 WinForms 相同的领域之一。
回想一下“那个首要规则,即所有控件都必须使用创建它们的线程来访问”。嗯,WPF 中也是如此。唯一的区别是我们必须使用一个称为 `Dispatcher` 的 WPF 对象,它提供了管理线程工作项队列的服务。
我创建了一个 `BackgroundWorker`,在 WPF 中使用它完全没问题。我见过一些人花了好几天时间在 WPF 中寻找某样东西,结果才意识到他们可以简单地使用一些与 WinForms 相同的想法。
总之,这里有一个使用 `BackgroundWorker` 的 WPF 示例。我将不展示 XAML,因为它对示例来说并不重要。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.ComponentModel;
using System.Windows.Threading;
namespace Threading.UI.WPF
{
/// <summary>
/// Interaction logic for BackGroundWorker.xaml
/// </summary>
public partial class BackGroundWorkerWindow : Window
{
private BackgroundWorker worker = new BackgroundWorker();
public BackGroundWorkerWindow()
{
InitializeComponent();
//Do some work with the Background Worker that
//needs to update the UI.
//In this example we are using the System.Action delegate.
//Which encapsulates a a method that takes no params and
//returns no value.
//Action is a new in .NET 3.5
worker.DoWork += (s, e) =>
{
try
{
for (int i = 0; i < (int)e.Argument; i++)
{
if (!txtResults.CheckAccess())
{
Dispatcher.Invoke(DispatcherPriority.Send,
(Action)delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
});
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
};
}
private void btnGo_Click(object sender, RoutedEventArgs e)
{
worker.RunWorkerAsync(100);
}
}
}
这与我之前给出的 WinForms 示例非常相似,但这一次我们**必须**使用 WPF 的一个特性,那就是 `Dispatcher`。让我们更详细地看一下;重要部分是这一段。需要注意的有 `CheckAccess()` 的使用——这可以被认为是 WinForms 中 `InvokeRequired` 的等价物。另一点需要注意的是转换为 `System.Action`;这封装了一个无参数/无返回方法。除了这些细微的差别之外,在我看来,这些代码片段看起来是相同的。
WPF
if (!txtResults.CheckAccess())
{
Dispatcher.Invoke(DispatcherPriority.Send,
(Action)delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
});
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
如果我们现在将其与我为 WinForms 工作提供的第一个选项进行比较:
WinForms
if (this.InvokeRequired)
{
this.Invoke(new EventHandler(delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}));
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
我还想向你展示如何在 WPF 中使用 `ThreadPool`(这在 WinForms 中也类似,只需去掉 WPF 特定的东西,如 `Dispatcher`/`CheckAccess()`)。
ThreadPool 用法
附件中有一个使用 `ThreadPool` 的小型示例(在本系列第四部分中有详细讨论)。我包含了两个选项,它们是:
选项 1:使用 Lambda 表达式
此示例使用 lambda 表达式:
try
{
for (int i = 0; i < 10; i++)
{
//CheckAccess(), which is rather strangely marked [Browsable(false)]
//checks to see if an invoke is required
//and where i respresents the State passed to the
//WaitCallback
if (!txtResults.CheckAccess())
{
//use a lambda, which represents the WaitCallback
//required by the ThreadPool.QueueUserWorkItem() method
ThreadPool.QueueUserWorkItem(waitCB =>
{
int state = (int)waitCB;
Dispatcher.BeginInvoke(DispatcherPriority.Normal,
((Action)delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", state.ToString());
}));
}, i);
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", i.ToString());
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
这里重要的部分是如何为 `WaitCallback` 获取状态。`WaitCallback` 的 `State` 参数通常是一个对象,并且 `WaitCallback` 通常按选项 2 所示的方式进行。通过使用 lambda 表达式,我们可以一定程度上缩短这个过程。其中 `waitCB` 是实际的状态对象,它是一个 `Object`,因此必须转换为正确的类型。
选项 2:使用更明确的语法
try
{
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadProc), i);
}
}
catch (InvalidOperationException oex)
{
MessageBox.Show(oex.Message);
}
....
....
....
// This is called by the ThreadPool when the queued QueueUserWorkItem
// is run. This is slightly longer syntax than dealing with the Lambda/
// System.Action combo. But it is perhaps more readable and easier to
// follow/debug
private void ThreadProc(Object stateInfo)
{
//get the state object
int state = (int)stateInfo;
//CheckAccess(), which is rather strangely marked [Browsable(false)]
//checks to see if an invoke is required
if (!txtResults.CheckAccess())
{
Dispatcher.BeginInvoke(DispatcherPriority.Normal,
((Action)delegate
{
txtResults.Text += string.Format(
"processing {0}\r\n", state.ToString());
}));
}
else
txtResults.Text += string.Format(
"processing {0}\r\n", state.ToString());
}
虽然这种语法比 lambda 表达式示例更长,但它显然更明确。我认为这是一个权衡;如果你乐于使用 lambda 表达式,那就去用吧。
Silverlight 中的多线程
本节假定您已安装 Silverlight 2.0 BETA。
“Silverlight 2 带来了对浏览器中多线程的支持。您可以直接使用 `System.Threading.Thread` 和 `System.Threading.ThreadPool` 来启动新线程,或者使用更高级(且推荐)的 `System.ComponentModel.BackgroundWorker` 类型。后者封装了在后台执行工作(使用线程池中的线程)并根据工作进度和/或完成情况更新 UI 的概念,这意味着您可以安全地从相关事件更新 UI。
我们在 beta 1 中引入了一个鲜为人知的类型,那就是 `System.Windows.Threading.Dispatcher`。此类型允许您在 UI 线程上执行工作——当您希望直接从后台线程更新 UI 时,这非常有用。由于 Silverlight 始终只有一个 UI 线程,因此每个 Silverlight 应用程序只有一个 dispatcher 实例。此实例可以通过任何 `DependencyObject` 或 `ScriptObject` 实例的 `Dispatcher` 属性访问。一旦您获得了 dispatcher 的引用,就可以使用其 `BeginInvoke` 方法来调度您的工作。在 Silverlight 中,我们添加了一个接受 Action 的重载,这意味着您无需添加转换或其他任何内容即可帮助编译器推断您要传递的委托类型。
请注意,您可能无法通过 IntelliSense 找到 dispatcher 属性。它被标记为高级属性,因此您需要更新 VS 设置以显示高级成员,或者只需忽略 IntelliSense,并假定您的代码实际上会编译,无论 IntelliSense 显示什么。`CheckAccess` 也是如此,它实际上被标记为一个不应显示的成员。这些成员不总是可见的主要原因是它们不像 `DependencyObject` 上的其他成员那样常见。如前所述,您很可能大多数时候会使用 `BackgroundWorker`。
注意事项
有几件事情需要注意。第一件事是,我们试图防止跨线程调用,当这种调用可能不安全时。例如,我们不允许您从后台线程调用 HTML DOM 或 JavaScript 函数。原因是这两者都假定在 UI 线程上调用。打破此假设可能导致意外行为,包括浏览器崩溃。
另一件需要注意的事情是创建死锁。Silverlight 提供了 `Monitor`(在 C# 中通过 `lock` 构造函数封装)和 `ManualResetEvent` 等原语,使得创建死锁变得轻而易举。死锁将导致大多数浏览器完全挂起。虽然技术上这与某些 JavaScript 不同,但意外创建死锁通常比创建无限循环代码更容易。例如,我曾见过几个人试图通过让当前线程等待 `ManualResetEvent` 的响应回调来创建一个同步版本的 `HttpWebRequest`。然而,`HttpWebRequest` 在 UI 线程上执行其回调,这意味着您在那里已经创建了一个死锁。虽然理想情况下您应该完全避免阻塞 UI 线程,但您至少应该考虑在使用同步对象时指定超时。例如,与 C# 中的 `lock` 构造函数(`Monitor.Enter/Exit`)相比,考虑使用 `Monitor.TryEnter/Exit`,并提供合理的超时时间;并且,与其使用 `ManualResetEvent` 的无参数 `WaitOne`,不如考虑使用其重载之一。
http://www.wilcob.com/Wilco/Silverlight/threading-in-silverlight.aspx
这一切意味着,我们可以做类似这样的事情来在 Silverlight 中将线程封送到 UI 线程。在此示例中,我创建了一个新线程并使用 lambda 封送到正确的 UI 线程。第二个选项使用匿名委托;两者都可以。
var myThread = new Thread(() =>
{
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// OPTION 1 : Use lambda
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
txtResults.Dispatcher.BeginInvoke(() =>
txtResults.Text = "Updated from a non-UI thread.");
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// OPTION 2 : Use anonymous delegate
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//txtResults.Dispatcher.BeginInvoke(delegate
//{
// txtResults.Text = "Updated from a non-UI thread.";
//});
});
myThread.Start();
我们完成了
好了,这就是我这次想说的全部内容。希望您喜欢这篇文章,并希望它能帮助您创建更具响应性的 UI。我能否请求一下,如果您喜欢这篇文章,能否请您为它投票?非常感谢。
下期可能内容
如果我有足够的时间/耐心/精力,下次我们将探讨多线程的未来,即任务并行库(TPL),它目前处于 BETA 阶段。它非常复杂,但看起来相当有趣。我们将看看时间是否站在我这边。