C# 工作线程启动工具包






4.49/5 (32投票s)
2004 年 6 月 17 日
17分钟阅读

167289

2589
本文介绍了一种用于工作线程和基于窗体的程序的简单模式。
引言
在此,我提供了一个小的类库和一套示例,用于开发基于 Windows.Forms
的多线程程序。本文关注三个问题:
- 工作线程和主线程(
Form
运行所在的线程)之间的同步数据共享。 - 干净地停止线程。
- 安全地从工作线程向 UI 触发事件。
这里的示例都使用共享数据方法在工作线程和侦听器(在这些示例中是 Form
)之间进行通信。在此模式下,工作线程将结果写入共享位置,向侦听器发出信号,然后继续其工作。侦听器在稍后某个时间响应信号,并在共享位置查找任何可能感兴趣的内容。由于数据流基本是单向的,从工作线程到所有者或侦听器,此模式类似于使用邮箱的生产者-消费者模型。示例演示了如何同步对共享数据的访问。
如果您愿意(邪恶地)可以将需要花费数小时才能完成的代码放入 OnClick
事件处理程序中。这样的设计将使 UI 在任务期间无响应,尽管这是一个糟糕的设计,但并非错误,即它会起作用。在这种糟糕的设计下,用户将不得不按 Ctrl+Alt+Del 来停止您的程序。用户对这种设计产生的沮丧将给您带来一生的坏因缘。将长任务移入工作线程的一个好处是 UI 可以包含一个“停止”按钮。将任务放在单独的线程中,您就不会杀死在主线程中运行的 UI,并且在线程运行时,“停止”按钮也可以操作。(拜托,不要有人评论说使用 PeekMessage
或其他消息队列的怪物。)在示例中,我添加了一个 RunStopButton
控件,您可能会在您的项目中发现它很有用。
随着后台任务现在在工作线程中执行,并在窗体上有一个“停止”按钮,我们**可以**停止工作线程。停止工作线程有两种方法:协作式或强制式。示例展示了一种协作式停止线程的常用方法,该方法使用两个信号:一个来自所有者请求线程停止,另一个来自线程确认已停止。强制终止线程的方法是 .NET API 提供的 Abort()
方法。框架提供了一种让线程检测 Abort
并尝试干净退出的能力,但我仍然认为这种方法只是一个备用方案,用于在线程拒绝合作时使用。
将 WinForms 与线程结合使用的主要障碍是 UI 的一个关键问题:只有创建窗体的线程才能使用该窗体。通常,主线程会创建您的所有窗体,这意味着只有主线程可以访问任何窗体。乍一看,这似乎违背了线程的主要用途之一:通常您会将代码中执行时间长的部分从 UI 分离到工作线程中,以保持 UI 的响应。解决此问题关键在于如何从一个线程向主线程触发事件。有其他文章讨论了此细节,我在这里只是实现了一个可重用类。
注意:如果您尝试从另一个线程访问 Form
,您可能会**幸运**地看到您的代码按预期工作,这与文档中声明的排除情况相反。如果您是如此幸运的人,请放心,您的运气会在最不合适的时候耗尽(参见墨菲定律:任何可能出错的事情都会出错,并且会在最糟糕的时刻发生)。
类库
H5ThreadLib 项目构建了一个用于多线程程序的类库。Hangar5.Threading
命名空间包含作为所有示例基础的少数类。该库主要分为两个部分:线程和控制器。下面的 UML 图概述了第一部分。
ThreadBase
是一个抽象类,用作所有线程的基础,并使用标准的事件/委托来发出更改或进度的信号。StopRequestException
是一个自定义异常,用于指示协作式请求线程停止。SharedData
是一个用于提供同步访问的基类。ThreadBaseData
类为 ThreadBase
提供了一些标准的共享数据成员。
ThreadBase
要使用此库创建工作线程,您需要创建一个从 ThreadBase
派生的类。要实现您的类,只需实现 Run()
方法,类似于 Java 的 Thread
类。基类在 RunOuter
中实现了标准的启动/停止行为,这是下一节中讨论的控制器所必需的。为了良好行为,您的 Run()
方法只需执行两件事:
- 在发生您希望其他对象了解的更改时调用
FireStatusTick
。 - 使用
Wait()
而不是Sleep()
。
线程仅使用一个事件用于所有信号。该事件包含的数据非常少,主要是线程当前的运行/停止状态。它旨在仅仅通知 SharedData
中有可用更改,并且可能有人想来查看。因此,此事件无需为不同的应用程序重新定义。
ThreadBase
类中的 Wait
方法是协作式停止线程的关键。它应该在任何会使用 Sleep()
的地方使用,并且应分散在长循环中。该方法实现为在 ManualResetEvent
上调用 WaitOne
。
protected void Wait( int nMs ) {
if( basedata.signalStopRequest.WaitOne( nMs, false ) ) {
throw new StopRequestException();
}
return;
}
signalStopRequest
是控制器将在某个其他方请求线程停止时设置的事件。这里的用法与典型情况相反,因为我们主要期望超时限制将被达到,因为事件未被设置。因此,WaitOne
调用将阻塞超时周期,然后返回 false
。如果在方法进入时设置了事件,或者在 WaitOne
调用期间任何时候设置了事件,则会抛出异常来中断工作线程。
如果您的线程代码只是一个巨大的计算循环,您可以分散调用 Wait()
来允许线程被中断。例如:
const int chunk = 1000;
int i, j;
i = 0;
while( i < 1000000 ) {
Wait(0);
for( j=0; j<chunk; j++ ) {
/* do something */
i++;
}
}
您需要自己确定检查的频率。决策的基础是了解用户的耐心程度。即使您不放置任何 Wait()
调用,强制终止方法仍然能够停止您的线程。
基类包含 StopRequestException
的标准行为,因此您的代码不必包含该异常的 catch
子句。如果您的代码在抛出 StopRequestException
时有任何特殊操作,它可以捕获此异常并需要重新 throw
它。示例对此进行了演示。
仅使用 ThreadBase
类只解决了一部分问题:停止线程已部分实现,并且通知机制的开始部分已实现。但是这里抛出的事件存在引言中讨论的问题。
SharedData
在我本文使用的模式中,所有可能与其他线程共享的数据成员都分解到一个单独的类中。您可以将它们写成线程类本身具有公共访问器的成员。我使用一个派生自 SharedData
的类,主要是为了将注意力集中在可能共享且需要同步的数据成员上。任何将在工作线程和其他线程之间共享的成员都放置在此对象中,并且需要:
- 在类成员的帮助下实现同步访问。
- 进行无情检查,以确保它们不需要同步。
同步访问非常简单,很难想象您会使用 b) 的情况。共享数据成员的单独类也提供了有用的设计灵活性。工作线程的所有者可以在工作线程完成后保留共享数据。您可以将其视为老师给学生出测验,在课程结束时,学生交回测验并离开,老师仍然拥有测验。
对于您编写的每个线程类,都需要一个派生自 SharedData
的匹配线程数据类。线程数据类可能只包含属性和访问器方法。要同步对数据的访问,请使用一对匹配的调用:getter 方法使用 ReaderLock()
/ReaderRelease()
,setter 方法使用 WriterLock()
/WriterRelease()
。例如:
public class ThreadXData : SharedData {
public string Message {
get {
string sRet;
ReadLock();
sRet = sMsg;
ReadRelease();
return sRet;
}
set {
WriteLock();
sMsg = value;
WriteRelease();
return;
}
}
protected string sMsg = "";
}
展示了一个带有单个字符串属性的简单线程数据类。要获取字符串,请锁定它,复制一份,然后解锁。读取锁会在复制字符串时阻止任何写入操作。要设置字符串,请锁定它,写入值,然后解锁。由于此模式很常见,基类提供了方便的函数,可以减少输入量:
public class ThreadXData : SharedData {
public int Complete {
get {
return LockedCopy( ref nPercent );
}
set {
LockedSet( ref nPercent, ref value );
}
}
protected int nPercent = 0;
}
示例演示了几种其他类型的访问器函数以及相同的锁定/释放用法。
SharedData
类的一个关键简洁之处在于,它只使用一个互斥锁/锁来访问所有成员。多线程代码中的一个常见死锁示例是阻塞在两个或多个互斥锁上:一个线程锁定 B 并被中断,第二个线程锁定 A 然后尝试锁定 B 并被阻塞,第一个线程继续并尝试锁定 A,导致死锁(这是复制构造函数问题)。通过仅在 SharedData
中使用一个锁,您需要考虑的复杂情况大大减少。
请注意,这种对整个 SharedData
类的刻意的粗粒度访问意味着所有成员都会被一次性锁定。因此,如果工作线程正在写入变量 x
,所有者就无法读取变量 y
。您可能会担心线程会不必要地阻塞等待访问数据成员,从而导致程序性能下降。我认为在此模式下,这种情况的概率较低,原因有两个。首先,这些示例讨论的是用户界面更新,而不是游戏开发。屏幕更新的频率没有理由高于每秒几次,甚至更低。持有锁的时长应该很短。数据成员访问器中您所做的只是赋值和返回。对于 GHz 处理器,赋值 x=bla
应该只需要微秒的一部分。将 UI 更新的频率与持有锁的时间间隔进行比较,两个线程发生冲突的概率很低。其次,工作线程和 UI 的性质往往主要是生产者消费者模式。事件会松散地序列化操作,使得工作线程写入一些结果,UI 随后来读取结果。对于这两种情况都适用的应用程序,单个锁的性能会很好。
希望 SharedData
类和示例表明,提供对数据的同步访问相当简单。为了完整起见,该库还需要另一部分来处理事件问题。
控制器
下图显示了提供的两个控制器类,用于控制线程和处理来自线程的事件。
这两个类可以控制任何派生自 ThreadBase
的类。它们提供典型的 Start()
方法和几个停止方法:
Stop
- 使用基类提供的协作式线程停止方法。Abort
- 使用 .NET APIAbort
方法强制终止线程。Terminate
- 通过先尝试Stop
,然后如果工作线程不合作,则尝试Abort
来停止线程。
控制器有趣的作用是将事件从线程转发到其他目的地,称为侦听器。在此库中,侦听器必须是 Control
,通常是 Form
。SingleSink 控制器只接受一个侦听器,MultiSink 控制器允许任意数量的侦听器。侦听器通过提供一个 Form
和一个委托来向控制器注册自己。在这两种情况下,控制器都会接收来自工作线程的事件并将其转发给侦听器。
BeginInvoke
尽管您可能想这样做,但您无法通过标准的事件/委托方法让线程触发事件。此限制由 Windows.Forms
框架强制执行,该框架要求只有创建 Form
的线程才能使用它。此限制在 C++ 和 MFC 应用程序中也存在,因此并非新事物。例如,如果您要从工作线程向 Form
中的委托触发事件以更改某些文本,那么该委托将在工作线程的上下文中执行。BeginInvoke()
是解决此问题的方法,因为它不会立即执行委托,而是会在接收者的消息队列中放置一条消息。然后,消息接收者稍后会在其自己的线程上下文中执行该委托。对于熟悉 Win32 API 的人来说,此解决方案与使用 PostMessage
将事件放入另一个窗口的消息队列相同。两个控制器中的 FireStatusTick
方法已连接到工作线程中的状态 tick 事件,并在 ControllerBase
的 DoEvent()
方法中将事件转发给侦听器。
为什么不使用 Invoke?
您可以使用 Invoke
。我见过其他文章建议您可以使用 Invoke()
,并且委托将在创建该委托的线程的相同上下文中执行。您可以通过编辑 DoEvent()
来探索此选项。事实上,如果您设置断点,您将看到 Invoke
会导致所需的上下文切换以正确执行委托。再次回顾标准的 Win32 API,这类似于 SendMessage
,因为委托会立即执行,并且有返回值。对于本文使用的模式,由于消息是单向信号,因此返回值没有用。而且,Invoke
提供的硬同步没有任何好处。
通过将事件从线程链接到用户界面,控制器类完成了引言中概述的三个问题的最后一个。现在该看看它们如何协同工作了。
全局概览
这些类之间的关联如下图所示。
哎呀...
等等,这很简单。阴影类是您为应用程序编写的内容。MyControl
,通常是 Form
,是 UI 元素,它将是线程结果的某个方面的侦听器。MyThread
包含您应用程序的工作线程,而 MyThreadData
是匹配的 SharedData
。UI 元素包含三个主要活动类别:它使用其创建的 ControllerBase
类型来控制线程(主要是启动和停止),它通过 StatusTickHandler
s 接收来自线程的信号,并通过 MyThreadData
共享数据。示例包含如何执行此操作的样本。
示例
够了,别看图了(顺便说一句,所有图都是用 Dia 制作的),现在来点能运行的。源代码下载包含四个示例应用程序,以演示类库的各个方面。
示例应用程序包含一个“运行/停止”按钮和一个“忙碌”按钮。“忙碌”按钮用于模拟会使 UI 无响应约一秒钟的操作。模拟是通过 Sleep(1000)
完成的,这在实际应用程序中永远不会出现。诸如保存文件之类的实际操作可能会使主线程忙碌一秒钟。
测试 0 - 基于计时器的轮询
此示例不向侦听器触发事件,而是依赖主线程以固定间隔轮询结果。这种方法可以被轻描淡写地描述为:
Boss: Worker bee, here is a task for you, go do it.
Just check off these boxes
on the white board as you finish each part.
Worker bee: [shuffles off to cube]
[time passes]
Worker bee: [checks off first box]
[time passes]
Boss: [looks at white board sees 1 box checked]Ahh good
[time passes]
Boss: [looks at white board sees 1 box checked]mutter mutter
[one second latter]
Worker bee: [checks off second box - Boss does not see]
[time passes]
Worker bee: [checks off third box]
Boss: [looks at white board sees 3 boxes checked]
Wow this is flying. I'm gonna get a bonus.
[time passes]
Boss: [looks at white board sees 3 boxes checked]What? Nothing happened.
I got out of my chair just to see nothing happening.
(bellows)Worker bee! When will box 4 be done?
Worker bee: I just finished it, if you had waited just 1 second
more, you would have seen it.
此示例启动一个工作线程,该线程创建一个包含多个部分或步骤的报告。工作线程将报告的累积部分作为 SharedData
提供。所有者 Form
设置一个计时器,并从工作线程轮询累积结果。
运行此示例时,您将看到同步的缺乏。此示例中的时间间隔被强制设置为演示屏幕更新与工作线程的活动不同步。您应该看到工作线程执行的几个步骤未显示。即使中间的更改未准确显示,文本框中显示的最终结果也是完整的。可以通过增加 UI 轮询的频率来解决此审美 UI 问题。虽然该方法可以使 UI 响应良好,但应用程序开始浪费时间频繁更新未更改的数据屏幕。
虽然可能不优雅,但这种方法是直接且安全的。为了改进,下一个示例使用来自工作线程的通知,指示何时发生更改。该实现将允许 UI 仅在需要更改时更新,并将收紧更改发生时与更改在 UI 中可见时之间的同步。
测试 1 - 集成
此程序与测试 0 相同,只是它使用了库的所有功能。您应该看到报告定期更新。尽管如此,如果您按下“忙碌”按钮,UI 将停止更新一秒钟。
测试 2 - 多接收器示例
此示例演示了一个使用多个接收器处理线程状态 tick 的应用程序。当线程运行时,一个无模式对话框会弹出并显示一个进度条。当线程完成时,对话框会隐藏。
此应用程序演示了将数据与信号分离的一个优点。可以将应用程序编写为将所有结果数据放入事件参数中。但此应用程序显示了这种方法造成的低效率。每个 Form
在事件中接收的数据大多是无关紧要的。使用此库中的模式,Form
只从 1 个共享对象中获取它们需要的内容。
测试 3 - 数据喷泉
看待我在此使用的模式的一种方式就像一个通信连接,其中我们的 SharedData
是带内数据,事件是带外控制数据。这就是我心中所想的模型,它使我不必将工作线程产生的所有数据放入事件参数中。我喜欢将状态/SharedData
与路径/事件分开。乍一看,产生连续结果流的工作线程可能不适合此模式。遥测数据将是一个困难的例子,其中工作线程以固定的间隔产生数据块。您可能会倾向于将数据块打包到 EventArgs
中。
测试 3 展示了我偏好的解决方案,该解决方案使用 SharedData
中的 Queue 来存储工作线程产生的块状结果。而不是通过 EventArgs
将整个数据块触发给侦听器,它存储结果并仅向侦听器发出数据可用的信号。Queue 确保侦听器将按生成顺序接收一个或多个块。
摘要
我在某个地方读到过一句话,关于线程的警告说:“如果它很难,说明你做错了”。对我来说,这句话说得很有道理,也许本文中的示例和库支持了这句话。我 long time 以来一直在 C++ 程序中使用多线程来简化设计。虽然有些人似乎警告开发人员远离线程,但我会鼓励您,并声称多线程程序比完成相同功能的非线程程序更简单。这是我的第一个 C# 项目,也是我将 long time 以来在 C++ 中使用的模式翻译过来的结果。许多零散的部分在其他文章、这里以及 MSDN 上都有深入的讨论。我主要只是想把所有东西都整合起来。希望这个库和示例能为像我一样的其他初学者提供一个好的入门工具包。