在基于 Windows 的 C++ 应用程序中使用异步过程调用 (APC) 执行 GUI 更新





5.00/5 (11投票s)
如何使用 APC 执行用户界面更新
引言
当您在 C++ 中创建基于 Windows 的应用程序(例如使用原始的 win32 API 或 MFC)并且应用程序执行繁重的工作时,您很快就会遇到一个问题:这项工作会影响应用程序的响应能力。如果点击一个按钮导致在按钮点击事件的处理程序中执行 1 秒的处理,这意味着在这一秒内整个应用程序将无法响应其他事件。
显然,这违背了良好用户界面设计的理念,因为它感觉缓慢且不专业。人们会开始认为有问题,并可能决定终止您的应用程序。解决方案是将这项工作卸载到后台线程,该线程可以在用户界面窗口响应新事件(如系统消息或用户交互)的同时执行该工作。
这解决了“执行工作所需时间”的问题,但引入了一个新的“与用户交流”的问题。在工作完成或正在进行时,工作线程需要能够与用户界面进行交互。这可能是为了显示结果、中间状态或更新用户界面本身的辅助功能。但是,用户界面交互的一个主要限制是,在任何情况下,工作线程都不允许触碰由用户界面线程管理的任何用户界面元素。这样做会导致严重问题,例如崩溃或数据损坏。
我们需要一种可靠、简单的方法来实现一种机制,让工作线程能够向用户界面发送更新。像许多此类问题一样,没有“唯一”的正确解决方案。然而,APC(异步过程调用)有很多优点,因此在本文中我将探讨这些优点。请注意,这是我关于 APC 的第一篇文章的续篇。在那篇文章中,我解释了它们的工作原理以及如何使用它们。在这篇文章中,我将展示一个实际应用。
背景
在实际应用程序中,最终用户通常通过点击按钮来启动一个操作,然后等待/同时获取结果。这可以是任何事情。机器操作、连接仪器的测量序列、复杂的数据库查询等等。这通常会与一组数据(如果适用)一起作为命令发送到另一个线程,这些数据作为输入数据。
工作线程然后可以花费任意时间来处理命令,并在完成后发送更新。为了方便起见,我们同样使用 APC 来调度命令到工作线程,因为它非常优雅和方便,并且省去了线程安全队列的麻烦。
那么,我们开始吧。
线程代码
我们需要一个后台工作线程,所以我们先来实现它。
DWORD WorkerThreadFunc(void* context)
{
HANDLE shutdown = (HANDLE)context;
DWORD retVal = 0;
while (retVal = WaitForSingleObjectEx(shutdown, INFINITE, TRUE) == WAIT_IO_COMPLETION)
;
return retVal;
}
正如您所见,这段代码不可能更简单,因为它根本就没有什么。请记住,在上一篇文章中,Windows 会在内部排队我们的工作请求,并在该线程正在进行的操作的上下文中执行它。通过使用 `WaitForSingleObjectEx`,我们实际上拥有一个线程,它除了等待某事发生之外什么都不做。函数名中的“Ex”表示我们允许 Windows 中断等待。
这个机制的额外好处不仅在于我们不必自己编写任何调度代码,而且工作线程不必预先知道它将做什么,因为我们可以调度任何我们想要在该线程上执行的操作。
异步任务数据
我们已经提到,我们将把工作请求发送到后台线程,并获取某种形式的响应。
struct TaskData
{
HANDLE hCaller;
CMFCTestDlg* sourceDialog;
FLOAT Value;
};
struct TaskResponse
{
CMFCTestDlg* targetDialog;
CString Value;
};
在本例中,我们将向后台工作线程发送一个浮点数来处理。`hCaller` 参数的目的很明显。如果我们想使用 APC 将工作项调度到原始用户界面线程,我们必须知道要将它发送到哪个线程。最后一个参数是指向必须处理更新的窗口对象的指针。
起初您可能认为:我们为什么要传递窗口指针,而我们已经知道返回的 APC 必须去哪里(通过 `hCaller`)。原因有两个。首先,从技术角度讲,APC 函数不能是成员函数,它们是全局函数(不绑定到特定对象)。因此,除非我们在代码中使用全局变量,否则 APC 函数无法知道它需要在哪个窗口对象上更新信息。
其次,像 MDI(多文档界面)这样的 Windows 应用程序可能同时有多个活动窗口,在这种情况下,工作线程和用户界面线程都不知道源/目标窗口。
任务工作线程本身
任务工作线程是将在后台线程上执行的函数。
void PerformTask(void* context)
{
TaskData* data = (TaskData*)context;
TaskResponse* response = new TaskResponse;
Sleep(500);
response->targetDialog = data->sourceDialog;
response->Value.Format(L"Processed APC with value %f\r\n", data->Value);
if(QueueUserAPC(
(PAPCFUNC)&ReportBack,
data->hCaller,
(ULONG_PTR)response))
{
//do error handling here
delete response;
}
delete data;
}
第一部分很容易理解。我们获取输入数据并创建一个响应消息。500 毫秒的延迟是为了模拟正在进行的工作,之后填充响应数据。请注意,在任何情况下 (*) 都允许您对来回传递的窗口对象指针执行任何操作。这将违反窗口对象从另一个线程触碰的前提,并可能导致数据损坏或崩溃。
当响应消息准备好后,它会与窗口对象一起被发布回原始线程,该窗口对象可以与任务结果一起用于执行用户界面更新。请注意,这是一个相当简单的例子。拥有多个可能的报告函数是完全有效的。通常这可以带来更简洁的代码。
例如,假设发生了一个错误,那么 `ReportError` 函数就有用武之地,它可以执行在发生错误时才需要执行的操作,例如将状态栏变为红色。拥有多个函数来处理特定情况,消除了 `ReportBack` 实现复杂处理来处理所有情况的需要。
(*) 从技术上讲,限制仅限于触碰任何“与窗口相关”的内容,例如文本框的内容或背景颜色,或任何可能被用户界面线程触碰的线程不安全数据,例如 `CString` 对象。理论上,访问文件句柄或 `bool `成员变量是完全可以的,因为它们不会以任何方式影响窗口基础结构。但是,即使在这种情况下,我也强烈反对这样做。不仅很容易误判事情,而且您仍然需要处理“正常的”多线程陷阱,例如——可能——在同一时间在两个不同的线程中使用文件句柄。
大多数多线程问题可以通过便利的捷径来避免。如果工作线程需要某项内容,请以移交所有权的方式将其与任务一起发送。当任务完成时,它会关闭资源或将所有权交还。
任务响应
就我们的例子而言,响应很简单。屏幕上有一个状态窗口,我们将最新任务执行的结果添加到该窗口。这里没有什么好说的。
void ReportBack(void* context)
{
TaskResponse* response = (TaskResponse*)context;
response->targetDialog->logText = response->Value + response->targetDialog->logText;
response->targetDialog->txtApcResult.SetWindowTextW(
response->targetDialog->logText);
delete response;
}
APC 执行
我们将 APC 用于两件事:将工作卸载到后台工作线程,以及在用户界面线程上处理响应。
调度工作
如果您阅读了我的第一篇 APC 文章,您会发现这一部分非常相似。我们有两个按钮用于调度工作。第一个调度 1 个工作项,另一个调度 5 个。
void CMFCTestDlg::OnBnClickedStart()
{
auto data = new TaskData;
data->hCaller = hGuiThread;
data->Value = fVal++;
data->sourceDialog = this;
if(QueueUserAPC((PAPCFUNC)&PerformTask, hWorkerThread, (ULONG_PTR)data))
{
//do error handling here
delete data;
}
}
void CMFCTestDlg::OnBnClickedStart5()
{
for (int i = 0; i < 5; i++) {
auto data = new TaskData;
data->hCaller = hGuiThread;
data->Value = fVal++;
data->sourceDialog = this;
if(QueueUserAPC((PAPCFUNC)&PerformTask, hWorkerThread, (ULONG_PTR)data))
{
//do error handling here
delete data;
}
}
}
这两个选项非常相似。我们创建一个新的任务输入数据结构,填充数据,然后将工作函数与输入数据一起排队到后台线程。
处理响应
讨论完任务执行后,我们需要回到起点。还记得我们说过 APC 只有在目标线程通过执行所谓的“可中断等待”告诉 Windows 它已准备好被中断时才会执行。
在工作线程中,这很容易,因为我们可以使用 `WaitForSingleObjectEx` 来保持监听传入的 APC。但是,在 GUI 线程中,我们没有这样的运气。窗口应用程序由内部的消息泵驱动。
作为参考,消息循环通常看起来像这样
while( (bRet = GetMessage( &msg, hWnd, 0, 0 )) != 0)
{
if (bRet == -1)
{
// handle the error and possibly exit
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
细节超出了本文的范围,但足以说明该应用程序可以接收来自系统本身以及用户交互的消息。每条消息都会被快速处理,之后应用程序会回到等待状态。乍一看,GUI 线程没有合适的方法让自己进入可提醒状态。
对于使用原始 win32 编程的 GUI 应用程序,我们可以用 `MsgWaitForMultipleObjectsEx` 替换 `GetMessage`。有关更多详细信息,您可以阅读文档,但它本质上提供了一个可提醒的 `GetMessage` 等效项。
如今,很少/几乎没人会用原始 win32 编写新应用程序,因此您这样做机会渺茫。更有可能您会使用 MFC 或类似的类库,它将消息循环隐藏在对象层次结构的深层之下,在那里它不应该被触碰。当然,您可以挖掘所有这些层并进行大量更改,但这并不是您应该做的事情。这并非必需,并且它会抵消使用标准框架带来的许多好处。
相反,我们将作弊。
在 GUI 线程中启用 APC 处理
仔细想想,我们唯一的要求是不要在用户界面线程上执行工作而阻塞它,并在某个时候在用户界面线程中处理任务响应。这并不意味着我们必须立即处理。退一步想想,用户界面中的任何内容都不需要实时响应。0 到 250 毫秒的响应时间已经足够好了,只要应用程序在此期间可以自由交互。
我们通过计时器来实现这一点。每个派生自 `CWnd` 的窗口类都有一个 `SetTimer` 方法,该方法会导致在窗口的消息泵中触发一个 WM_TIMER 事件。因此,每隔 `nElapse` 毫秒,处理该事件的方法就会被执行。
void CMFCTestDlg::OnTimer(UINT_PTR nIDEvent)
{
SleepEx(0, TRUE);
CDialogEx::OnTimer(nIDEvent);
}
在该消息的处理程序中,我们所要做的就是执行一个 0 毫秒的可提醒睡眠。这将通知系统,用户界面线程能够执行可能存在的任何 APC,如果有的话。
说是这样,这并不是一个完美的解决方案。毕竟,我们需要每秒处理几次计时器消息。即使处理量微不足道,它也不是像 `MsgWaitForMultipleObjectsEx` 那样完美的解决方案。但这是我们在不破坏 MFC 窗口处理核心的情况下所能达到的最接近的程度。
如果这是一个需要关注的问题,您仍然可以进行一项优化。只有在您预期有 APC 时,处理 APC 才有意义。如果没有后台处理正在进行,您可以简单地禁用计时器!
在我的例子中,为了方便演示,我选择实现一个单选按钮,您可以通过它在手动触发 APC 处理和自动处理之间切换。在 MFC 中,单选按钮会生成独立的事件,所以这就是实现的方式。
void CMFCTestDlg::OnBnClickedApcauto()
{
rbnAuto.SetCheck(1);
rbnMan.SetCheck(0);
btnProcessAPC.EnableWindow(0);
SetTimer(1, 250, NULL);
}
void CMFCTestDlg::OnBnClickedApcman()
{
rbnAuto.SetCheck(0);
rbnMan.SetCheck(1);
btnProcessAPC.EnableWindow(1);
KillTimer(1);
}
如果我们选择自动处理,我们将启用计时器,周期为 250 毫秒,并禁用手动处理它们的按钮。如果我们选择手动处理,我们将关闭计时器并启用按钮以手动启动 APC 处理。手动处理非常简单。
void CMFCTestDlg::OnBnClickedButton2()
{
SleepEx(0, TRUE);
}
从这些示例中,同样可以轻松看出,每当您将工作推向后台线程时,您都会启动计时器,并在 GUI 完成处理工作时禁用它。当然,您必须考虑到可能存在多个工作项,而您只希望在所有工作都完成后才禁用计时器。
好消息是,启动工作和处理工作响应都在 GUI 线程中完成,因此是同步的。您只需使用一个成员变量来增加和减少打开任务的数量,而无需担心同步问题。
使用测试应用程序
测试应用程序很简单。
您可以一次启动 1 个或 5 个任务,然后决定是手动还是自动处理它们。就这么简单!
关注点
感觉我们编写了很少的代码就实现了一个健壮的工作线程解决方案,具有健壮且线程安全的任务调度。这是真的。如果您使用 APC,Windows 会为您处理调度、上下文切换和工作排序。通过在用户界面线程中使用它们来处理任务响应,您可以遵守 Windows 的要求,避免崩溃/数据损坏,并且不必担心竞态条件,因为所有响应处理都井然有序,并且在用户界面本身无法执行其他操作时进行。
我曾想过用原始 win32 代码构建一个示例应用程序来演示 `MsgWaitForMultipleObjectsEx`。我决定改为使用 MFC,因为如果您是 C++ 开发者并且正在开发基于 Windows 的应用程序,那么您很可能会使用 MFC 或类似的框架。如今,很少有项目是从 win32 开始的。
我还想指出,为了保持简单,我没有为 new 操作符实现错误处理,也没有检查 NULL 指针。不用说,在生产代码中,您应该始终实现输入验证、范围检查、指针验证等。
历史
2023 年 12 月 8 日:第一个版本。
2023 年 12 月 19 日:第二个版本。添加了对 QueueUserAPC 错误的處理。