多线程和用户界面基础知识
构建一个不稳定的应用程序
您们中的许多人可能已经熟悉从后台线程与 UI 交互的相关问题,但作为一种复习,让我们快速回顾一下。考虑下面的代码示例。
class MyForm : Form{
ListBox lbData ;
MyForm() {
InitializeComponent(); // Create form controls
Work1_(); // Call Work1_ on the current thread
}
void Work1_(){
StreamReader rdr1 = new StreamReader(@"/My Documents/DataFile.dat");
string line = rdr1.ReadLine();
while(line != null) {
lbData.Items.Add(line); // Populates the list box as expected
line = rdr1.ReadLine();
}
}
}
这是一个非常简单的示例,但是它表示了智能设备开发人员所面临的共同问题:需要用数据填充应用程序 UI,检索这些数据可能会非常耗时。在该示例中,应用程序创建了一个包含有列表框的窗体,然后调用函数 Work1_ 来用某个文件的内容填充列表框。
如果该文件很小,那么毫无意外,该应用程序会运行的非常好。但是,如果读取数据的过程所花时间过长,那么呈现给用户的应用程序可能会无响应甚至会冻结。如果将应用程序修改为从低带宽的无线连接中读取数据,应用程序的无响应性则更需要关注。
我们必须确保开发人员执行一项冗长的任务时 UI 要保持响应性的一种方法是,将该任务转移给一条后台线程。这不会使实际任务的运行速度更快,但是通过长时间运行的任务在后台运行期间允许应用程序的其他部分继续进行,它确实提供了一种响应性更好的用户体验。
通过使用 Thread 类和 ThreadStart 委托在后台线程中执行 Work1_,我们可以轻松地将应用程序修改为使用多线程。
class MyForm : Form{
ListBox lbData ;
MyForm() {
InitializeComponent(); // Create form controls
Thread t = new Thread(new ThreadStart(Work1_));
t.Start() ; // Runs Work1_ on a background thread
}
void Work1_(){
StreamReader rdr1 = new StreamReader(@"/My Documents/DataFile.dat");
string line = rdr1.ReadLine();
while(line != null) {
lbData.Items.Add(line); // This line is unstable
line = rdr1.ReadLine();
}
}
}
好消息是,长时间运行的任务现在在后台运行,因此不会延时或者冻结 UI。坏消息是,在引入多线程之前很稳定的应用程序现在好像会随机发生崩溃。实际上,程序很不稳定,所以我们不可能成功地部署它。
问题在于 Microsoft .NET 中所有的 Microsoft Windows 窗体控件都有所谓的线程关系,意思是说,它们的属性和方法只能由运行在创建该控件的同一个线程上的代码调用。对于本例的情况,lbData 是在主应用程序线程上创建的,但却是从一个后台线程调用 lbData.Items.Add 的。从后台线程调用 lbData.Items.Add 会导致数据损坏。
注有关 Windows 窗体控件和多线程需要特殊考虑的具体原因,请参阅 Chris Sells 的文章 Safe, Simple Multithreading in WinForms。本文的目标是 .NET Framework 完全版,因此该文章提供的一些解决方案不适用于 .NET Compact Framework,但 Chris 对问题的描述极为不错。
亡羊补牢
为了使我们的应用程序重新稳定,我们需要修改代码,这样所有与列表框的交互都会在主应用程序线程上发生。通过使用列表框上的 Invoke 方法,我们可以修改代码。Invoke 方法由 System.Windows.Forms.Control 基类提供,因此由所有的 Windows 窗体控件公开。Control.Invoke 方法在最初创建控件的线程上运行某个委托,允许该委托安全地与控件交互。
注.NET Framework 实现可以运行任何委托,与此不同,Control.Invoke 的 .NET Compact Framework 实现只支持 EventHandler 委托。
class MyForm : Form{
ListBox lbData ;
MyForm() {
InitializeComponent(); // Create form controls
Thread t = new Thread(new ThreadStart(Work1_));
t.Start() ; // Runs Work1_ on a background thread
}
private Queue qData = new Queue(); // Visible to all member functions on all threads
void Work1_(){
// Wrap AddItem in delegate
EventHandler eh = new EventHandler(AddItem);
StreamReader rdr1 = new StreamReader(@"/My Documents/DataFile.dat");
string line = rdr1.ReadLine();
while(line != null) {
lock(qData){ // Synchronize queue acess
qData.Enqueue(line); // Store line value in queue
}
lbData.Invoke(eh); // Transfer control to thread that created lbData
line = rdr1.ReadLine();
}
}
void AddItem(object o, EventArgs e)
{
string line = null;
lock(qData){ // Synchronize queue acess
line = (string)qData(); // Get data from queue
}
lbData.Items.Add(line); // Update list box
}
}
应用程序又稳定了。通过将修改列表框内容的代码移动到 AddItem 函数中,并将它包装到一个 EventHandler 委托中,我们已经将后台任务从它与 UI 的交互中分离出来。循环的每次传递期间,Work1_ 将从文件读取的数据放置到 qData 队列中并调用 lbData.Invoke 来运行包装 AddItem 函数的 EventHandler 委托。每次调用 lbData.Invoke 会挂起运行后台线程,直到主应用程序线程完成运行 AddItem 方法。AddItem 运行在主应用程序线程上,它从队列中提取值并将其安全地添加到列表框中。
克服局限性
对于简单的线程方案,Control.Invoke 的 .NET Compact Framework 实现很适用,但与 .NET Framework 实现相比却具有明显的局限性。
传递参数
首先,.NET Framework 提供了 Control.Invoke 的一种重载,它接受一个对象数组。用该对象数组将参数传递给执行的委托。
通过使用 .NET Framework 中的 Control.Invoke 重载,我们不再需要使用队列或者任何其他的数据结构在线程之间共享数据。数据可以只是作为委托调用的一部分而传递,明显地简化了在后台与 UI 线程之间的数据传递。
使用 Control.Invoke 重载生成下面 Work1_ 与 AddItem 的实现。
void Work1_(){
// Wrap AddItem in delegate
EventHandler eh = new EventHandler(AddItem);
StreamReader rdr1 = new StreamReader(@"/My Documents/DataFile.dat");
string line = rdr1.ReadLine();
while(line != null) {
lbData.Invoke(eh, new object[]{line, EventArgs.Empty}); // Pass to AddItem
line = rdr1.ReadLine();
}
}
// o receives the reference to line, e receives EventArgs.Empty
void AddItem(object o, EventArgs e)
{
string line = (string) o; // Upcast o
lbData.Items.Add(line); // Add to list box
}
异步执行
另一个主要不同点是,台式计算机支持 Control.BeginInvoke,这样委托可以异步执行。在我们的应用程序中,每次调用 lbData.Invoke 时,后台线程就挂起执行,直到 AddItem 方法结束。结果是,循环的每次迭代中应用程序被迫导致一个线程上下文切换。
一般情况下,我们希望将线程上下文切换降低到最低限度,因为执行它的成本相当高;首选的做法是允许操作系统选择何时发出线程上下文切换。用 .NET Framework 中的 Control.BeginInvoke 替代 Control.Invoke 调用消除了这种不得已的线程上下文切换,并允许后台线程继续处理,直到操作系统决定执行一个线程上下文切换并运行委托。
为了更新 Work1_ 方法来异步运行 AddItem 委托,我们只需使用 lbData.BeginInvoke 替代对 lbData.Invoke 的调用。
lbData.BeginInvoke(eh, new object[]{line, EventArgs.Empty});
返回页首
构建更好的类
我们在构建多线程设备应用程序时,.NET Compact Framework Control 类缺乏支持传递参数以及异步执行增加了复杂性并降低了效率。我发现这种不支持是一个特别重要的问题,因为智能设备应用程序一般都使用多线程。同时,智能设备的资源往往有限,这使得简单、有效的多线程非常重要。
因为我们没有使用 .NET Compact Framework 源代码,因此我们不能合理地向 .NET Compact Framework Control 类添加对参数和异步委托执行的支持。但是,我们可以构建一个提供这些功能的新类。我将该类称为 UISafeInvoker。
注本月专栏随附的下载包括 UISafeInvoker 的完整源代码以及一个演示其用法的应用程序。
一言以蔽之,UISafeInvoker 是一个与线程有关的 .NET Compact Framework 类,它提供了行为与 .NET Framework Control 类的 Invoke 与 BeginInvoke 方法类似的 Invoke 与 BeginInvoke 方法。虽然不是 Control 类的一部分,但 UISafeInvoker.Invoke 与 UISafeInvoker.BeginInvoke 方法的使用却非常简单。
注本专栏的作者提供了 UISafeInvoker 的代码示例。Microsoft 不提供对这些代码的支持,对该类的使用也没有明确或暗示的任何担保。
与可以执行任何种类委托的台式机的 Control.Invoke 与 BeginInvoke 方法不同,UISafeInvoker 和 .NET Compact Framework Control.Invoke 与 BeginInvoke 方法一样,只支持 EventHandler 委托。因为 UISafeInvoker 仅支持一种委托类型,因此不需要使用对象数组来传递参数。相反,Invoke 与 BeginInvoke 接受直接传递给 EventHandler 委托中对应参数的对象和 EventArgs 参数。这里是每种方法的签名。
void Invoke(EventHandler eh, object obj, EventArgs eArgs);
IAsyncResult BeginInvoke(EventHandler eh, object obj, EventArgs eArgs);
使用 UISafeInvoker 就是简单地在 Form 类中声明一个引用,并在 Form 构造函数中创建一个实例。创建之后,UISafeInvoker 就内部跟踪创建它的线程,因此 Invoke 和 BeginInvoke 方法可以在同一个线程上运行期望的委托。作为 Form 构造函数的一部分而创建 UISafeInvoker,并且是在与所有窗体控件相同的线程上创建;因此,Invoke 或者 BeginInvoke 方法运行的任何委托都可以安全地更新 UI 控件。
这里是使用 UISafeInvoker 更新后的测试应用程序。
class MyForm : Form{
ListBox lbData ;
UISafeInvoker invoker ; // Declare UISafeInvoker
MyForm() {
InitializeComponent();
invoker = new UISafeInvoker(); // Create UISafeInvoker on main UI thread
Thread t = new Thread(new ThreadStart(Work1_));
t.Start() ; // Runs Work1_ on a background thread
}
void Work1_(){
// Wrap AddItem in delegate
EventHandler eh = new EventHandler(AddItem);
StreamReader rdr1 = new StreamReader(@"/My Documents/DataFile.dat");
string line = rdr1.ReadLine();
while(line != null) {
invoker.BeginInvoke(eh, line, EventArgs.Empty); // Pass to AddItem
line = rdr1.ReadLine();
}
}
// o receives the reference to line, e receives EventArgs.Empty
void AddItem(object o, EventArgs e)
{
string line = (string) o; // Upcast o
lbData.Items.Add(line); // Add to Listbox
}
}
对于 UISafeInvoker,我们的 .NET Compact Framework 应用程序已经克服了 .NET Compact Framework Control.Invoke 方法的局限性,现在可以提供简单、有效的线程内通信了。这种通信类似于 .NET Framework 中的通信,不需要额外的数据结构或者复杂的编码。
使用窗口消息
在内部,UISafeInvoker 非常简单,因为它仅完成两件事情:跟踪创建它的线程并提供一种在线程之间传输数据的可靠方法。
这种解决方案 — 虽然听起来有些陈旧 — 是基于窗口消息的。Windows 操作系统创建的所有窗口都有一个消息队列。应用程序可以通过使用 Microsoft Win32_ SDK 函数 SendMessage 和 PostMessage 将消息放置到该队列中。这些函数允许应用程序传递一个标识执行操作的整数标志和两个过去被称为 WParam 与 LParam 的消息定义数据值。
除了有一个主要的不同点,SendMessage 和 PostMessage 函数基本上是一样的。SendMessage 将消息放置到窗口消息队列中,并阻止消息直到窗口完成对它的处理。PostMessage 将消息放置到窗口消息队列中并立即返回。SendMessage 和 PostMessage 可以由任何线程调用,但是窗口总是在创建该窗口的线程上处理消息。这种行为隐含地解决了线程跟踪和在线程间传输数据的问题。
所有的 UI 控件都是窗口,但是应用程序也可以创建隐藏的窗口,它们能够处理消息,但在屏幕上不会呈现任何可见物。MessageWindow 类向 .NET Compact Framework 公开了实现隐藏窗口的功能。
注如果您对消息队列和消息处理如何工作的细节感兴趣,请参阅 About Messages and Message Queues。有关 MessageWindow 类的更多信息,请参阅 Compact Framework Unique Classes。
从根本上说,UISafeInvoker 只是封装了一个隐藏窗口和对 SendMessage 和 PostMessage 的调用。这里是 UISafeInvoker 实现的框架。
public class UISafeInvoker : MessageWindow
{
const int WM_USER = 1024; // Traditional start of application-defined messages
const int WM_INVOKEMETHOD = WM_USER + 1; // Our special message
// Handle window message processing
protected override void WndProc(ref Message m)
{
base.WndProc (ref m);
if (m.Msg == WM_INVOKEMETHOD)
{
// Get data from message
// Run delegate
}
}
// Instigate delegate execution and wait for completion
public void Invoke(EventHandler eh, ...)
{
Message m = Message.Create(this.Hwnd,
WM_INVOKEMETHOD, ...);
MessageWindow.SendMessage(ref m);
}
// Instigate delegate execution and return immediately
public void BeginInvoke(EventHandler eh, ...)
{
Message m = Message.Create(this.Hwnd,
WM_INVOKEMETHOD, ...);
MessageWindow.PostMessage(ref m);
}
}
Invoke 和 BeginInvoke 方法都向包含有关运行委托信息的隐藏窗口发送消息。由于 Invoke 方法使用 SendMessage ,因此是同步运行。BeginInvoke 提供异步执行,因为 PostMessage 将消息放置到窗口消息队列中并立即返回。
每次隐藏窗口接收到一条消息就会调用 WndProc 方法,它负责运行期望的委托。由于它是作为窗口消息处理的一部分而调用的,因此创建该窗口的线程总是执行 WndProc 方法。因为总是在同一个线程上运行代码,所以它运行的委托可以安全地与在同一个线程上创建的 UI 控件进行交互。
在线程之间传递数据
UISafeInvoker 实现的一个复杂方面就是将数据从 Invoke 与 BeginInvoke 方法传递到 WndProc 方法。为了使用 SendMessage 或者 PostMessage 将数据发送给隐藏窗口,我们首先需要定义一个类,该类包含将引用存储到 EventHandler 委托以及对象和 EventHandler 委托期望的 EventArgs 参数等必需信息。
class InvokerData{
public EventHandler eventHandler;
public object obj;
public EventArgs eventArgs;
}
然后,Invoke 和 BeginInvoke 实现可以填充 InvokerData 类的一个实例,并使用 SendMessage 或者 PostMessage 将这些信息发送给 WndProc 方法。
问题是,InvokerData 是一个存储在 .NET Compact Framework 托管内存空间中的 .NET 类。SendMessage 和 PostMessage 的实现以及窗口消息队列由底层的 Windows 操作系统来处理,操作系统位于 .NET Compact Framework 托管内存空间之外。
请记住,.NET Compact Framework 主动地跟踪和管理着所有应用程序对象的内存。它可以将对象在内存中从一个位置移动到另一个位置,并且它删除没有活动 .NET 引用指向的任何对象。
我们使用 SendMessage 和 PostMessage 的复杂性在于,如果我们将 InvokerData 对象引用作为参数传递给这些函数中的一个,那么该对象的当前地址将被直接复制到隐藏窗口消息队列中。Windows 操作系统实现的窗口消息队列位于 .NET Compact Framework 托管内存空间之外;因此,.NET Compact Framework 不知道应用程序仍旧保存着 InvokerData 对象地址的事实,并计划再次使用它。
当隐藏窗口最终处理消息时,地址从窗口消息队列中复制到由 UISafeInvokerWndProc 函数接收的 .NET Compact Framework Message 结构中。在地址被复制到消息队列中并传递回 WndProc 函数期间,.NET Compact Framework 垃圾回收器可能会移动甚至可能会删除 InvokerData 对象。
为了避免这种潜在的危险情况,我们可以只要求 .NET Compact Framework 为我们提供一种表示 InvokerData 对象安全传输到 .NET 环境之外并且我们返回时仍旧有效的标记。为此,我们可以使用 GCHandle 结构。
GCHandle 结构可以方便地创建一种表示 .NET 对象被安全传递到 .NET 环境之外并且稍后可以用来找到同一个对象的标记。拥有 GCHandle 结构的 .NET 对象可以防止该对象被作为垃圾回收。GCHandle 结构还可以安全地来回转换成 IntPtr,使得使用 SendMessage 和 PostMessage 非常简单。
既然我们有了 GCHandle,因此这里是完整的 UISafeInvoker.Invoke 方法;UISafeInvoker.BeginInvoke 除了使用 PostMessage 之外,基本上是相同的。
public void Invoke(EventHandler eh, object obj, EventArgs e){
InvokerData d = new InvokerData() ;
d.eventHandler eh;
d.obj = sender;
d.eventArgs = e;
GCHandle dataHandle = GCHandle.Alloc(d); // Get token to InvokerData
IntPtr iPtr = (IntPtr) dataHandle; // Cast to IntPtr
Message m = Message.Create(this.Hwnd, WM_INVOKEMETHOD, IntPtr.Zero, iPtr);
MessageWindow.SendMessage(ref m);
}
我们的 WndProc 实现然后就可以使用包含在接收消息内的 GCHandle 结构来检索 InvokerData 实例。
注在不再需要该结构时,WndProc 函数必须调用 GCHandle 结构上的 Free 方法。如果没有调用 Free 方法,.NET Compact Framework 内存管理器就无法知道我们不再需要 GCHandle,并且将不能清除相关的对象。
protected override void WndProc(ref Message m)
{
base.WndProc (ref m);
if (m.Msg == WM_INVOKEMETHOD) {
GCHandle h = (GCHandle) m.LParam;// Cast IntPtr back to GCHandle
InvokerData d = (InvokerData) h.Target; // Get the InvokerData instance
h.Free(); // Indicate that we are finished with GCHandle
d.eventHandler(d.obj, d.eventArgs); // Call the delegate passing the parameters
}
}