多线程实用指南 - 第二部分






4.96/5 (112投票s)
更多使用多线程的实际情况!
延长...
本文继续《多线程实用指南》。请 阅读第一部分,其中我解释了多线程的基础知识、同步/异步调用、事件原语以及锁定/解锁等。
对于首次访问此页面且未阅读或了解第一部分的读者,请注意,我不会解释如何使用多线程,也不会解释语法,更不会解释多线程的工作原理。您可以在其他地方阅读这些内容。我的主要重点是何时、如何以及为何使用多线程。
与第一部分一样,我将提供多线程的实际示例。因此,本文可能不会遵循探索多线程不同编程原语(事件、监视器、互斥锁、线程间数据传递、锁、原子操作等)的常规顺序。您不需要在理论上或实践上了解这些术语。我会解释第一次遇到的术语。
实际示例
在上篇文章中,我介绍了一个程序执行一些耗时处理,并允许用户停止或挂起/恢复处理的示例。我将介绍更多实际编程场景,其中将利用多线程。
本文的内容中的代码示例使用伪代码语言。使用两种不同的语言会打断不看其他语言的读者的思路。此外,本文篇幅较大;用多种语言提供实际示例会使其更长。随附的项目套件包含您所用编程语言的完整源代码。
一个卑微的请求给所有读者:请不要一次性读完这篇文章——阅读,完全理解,查看附带的代码、代码注释,随意修改代码,并至少运行您的更改 10-15 次——每一个项目/示例。一旦您对某个特定示例有了扎实的理解,才继续前进。本文会一直在这里!
多线程编程不是学习线程基础知识,了解不同的线程原语是什么,操作系统如何调度它们等。它是关于实践、编程和通过动手经验来学习。请耐心点——给这件很棒的事情——多线程——一些时间!
前四个示例本身并非多线程,而是展示了如何使用多线程来实现它们。其余示例利用了多线程的真正光辉。每个示例都遵循项目套件中的项目详细信息。
[•] 实践示例 4:应用程序的单个实例
您希望用户只运行一个应用程序实例。嗯,有很多种方法可以做到这一点。
解决方案 1:使用 FindWindow
API 按标题查找正在运行的应用程序。例如,如果您的应用程序标题是“Advanced MP3 Tag Editor”,您可以将此字符串传递给 API,它将返回目标窗口的句柄。
如果以下情况,这可能无效:
- 用户创建一个与您的应用程序标题同名的文件夹,在 Windows Explorer 中浏览到该文件夹,则
FindWindow
将返回 Explorer 的窗口句柄! - 使用一些桌面调整工具,您的应用程序窗口可能被隐藏(例如,在任务栏中),或者位于另一个虚拟桌面上!
- 您的应用程序正处于启动阶段,并且在实际显示之前,用户(像我一样不耐烦的人!)再次启动了应用程序。哇!
FindWindow
失败,两个(或更多)实例现在正在运行。
解决方案 2:使用相同的 API 按窗口类(参见 MSDN 中的 RegisterClass
)查找窗口。这对于上述第 1 点会成功,但对于其他点会失败。它还要求您使用某个唯一名称注册一个窗口类,然后创建并显示具有该名称的窗口;或者知道预定义类使用的类名。正如您所料,另一个应用程序可能正在使用相同的预定义窗口类(例如,一个具有“#32770”作为类名的对话框)。
如果您的应用程序是控制台应用程序或没有任何窗口,则上述两种解决方案都将无效。此外,如 Windows Vista 及更高版本中的权限和完整性级别,可能会导致问题。本文太短,无法讨论“如何”实现。
解决方案 3:枚举所有正在运行的进程,并查找您的应用程序是否正在运行。您只需匹配进程名称即可找到您的进程。嗯,任何人都可以将任何其他应用程序(例如 notepad.exe)重命名为您的可执行文件名称(例如,advmp3.exe)并运行它。是的,您明白了!
另一个问题是多用户登录(通过快速用户切换或远程桌面)。您的应用程序可能在另一个桌面运行,但以您的用户凭据运行,反之亦然——您(当前用户)可能在另一个用户的令牌中运行了您的应用程序!这取决于您的应用程序如何在这种情况下做出反应;但 FindWindow
完全被排除在外!
解决方案 4:这是一个高级解决方案,不应使用,我没有测试/阅读过此技术以了解上述几点。使用 Visual C++ 的链接器开关,您可以使一个全局变量在所有正在运行的进程实例之间共享。不是一个可靠的解决方案!不知道其他编译器的情况。
最好的解决方案是使用命名互斥锁。
什么是互斥锁?
互斥锁是一种线程同步原语,用于控制对共享资源的访问(变量、数组、文件对象、设备上下文、窗口句柄、套接字或任何其他资源)。如第一部分所述,同步原语不是您想要控制访问的资源,而是用于控制同时访问的锁。
互斥锁
- 可以命名或不命名。如果命名,则可以在进程之间使用(忽略继承!)。
- 如果一个线程获取了互斥锁,另一个线程(或另一个进程中的线程)将无法锁定/获得互斥锁。另一个线程必须等待互斥锁变为空闲/解锁。
- 只有一个线程可以独占访问互斥锁。获取锁意味着获得独占访问。
- 与其他一些原语不同,您还可以为获取互斥锁指定一个时间限制。例如,您可以说“等待 5 秒”。您还可以指定无限超时。根据互斥锁-等待函数的返回值,您可以找到您是否获得了互斥锁的访问权限并据此采取行动。
- 成功获取互斥锁会使互斥锁处于非信号状态。释放锁会将状态更改为信号状态。阅读本文的第一部分,以获得对信号和非信号状态的良好理解。
您可能想知道这与防止应用程序运行两次有什么关系?嗯,在这个系列中,我首先介绍了互斥锁,所以我必须详细介绍它!
为了防止另一个实例,您需要在应用程序启动时执行以下操作(即在 main
中)
- (尝试) 创建一个命名互斥锁。名称应是唯一的。例如“advmp3$mutex-singleinstance”。您可以使用 GUIDGEN 工具来获取绝对唯一的互斥锁名称。
- 如果创建成功,则此实例是应用程序的第一个实例。
- 如果创建失败,则另一个实例已在运行。根据您的应用程序,您可以
- 将焦点设置到现有应用程序(如果它被隐藏则显示)。
- 仅显示一条消息框,说明另一个实例已在运行。
- 不给出任何视觉提示,然后退出。
- 在所有情况下,您都必须退出新创建的实例。
在这里,我展示了来自Process Explorer 的截图。它显示了 Windows Media Player 使用的互斥锁,以“'OtherInstanceMutex'”结尾。以管理员模式运行 Process Explorer,关闭此互斥锁的句柄,您就可以运行另一个 Windows Media Player 实例并收听多首歌曲了!
这是否意味着任何人都可以删除(关闭句柄)互斥锁,从而危及应用程序的内部状态?嗯,是的——如果您使用默认的安全属性创建互斥锁(或任何其他内核对象)(还记得 CreateMutex
、CreateProcess
的 LPSECURITY_ATTRIBUTES
参数,您总是将其设置为 NULL
?)并且有人拥有管理员权限!.NET Framework 还有一个选项可以为内核对象设置安全属性。您可以设置安全属性来拒绝/允许不同用户的操作,但描述它们不是本文的范畴!
互斥锁的名称由操作系统控制和存储。互斥锁的名称以及互斥锁本身将在进程删除互斥锁或进程退出(正常或异常)时由系统删除。对于所有内核对象,操作系统都会进行跟踪,并且只有当引用计数为零时才会实际删除。互斥锁是内核对象,因此它们可以在进程之间共享。关键部分(我们稍后会看到)是用户对象,它们不能在进程之间共享。使用内核对象需要从用户模式到内核模式的往返,因此它们比用户模式同步对象慢。
此外,如果您只想要应用程序的一个实例,无论登录用户数量如何,都必须在互斥锁名称前加上“Global\”。否则,它将被视为本地命名互斥锁。有关更多详细信息,请参阅 MSDN。
伪代码
if ( OpenMutex ("MyMp3Application_SingleInstance") == true)
{
// Notify the user that another instance is running
// Exit from this instance immediately.
}
else
{
CreateNamedMutex("MyMp3Application_SingleInstance"); // Assume success. Keep mutex open
}
下表列出了不同编程环境中的互斥锁编程元素
内容 / 位置 | Windows API | MFC 库 | .NET Framework |
互斥锁数据类型 | HANDLE |
CMutex 类 |
Mutex 类 |
创建互斥锁 | CreateMutex |
CMutex 构造函数 |
new Mutex |
锁定/获取互斥锁 | WaitForSingleObject |
CMutex::Lock 方法 |
WaitOne 方法 |
解锁/释放互斥锁 | ReleaseMutex |
CMutex::Unlock 方法 |
Mutex.ReleaseMutex 方法 |
等待多个互斥锁(全部,一个) | WaitForMultipleObects ,bWaitAll 指定全部/一个谓词 |
使用多个 CMutex 对象构造 CMultiLock ,然后调用 Lock 并指定所需的等待谓词。 |
WaitAll 和 WaitAny 方法 |
项目详情
- MFC 项目:SingleInstance_MFC
- .NET 项目:SingleInstance_NET
[•] 实践示例 5:后台耗时操作
您一定用过 Windows 计算器,并向计算器输入了一些需要很长时间才能处理的内容。例如计算 100,000! 的阶乘。如果您尝试计算上述值的阶乘,您会遇到以下消息
如何实现?
在处理线程开始计算(或执行任何其他操作,如代码编译)之前,它会启动另一个线程来监视此线程。此外,在启动另一个线程(监视器线程)之前,它会创建一个互斥锁对象并锁定它。另一个线程等待同一个互斥锁,并指定一定时间间隔(例如 5 秒)。现在两个线程都在运行。(您也可以使用事件对象而不是互斥锁。)
如果在指定时间内(5 秒)完成计算,它会立即释放锁。这将把锁交给监视器线程。如果它在指定的时间限制内未完成处理,它将继续处理并且不会释放锁。
监视器线程的等待调用无论如何都会在指定时间内(此处为 5 秒)返回。然后它会检查等待调用的返回值。如果发现无法获取锁,它就会弹出消息框——要求用户终止耗时处理。
好了,这是耗时处理终止机制的一部分。实际处理线程必须知道终止。最简单的方法是强制终止线程!如果您对线程正在做什么绝对确定,例如(此处)仅基于某些数字进行计算,您可以这样做。但如果处理线程使用了某些资源(文件、窗口、DC、套接字、文件等),则需要请求它优雅地退出。您可以使用事件来执行此操作,如前一篇文章中所述。随附的项目包含您所用语言的完整代码。
最后一点,您应该理解,这个示例与本文上一部分给出的“可取消”示例没有太大区别。唯一的区别是监视器线程请求停止,而不是用户。
另请注意,在计算器中,处理线程实际上是主/GUI 线程——这就是为什么计算器窗口无响应并显示(无响应)。当然,这是我的假设,基于视觉反馈和任务管理器中看到的线程数量!
项目详情
- MFC 项目:LengthCalculation_MFC
- .NET 项目:LengthyCalculation_NET
[•] 实践示例 6:等待外部程序完成
您的应用程序启动某个外部程序,例如 Notepad.exe,并希望在继续之前等待记事本完成。为什么?您可能会问。嗯,在这个示例中,您启动了记事本以允许修改某些设置文件,并期望用户在文件中进行更改,保存文件,然后退出记事本。一旦您发现记事本已退出,您将重新读取设置文件并进行相应操作。
Windows Vista 及更高版本的“程序和功能”也执行类似的操作。您启动某个安装程序(可以是任何类型的安装程序,不仅仅是 MSI),然后尝试启动另一个安装程序(卸载、更改或更新),它会显示以下错误消息
如何实现?
嗯,这很简单!您只需调用新创建进程句柄上的 WaitForSingleObject
API 或 Process.WaitForExit
方法,并指定无限超时。是的,您可能需要将此调用放在另一个线程上;否则,您的 GUI 将变得无响应。
另请注意,您可以使用相同的方法来等待您在程序内部创建的任何线程。上面给出的计算器示例可以更有效地实现为
- GUI 线程创建两个线程,一个用于实际的数字计算,另一个线程等待计算线程在指定时间内完成。
- 监视器线程现在等待计算线程(而不是某个事件/互斥锁)指定的时间超时。其余功能保持不变。请参阅下面的更多注释。
- GUI 线程将显示类似“正在计算...”的内容,禁用所有控件,并(可选)显示/启用一个“取消”按钮来取消计算。
- 现在我们总共有三个线程;第一个线程只显示“正在计算...”。第二个线程执行实际计算(此线程利用 CPU,而不是另外两个线程)。第三个线程只等待某个事件发生。如果您为用户提供“取消”功能,您必须在监视器线程中进行一些额外检查——以避免显示“正在占用很长时间...现在停止?”的消息框。用户已经点击了停止,而您编写糟糕的多线程代码可能会表现异常。
如果您无法理解上面要点中列出的有效方法,请不用担心。我只是为了完整性而写下它们。附带的项目也不这样做!
伪代码
ProcessHandle handle = CreateAnotherProcess(Path_To_Executable);
WaitForProcessToExit(handle);
// If control reaches here you can let user know, or take appropriate action
项目详情
- MFC 项目:ProcessWait_MFC
- .NET 项目:ProcessWait_NET
[•] 实践示例 7:构建一个防故障应用程序
这与数据安全无关,而是与进程安全有关。例如,您有一个服务器或服务,它应该无论如何都要保持运行。设备驱动程序、防病毒软件、数据库系统都可以是您的防故障应用程序。现在,如果您的进程因程序崩溃、用户故意终止您的进程或某些恶意进程试图终止您的进程而终止,您该怎么办?
在这里,您只需创建一个专用的进程(不是线程),它将等待进程终止,如上所述。现在,一旦它发现进程已终止,它将重新启动该进程(CreateProcess
或 Process.Start
)并再次等待新进程的句柄。
您的主进程还需要专用一个线程来等待另一个进程(它确保此进程的防故障)。在该进程中,您将等待其他进程终止,并且一旦发现它已终止,您将立即再次启动该进程!
最后一点:如果两者进程同时终止怎么办?嗯,在这种情况下,您可以让两个或多个监视器进程等待主进程终止,并在您的主进程中等待所有这些进程。我可以对此进行更多讨论,但就此结束。您能做的最好的事情是为您的进程附加一些安全属性,以及一个监视器进程,这样恶意程序就无法终止它们!
此示例未提供伪代码和项目。我假设读者能够理解如何实现这一点,并能够实现它!
让我们深入多线程的领域!
在上述四个示例中,我仅使用多线程概念来控制程序的执行和线程,仅通过等待或检查某些原语。嗯,正如大家所知,多线程的目的并非如此。多线程主要用于实现并行性,在执行其他任务的同时执行某些操作。通过多个处理器/核心,您的程序可以实现真正的同时执行。您可能已经阅读了更多关于此主题的理论知识——所以我们在这里停下来,专注于实际方法!
以下三个示例将向您展示如何使用多线程来提高应用程序响应能力,利用硬件的真正潜力。
[•] 实践示例 8:用户输入时进行拼写检查
嗯,不是!我不会详细介绍完整的拼写检查,提供合适的单词作为建议。只是这个简单的:程序将包含一些单词(比如大约 100 个单词),这些单词被认为是有效单词(所以说是关键字)。其他每个单词都将被标记为无效单词(即,被视为拼写错误)。请阅读这篇文章,了解使用 Word 自动化的实际拼写检查实现。
在我们的示例中,我们将允许用户在文本框中输入,并让用户将其添加到某个列表中。一旦用户输入文本并将其添加到列表中,我们就通知拼写检查线程有关该文本的信息。由于内联拼写检查(即,在用户输入时)的复杂性,我不会解释基于文本框内容更改事件的拼写检查。原因很简单:用户可能以任何顺序键入,可能将插入符(输入光标)移动到任何位置,可能剪切、复制、粘贴、删除或进行任何随机文本更改。每次文本更改都发送整个文本是不可行的;文本框中的文本可能已发生剧烈变化,而线程可能会对与文本框中的文本完全不符的文本执行拼写检查。
为此,我将使用字符串集合。字符串列表将受到关键部分的保护。GUI 线程会添加到此列表,而拼写检查线程会以 FILO(后进先出)的方式读取添加到列表中的最后一个元素。
什么是关键部分?
关键部分与互斥锁非常相似——它只允许一个线程访问某些受保护的数据。但是,存在以下差异
- 关键部分是用户对象,而互斥锁是内核对象。用户对象无需与内核(操作系统核心)进行交互;内核对象需要与内核模式进行往返。
- 关键部分比互斥锁更快。这条规则适用于任何用户与内核对象。
- 关键部分无法命名,并且无法在进程之间共享。
- 用户对象不能附加安全属性。
- 您无法在等待关键部分时指定超时。这意味着无限等待超时!
- 一些语言提供了直接简单的机制来使用关键部分。例如,C# 支持
lock
关键字来隐式使用关键部分,这比使用Monitor
类更直观。
编程元素表
内容 / 位置 | Windows API / C++ | MFC 库 | .NET Framework |
关键部分数据类型 | CRITICAL_SECTION |
CCriticalSection 类 |
静态 Monitor 类,或 lock 关键字 |
创建关键部分 | InitializeCriticalSection |
CCriticalSection 构造函数 |
-不适用- |
锁定/获取关键部分 | EnterCriticalSection |
CCriticalSection::Lock * 方法 |
Monitor.Enter 方法 |
解锁/释放关键部分 | LeaveCriticalSection |
CCriticalSection::Unlock 方法 |
Monitor.Exit |
尝试获取关键部分而不阻塞(即,超时为零) | TryEnterCriticalSection |
-不适用- | Monitor.TryEnter |
* CCriticalSection::Lock 有两个重载;一个也接受超时参数。但 CCriticalSection 会忽略它。请注意,CCriticalSection 派生自 CSyncObject ,它公开了两个版本的 Lock 方法。有关更多信息,请参阅 MSDN。 |
那么,我们如何实现受保护的字符串列表呢?
尝试理解以下图表
GUI 线程会锁定关键部分,将当前输入的文本添加到字符串列表中,然后释放关键部分。在此之后,GUI 线程可以执行自己的工作,例如将文本添加到列表控件,清空文本框以供下次输入,等等。此时,拼写检查器已开始处理文本!
同样,拼写检查线程将锁定关键部分,读取到某个本地变量中,然后释放互斥锁。然后它将开始对文本进行拼写检查。
从上图可以看出,我在很短的时间内获取并释放了关键部分。这是锁定和解锁的正确方法,以免其他线程等待关键部分的时间超过应有的时间!这就是为什么我将最后一项放入本地变量并对本地变量执行拼写检查的原因!GUI 线程也应该这样做,即在锁定之前执行文本验证等!
好的。但在这个例子中事件有什么作用?
嗯,拼写检查线程无法从空字符串列表中读取。它不能等待关键部分。这样做会阻塞 GUI(想想如何!)。因此,在这两个关键部分区域中,我们都会获取列表中的项目数并将其放入某个本地整数变量中。现在,在关键部分区域之后,在 GUI 线程中:我们检查元素计数是否为零,然后设置事件。在拼写检查线程中:我们仅在计数为零时等待事件被信号化。请参阅您喜欢的语言的附带代码。尽管如此,它可能有点难以理解,我无法通过语言、图片甚至代码将其推入您的大脑——尝试自己理解它——您就会!
如何让用户知道拼写错误?
在上图中,我提到了数据从UI 线程发送到拼写检查线程。我们还需要拼写检查器将某些数据发送到 GUI 线程,以便它可以为最终用户显示一些内容。
1. 发送无效计数
如第一部分所述,我们使用 PostMessage
或 BeginInvoke
结合自定义消息来通知 GUI 线程有关拼写错误的信息。要仅传递无效单词的数量,我们可以简单地传递一个整数,就像我们在第一部分中那样。
C++/MFC
// WM_SPELL_CHECK_REPLY is defined as our custom message code.
pThis->PostMessage(WM_SPELL_CHECK_REPLY, nInvalidWordCount);
C#
this.BeginInvoke(this.SpellReplyDelegate, nInvalidWordCount);
如果您不理解上面的代码(相对于您的语言),请阅读本文的第一部分。
GUI 线程将收到来自拼写检查线程的这个回复,并将更新计数。但是这样,您只能传递一个无效单词计数,而不能传递有效单词计数(您需要同时传递两者)。此外,您可能需要回传一个包含所有有效和无效单词的列表。此外,GUI 线程需要知道包含那些有效/无效单词的行。虽然我们以顺序方式将一行发送到拼写检查线程,并且该线程将以顺序方式接收它(记住,拼写检查线程以 FILO 模式处理行);但可能需要我们必须(从拼写检查线程回传到 GUI 线程)也传回行号。
我知道这是一个艰巨的任务,但让我们逐步进行。请注意,我下面讨论的内容对多线程编程非常有用。所以,请仔细理解!
2. 发送有效和无效计数
有两种方法可以做到这一点。第一种是将有效计数和无效计数打包到一个变量中——将计数作为 2 字节整数,并将它们打包成 4 字节(或 4-4 打包成 8 字节)。但我将此方法留给读者作为练习!第二种是为 PostMessage
/ BeginInvoke
使用两个参数。第一个参数是有效单词计数,第二个参数是无效单词计数。PostMessage
可以接受一个额外的参数(可选功能参数),相应的 C# 委托可以轻松修改以接受两个(或更多)参数。
3. 发送所有有效和无效单词的列表
请忽略此情况下稍微额外的内存使用量。在此方法中,我们将所有有效和无效单词放入一个列表中。因此,我们需要每个类别有两个 vector/list/array。此外,我们将这两个字符串列表放入某个结构中。我们在堆上分配此结构(使用 new
),并将有效/无效单词放入相应的字符串列表变量中。最后,我们将此结构的引用/指针发送到 GUI 线程。
您可能会争辩说为什么这么复杂!
嗯,PostMessage
/BeginInvoke
的参数数量是有限的。即使使用委托,您也无法轻松地编写一个接受可变数量参数的委托(例如 3 个有效单词,6 个无效单词—— 9 个参数!)。我们不必深入研究!
其次,我们必须在堆上分配结构(new
),因为堆栈变量仅限于当前线程/函数,而不是 GUI 线程。GUI 线程在使用完结构后,将删除该结构(C++)。
伪代码
struct SpellResult
{
StringList ValidWords;
StringList InvalidWords;
int Line;
};
/// In spell-checker thread
// Before loop which is checking the line nCurrentLine
SpellResult spell_result = new SpellResult; // Assume 'spell_result' is pointer (C++)
spell_info.Line = nCurrentLine;
// Inside the loop, which is checking one line
if( IsValidWord (sWord) )
spell_result.ValidWords.Append(sWord);
else
spell_result.InvalidWords.Append(sWord);
/// After the loop (for current line)
/// C++ ////
PostMessage ( WM_SPELL_CHECK_REPLY, spell_result);
// 'spell_result' is pointer - ignore the syntax
/// C# ////
this.BeginInvoke(this.SpellReplyDelegate, spell_result);
在 C++ 中,GUI 线程会将 WPARAM
转换为 SpellResult*
,并将使用该结构向最终用户显示拼写错误,最终删除指针。对于 C#,委托将接受 SpellResult
类型的参数,因此不需要类型转换。请下载项目并查看代码,此处未显示代码以节省篇幅。
如果用户在拼写线程完成之前关闭窗口怎么办?
嗯,这是一个有趣的情况!有一系列可能的解决方案。首先,在上面讨论的示例中,您可以让 GUI 线程和进程死掉——拼写检查线程会自动死掉(因为它执行拼写检查非常快)。其次,您可以让拼写检查器知道 GUI 正在终止,“退出”!第三,我们可以等待拼写检查线程完成工作。
- 对于第一个解决方案,我们无需做任何事情。它已经完成了!
- 对于第二个解决方案,这适用于本示例,我们让线程知道用户的退出意愿。(A)我们可以使用某个共享变量让拼写检查器知道退出语句。或者(B)我们可以有一个额外的事件让拼写检查器知道退出请求。
- 第三个解决方案不适用于此情况。我们必须遵守用户的请求,停止用户不想看到的拼写检查!对于非 GUI 请求,让我们推迟到下一部分讨论第三个解决方案!
解决方案 A:只需将一个简单的 bool
或 int
变量作为类成员。将其设置为 false/0——表示“退出请求”为 false
。每当用户尝试退出时,您只需将此变量设置为 1/true
。在拼写检查线程中,在处理每一行时,在 CS 锁(参见上面的图表)之前,检查此变量。如果为 true
,则立即退出!在 32 位处理器上,对 32 位或更小变量的读/写是原子操作,因此您无需同步它们。但这仍然不是一个好主意。
解决方案 B:您还记得第一部分中提出的暂停-恢复场景吗?我只是使用了线程暂停和线程恢复函数来暂停/恢复工作线程。嗯,这个解决方案与那个场景相似。在该示例中,我们需要工作线程知道 GUI 线程的两个请求之一:停止或暂停。在此情况下,我们需要拼写检查器知道两个请求之一:新行到达或停止请求到达。但正如您可以理解的(我假设您能理解!),当拼写线程中有更多行时,在 CS 区域检查这个两个事件检查不起作用。再次回忆,我们仅在字符串列表中没有行时才等待事件(参见上面的图表)。因此,拼写线程必须处理完所有行才能重新读取事件状态!但为了完整起见,我在此也提到了这种方法。
如何在一个调用中等待两个(或更多)事件?
您可能认为显而易见的解决方案是调用等待函数两次或更多次!例如
// Pseudo-code
status_stop = Wait(event_for_stop); // WaitForSingleObject / WaitOne method
status_newline = Wait(event_for_newline);
然后根据事件状态采取相应行动。在此示例中(以及线程暂停/恢复示例中),它会起作用,因为我们将 0 作为超时参数传递。但是如果我们将 0 作为等待超时怎么办?一个例子是:启动多个进程并等待其中一个完成。想想解决方案!没有一个能有效工作。
解决方案是使用 WaitForMultipleObjects
API,或 WaitHandle
基类的 WaitAny
方法。这些例程可以等待最多 64 个同步对象。可以指定任何类型的对象(任何互斥锁、事件、信号量、进程/线程句柄等的组合)。例程的返回值指定哪个事件已发出信号。请查看文档,了解如何使用这些例程;以下仅是伪代码
// Initialize with two event-handles (or any type of handle)
SyncObject event_array[2] = {...};
event_wait_result = WaitMultiple(event_array, 2); // Assume timeout is 0
if( event_wait_result == 0 ) // first event fired.
// Handling for first event
else if (event_wait_result == 1)
// Handling for second event
event_array
包含您希望作为原子操作等待的对象/句柄。WaitMultiple
的返回值指定哪个事件已被信号化,并据此采取行动。因此,对于取消/暂停场景,您可以创建两个事件,将它们放入数组,然后调用 WaitMultiple
。同样,对于这个新行/停止请求场景,您可以创建两个事件。但是,对于这个示例,停止请求事件必须在数组中的第一个位置。为什么?嗯,WaitForMultipleObjects
API 和 WaitHandle.WaitAny
方法都会返回最低索引(如果两个事件都已设置)。因此,如果新行到达,而用户希望关闭应用程序,那么关闭事件必须比新行事件具有更高的优先级。的确,这两种操作都由最终用户执行,因此它们会按新行、停止请求的顺序到达。但在其他情况下,您必须处理优先级较高的事件,因此在 WaitMultiple
调用中必须正确排序。
拼写检查根本不费时。因此,停止请求和 WaitMultiple
在此示例项目中没有实现。它在示例 9 项目中使用。在讨论该主题的过程中,它偶然出现,我解释了它!我假设您理解了它;如果不理解,请在阅读下面的示例 9 时再回来。
项目详情
- MFC:SpellChecker_MFC
- .NET:SpellChecker_NET
[•] 实践示例 8:读取非常大的文件
在记事本中打开一个巨大的文件(例如 400 MB)。您就知道剩下的事情了——记事本会挂起,直到它打开整个文件并在文本框中显示。或者它可能会引发一个错误,提示无法打开文件。
打开一个大小相似的 Word 文件(实际 Word 文档),其中包含数千页。它几乎会立即打开文档。打开文档后,您可以看到总页数不断增加。最终,整个文件加载完成。但是,同样,根据文件内容、页数等,Word 会采取一个聪明的举动:它不会将整个文件加载到内存中(或者至少不会加载到可见页面中)。
在这里,我将只解释加载巨大的文本文件。而不是逐行读取文件,在一个线程中,我们指定另一个线程来执行读取。专用线程将读取文件的行,并可能执行以下操作之一:
- 将该行添加到某个容器中。容器可以是
std::vector
、std::list
、CPtrList
、CStringArray
、Array
、ArrayList
——这取决于语言和您的偏好。主(GUI)线程可以以线程安全的方式读取此容器,并更新 UI。为了线程安全,我们可以使用关键部分(或互斥锁),正如我们在上一个示例中所做的那样。 - 直接使用
PostMessage
API 或BeginInvoke
方法通知主线程新读取的行。主 GUI 线程实际上会将该行添加到容器或 GUI 组件(如文本框、组合框、列表框等)。但这不会带来效果,因为 GUI 线程需要处理每一行,从而在读取线程完成之前使 GUI 无响应。 - 通过将几行添加到某个容器中,然后发送容器(包含几行——例如 100 行)而不是为每行读取发送,来优化上述方法。这将提高 GUI 线程的性能和响应能力。GUI 线程将从发送的迷你容器中读取所有行,并将这些行添加到控件中。
- 使用重叠 I/O 或 I/O 完成端口等高级技术。但在此阶段讨论它们并不合适。我可能会在未来的文章中讨论它们!
对于上述两种方法,读取线程将为单行或容器分配缓冲区,然后发送缓冲区(作为指针/引用)。GUI 线程将使用该缓冲区,并负责删除该缓冲区(C++)。
我们已经看到了一个使用容器的示例(选项 1)。选项 2 被排除,因为它几乎与单线程文件读取相同。本文不打算实现选项 4!
我们将使用选项 3。以下是步骤(GT:GUI 线程,RT:读取线程)
- [GT] 让用户选择要加载的文件。
- [GT] 尝试以读取模式打开一个文本文件。如果文件无法打开,请向用户发出错误。
- [GT] 文件成功打开后,创建一个新线程,读取线程。将此文件读取句柄/对象传递给读取线程。
- [GT] 将在启动读取线程之前存储文件大小。这对于向用户显示文件读取进度至关重要。
- [RT] 读取线程将参数类型转换为文件句柄/对象,并启动文件读取循环。循环将逐行读取文件。
- [RT] 线程会将该行保存在字符串列表中(本地分配,但分配在堆上)。达到某个阈值时——例如 100 行,它会将字符串列表容器发送到 GUI 线程,以便实际将此文本量附加到文本框(或其他控件)。发送通过
PostMessage
或BeginInvoke
完成。 - GT 收到发送的消息,将其转换为字符串列表类型,并将所有行添加到控件(文本框)中。此外,它还会增加/更新进度条或百分比(文本),以向最终用户显示已读取了多少文件。由于文件大小与行变化的长度没有直接关系,进度计算只是一个指示性估计。
- GT 还将释放由RT 分配的字符串列表的内存(C++)。
- RT 将最终发送文件读取完成的指示——这可能是一个空字符串列表(容器),或者
PostMessage
或 C# 委托中的某个额外消息。它还将关闭文本文件。 - GT 将处理该消息为文件读取完成,并更新 GUI(隐藏进度,启用文本框等)。
即使是读取小文件,我们真的需要一个读取线程吗?
实际上,不需要。我们可以通过仅委托读取大文件(例如,> 1KB)来优化。如果文件大小小于此值,我们可以在GUI 线程内部直接读取并添加到文本框控件中。
这里是图示表示
如果用户关闭应用程序、尝试加载另一个文件或请求取消文件加载怎么办?
对于所有这三个用户请求,我们可以使用一个事件来实现停止请求处理。如果您仔细阅读本文,您非常清楚:(1) 停止事件的含义,以及 (2) 在此示例中不需要两个事件,因为工作线程正在处理并将数据发送到 GUI 线程(拼写检查示例是本示例的反向)。
应用程序关闭请求应按上段所述方式处理。但对于其他两个请求,我们也可以
- 在文件读取完成之前禁用控件/菜单,以便用户无法取消或尝试打开另一个文件。
- 让用户在两个其他不同线程中打开另一个文件。这将需要一个 MDI 应用程序。
- 允许用户中止(取消)文件读取。这两种类型的应用程序(SDI 和 MDI)都可以这样做。
为什么 MDI 应用程序需要两个不同的线程?您可能很好奇!
嗯,我不会在本文中涵盖 MDI 或多文件读取(即使在附带的项目中)。不过,我必须澄清这一点。
整个应用程序的 GUI 在单个线程下运行,该线程通常(不一定)是主线程,无论您创建多少个窗口,或者在窗体/对话框上创建或放置多少控件/菜单。在原生 Windows API (Win32) 意义上,一个消息循环负责所有窗口(窗体、对话框、控件等)。因此,如果您创建一个 MDI 应用程序并让用户同时打开多个文件——GUI 线程仍然是一个!这个单一线程负责处理所有用户操作以及发送或调用的消息!因此,如果您创建三个子窗口并一次性打开三个大文件——总共只有 4 个线程(3 个读取线程和 1 个可怜的 GUI 线程),而不是 6 个线程!(请忽略 CLR 为 .NET 应用程序创建的线程;GUI 线程仍然是一个)。
例如,尝试打开 Windows Explorer (explorer.exe) 并复制一些文件。您会看到“正在复制...”窗口。查看任务管理器,explorer.exe 为处理此事创建了两个线程(甚至更多!)。一个是实际复制(工作线程),另一个是 GUI。GUI 线程是“正在复制...”窗口,它独立于 Explorer 的主窗口。这就是为什么您可以关闭主窗口并保持“正在复制”窗口打开的原因!
还有一点我没有在这篇文章中涵盖:线程池。许多应用程序会提前创建一些线程并让它们保持空闲。这是为了确保请求能够尽快得到处理,而无需处理创建线程耗时的开销。
(这两个好奇的话题没明白?不用担心,我会在接下来的几部分中的某个部分来涵盖它们!)
项目详情
我上面提到的关闭情况并未实现。也就是说,如果用户在整个文件读取完成之前关闭了应用程序,读取线程将不会收到通知。行为未定义。请查看“不带拼写检查的文件读取”以获取此示例。其他窗体/对话框/源文件用于下一个示例。
- MFC:LargeFileReading_MFC
- .NET:LargeFileReading_NET
[•] 实践示例 9:大文件读取和拼写检查!
没错——我们现在可以玩三个线程了!嗯,您可能认为这并不那么复杂。我们有两种实现方法。
方法 1:在用户请求打开文件时,我们创建两个线程。一个是文件读取线程,它将实际读取文件并将行集发送给 GUI 线程,就像我们在上一个示例中所做的那样。另一个是拼写检查线程,它将等待拼写检查请求,就像我们在拼写检查示例中所做的那样。
GUI 线程在从读取线程接收到行后,会将其附加到文本框并将同一行集(字符串列表)发送到拼写检查线程。拼写检查线程将检查文本行以进行拼写检查。它将像我们在示例 7 中那样,将拼写检查结果回复给 GUI 线程。但请记住,我们需要另一个消息/委托来处理来自拼写检查线程的请求。虽然我们可以使用相同或不同的消息/委托,但这会增加更多复杂性。
方法 2:打开文件时,我们只创建一个线程:读取线程。读取线程在开始读取文件之前,会创建拼写检查线程。然后它将开始读取文本文件,并将一组行发送到两个线程!拼写检查器将仅向 GUI 线程报告拼写结果。GUI 中需要另一个消息/委托来处理拼写结果,如方法 1 中所述。
我们将使用方法 2。应注意的是,GUI 更新比其他 CPU 密集型操作耗时。我们希望让用户流畅地使用我们的应用程序。因此,GUI 线程的负担较小。此外,与拼写检查示例(您掌握了那段代码吗?)不同,我们不会发送有关拼写检查的常规更新。相反,我们将在最后发送有效和无效单词的总数(如果您还记得,我在本文的某个地方详细阐述了这种方法!)。所以这里是它的样子(未显示 GUI 线程)
下面是关于这三个线程如何相互作用的抽象表示
由于此示例除了组合前两个示例外没有做任何额外的事情,因此没有必要进一步阐述。我希望上面的图表足够了。有关相关项目,请参见LargeFileReading项目中的File Reading with Spell Check。
[•] 实践示例 10:执行并行搜索
最后一个例子,但绝不是最不重要的!这个例子展示了多线程编程规则的优势。在深入技术细节之前,让我先讨论一个现实生活中的例子。
有十名工人(挖掘者,如示例 4 所述)需要挖掘土地。现在假设他们有一块大地要挖,但只有一个铲子或一台挖掘机。在这种情况下,所有其他九名挖掘工都必须等待,直到使用设备的那个将设备传给其他人。现在假设他们现在有 5 台设备来挖掘。如您所知,现在有五名工人可以同时工作,只有五名在等待挖掘设备。在计算中,挖掘设备是处理器(或者说,是处理器核心)。
下图说明了单个设备如何影响整体挖掘过程
下图说明了五台设备使用如何优化挖掘。请注意,它们挖掘的是同一块土地的不同区域。
现在,让我们进入技术领域。
假设您的应用程序在内存中存储了一个巨大的数据库以实现更快的检索或快速搜索。数据不应以任何方式修改。您只需要在内存中搜索。您会说——这很简单!只需编写一个线程来在容器中执行搜索(如上所述,容器可以是列表、数组、向量、映射……)。这是真的!但它不会在多处理器(2、48,甚至 256 个处理器)机器上带来显著的改进。如果您在一个线程中编写搜索代码,它只能按顺序处理,而不是并行处理(我这里不是说多个搜索请求,而是对一个包含约 100,000 个元素的容器的数据进行一次搜索)。这只会让您的进程占用一个处理器/核心(铲子),而其他处理器核心将处于空闲状态。因此,最佳解决方案是创建多个线程来进行搜索。
与挖掘示例一样,我们决定哪个区域的数据库供特定线程访问。与挖掘不同,我们不修改数据库(或内存中的任何集合)。我们只搜索。
例如,让我们分配两个线程来执行搜索。我们可以这样做:
- 创建两个线程;让它们知道它们要搜索的容器范围。在这里,我们可以说第一个线程搜索 0 到 49,999,第二个线程搜索 50,000 到 99,999。我们还可以指定处理器亲和性,以便这两个线程实际在不同的处理器上运行。但为了简化,我将跳过这一点。
- 这两个线程同时搜索相同的数据(搜索请求)!它们以我们讨论过的以下某种方法之一响应主线程(或某种监视器线程)(参见拼写检查示例)。
- 监视器线程或主线程(最初发出请求的线程)将合并这两个线程的搜索结果,并通知最终用户。根据您如何向最终用户提供结果,您可以一次性返回所有结果,或者在结果到达时通知最终用户——或者分批通知(例如,一次通知 100 个结果)。
您可能想知道在这个例子中监视器线程要做什么。嗯,不要纠结我发明的术语。发出搜索查询的主线程可能是主线程、GUI 线程、监听套接字的服务器应用程序的主线程,等等。该线程不能等待搜索完成——它必须响应其他请求,并要求工作线程执行实际工作。现在,正如我上面指出的,合并搜索结果也是需要的;中间线程(我称之为:监视器线程)负责合并工作。此外,中间线程负责创建实际搜索线程的数量,这取决于记录数、机器上的 CPU 数、应用程序的负载、所需的响应时间等。我希望您能感受到多线程的真正感觉、乐趣和复杂性!!
放松!我不会在示例中涵盖如此复杂的程度。只是这个简单的:应用程序启动时,应用程序将创建一个整数集(例如 1000 万个整数)并对其进行随机化。这个数字集合将作为我们应用程序的数据库,可以通过多个线程根据用户查询进行搜索。我为此制作了三个搜索示例
- 根据用户给出的关系运算符和数字进行搜索。关系运算符可以是(
==
、<
、>
、<=
、>=
和!=
)之一。 - 在数字数据库中搜索素数。我还添加了一个用户可以提供的条件(条件):查找大于的素数。请参阅附带的示例代码。
- 搜索一个数是否可被给定的数字整除或不可被其整除。
所有三个示例都在同一个数字集合上搜索,并且可以与其他示例并行工作。这意味着您可以同时搜索素数和可整除的数字。对于前面给出的两个子示例,线程将收集一组数字,并在达到某个阈值时,通知 GUI 线程。然后 GUI 线程会将数字显示到列表控件中。列表控件将显示数据来自的线程 ID,以及匹配条件的数字。
对于第三个示例,我征得您隐含许可来解释最后一件事:Interlocked 操作。
如您在上面的“可整除性”选项卡中看到的,仅显示了匹配数字的计数。DivisibilityCounterThread 只递增计数器,不收集匹配的数字。但是等等,会有多个 Counter 线程实例运行,并且在达到一定阈值后,线程必须响应 GUI 线程关于计数的信息。每个线程实例都有一个本地计数器变量,并将该变量发送到 GUI 线程,GUI 线程(拥有一个实际的计数器变量)将添加此值并在 UI 上显示它是完全可能的。
但我使用了另一种方法:从每个 Counter 线程实例递增相同的变量。为此,我们只需要在类级别(或您喜欢的任何级别,但必须可供线程修改)有一个整数变量,并允许线程对其进行修改。如前所述,在 32 位平台上修改 32 位整数是原子操作;但我们不应该这样做。我们可以简单地使用关键部分/锁以线程安全的方式修改变量。
例如,假设有一个员工基金账户,所有员工都可以存钱。由于这是一个单一银行账户,可能会同时发生多次存款。下图说明了不使用原子操作时可能发生的数据丢失情况
使用安全方法,即使用原子变量修改,情况会有所不同,并如下图所示
因此,我们将使用原子变量访问。下表列出了编程元素(这些是最常用的,但不完整)。请参考文档
变量上的原子操作 |
Windows API / C++ | .NET Framework |
加一 | Interlocked.Increment |
|
减一 | Interlocked.Decrement |
|
按某个值加/减(指定负值表示减) |
Interlocked.Add |
|
读取 |
|
|
分配另一个值(术语:交换) |
Interlocked.Exchange |
|
按比较赋值(仅限 |
Interlocked. CompareExchange
|
|
后缀为 64 的 API 用于 64 位变量,其他用于 32 位。 |
我们将变量的地址或引用传递给这些函数,它们以线程安全的方式修改(递增、相加、相减、AND/OR 等)变量。它们比使用关键部分原语进行锁定/解锁的受控变量修改更快。
工作分配
工人互相同意在土地的特定部分工作,他们很聪明。那么,我们如何告诉线程他们有权访问工作区域呢?嗯,首先,我们需要所有线程都能知道并可访问工作区域,即地面,以进行工作(搜索)。地面就是数字集合,您现在应该非常清楚了。我们将此集合提供给线程;我们可以将其放在全局级别、某个类级别,或将集合的引用传递给线程。本文篇幅已长,请查看附带的代码,了解我是如何做到这一点的。
在任何搜索线程开始之前,我们会计算线程需要搜索的范围。线程数默认为用户允许修改的机器上的 CPU 数量。我们计算特定线程将工作的低索引和高索引。这些索引不过是数组索引。例如,如果有 100 个数字,用户指定 4 个线程,我们将 0-24、25-49、50-74 和 75-99 作为这 4 个线程的工作区域。请查看代码,代码注释中说明了当总数与线程数变化时如何进行此划分。
下图说明了工作如何分配给不同的线程
项目详情
项目包含此处给出的所有三个子示例。应用程序启动时的第一个对话框将询问数据库大小(数字集合大小)。它将分配那么多整数,初始化并随机化数字。相同的数字集将由后面的对话框使用。
- MFC:ParallelSearch_MFC
- .NET:ParallelSearch_NET
最后...
希望您喜欢这次通过实践学习多线程的长途旅行。我已尽最大努力阐明一切。附带的代码无疑将让您更深入地理解多线程,以及如何以及为何编写多线程应用程序。所提供的代码并非完美,存在缺陷、设计限制。我这样做是为了阐明主题,并没有使示例代码完美。希望您能在实际应用程序中利用示例和此处提到的详细信息。
让学习曲线得到调整,以便您能够滑入实际多线程的领域,而不仅仅是阅读。
为何选择 MFC?
因为使用 C++ 中的 MFC 编写 GUI 应用程序比编写核心 Windows API 要方便得多。使用 WX 等其他库对我来说没有意义,人们甚至不接受 MFC。尽管我首先用 .NET、MFC 和 WinAPI 编写了几个应用程序,但制作核心 Win32 项目所需的时间和精力不允许我继续进行。因此,我放弃了这个想法。如果需求很大,我可能会提交一个。
等待反馈!