SyncInvoker:在冗长的同步调用期间保持 GUI 响应






4.93/5 (19投票s)
使您的 GUI 在进行阻塞性同步调用时保持响应。Dave 提供了一种使用 SyncInvoker 的技术,

Estragon:“我们走吧。”
Vladimir:“我们不能;我们还在等戈多。”
我认为这是不可避免的,但我们在开发 GUI 时似乎一遍又一遍地遇到许多相同的问题。您有多少次不得不编写 GUI 代码,该代码对某个外部服务(服务器、操作系统、中间件或硬件)进行有序的同步调用序列,并不得不想方设法在等待调用完成时保持 GUI 的响应能力?GUI 受调用花费时间的影响,如果调用时间长,最终用户将无法区分是应用程序挂起还是仅仅花费时间长。在许多情况下,调用时间可能因其性质而不确定。无论如何,GUI 开发人员有责任将用户与此隔离开,而不是让 GUI 部分重绘、变白或行为异常。
我敢肯定您已经尝试了所有技术,从在单独的线程中进行调用到将它们包装在调用完成时触发事件的类中。我看到的所有技术都使用事件、线程和状态机方法的某种组合,但这只会导致代码比需要的复杂得多。从根本上说,您只是在进行一系列顺序同步调用。
在本文中,我将探讨如何以直接的方式解决这个问题,使您的代码具有可读性且非常易于维护。
再给我一次机会
所有窗口和控件都使用事件驱动的消息传递基础结构来处理发生的事情:调整大小、鼠标点击、绘制请求等。这些事件使用窗口消息来表示和实现。每个窗口都有一个 Windows 过程(臭名昭著的 WndProc),负责处理消息。
应用程序的责任之一是主动从 Windows 系统获取消息并将其分派到目的地。这通常被称为“消息泵”或“消息循环”。应用程序运行在其上的主 GUI 线程拥有它为其传递消息的所有窗口。由 WndProc 调用 的方法将位于同一个线程中。一个简单的消息泵看起来像这样。
while(GetMessage(&Msg, NULL, 0, 0) > 0)
{
TranslateMessage(&Msg);
DispatchMessage(&Msg);
}
因此,如果主 GUI 线程中调用的同步方法长时间阻塞,则不会调用消息泵。绘制消息不会被处理,您将面临屏幕变白、GUI 无响应和用户不满意的情况。
消息泵课程到此结束!
典型问题
假设您有一个 GUI 需要按顺序执行以下调用。
void CPricingServerDlg::OnStartBadSimulation()
{
LPVOID params = 0;
NewPricingJob(params);
ReadSimulationParameters(params);
RunLocally(params);
WriteExposureData(params);
WriteResults(params);
}
我们不知道每次调用需要多长时间。它们可能从不到一秒到数秒不等,但我们不希望消息泵被它们阻塞。此外,我们不希望应用程序代码过于复杂且难以阅读或理解。我的解决方案是一个简单的模板类,封装在一个名为 SyncInvoker
的宏中。
#ifndef __SYNCINVOKE_H__
#define __SYNCINVOKE_H__
#include "atlbase.h"
namespace SyncInvoker
{
class Thread
{
public :
Thread(UINT uThrdDeadMsg = 0) : m_uThreadDeadMsg(uThrdDeadMsg) {}
virtual ~Thread() {CloseHandle(m_hThread);}
virtual Thread &Create(LPSECURITY_ATTRIBUTES lpThreadAttributes =
0, DWORD dwStackSize = 0, DWORD dwCreationFlags = 0, UINT
uThrdDeadMsg = 0)
{
if (uThrdDeadMsg) m_uThreadDeadMsg = uThrdDeadMsg;
m_dwCreatingThreadID = GetCurrentThreadId();
m_hThread = CreateThread(lpThreadAttributes, dwStackSize,
ThreadProc, reinterpret_cast(this), dwCreationFlags,
&m_dwThreadId);
return *this;
}
bool Valid() const {return m_hThread != NULL;}
DWORD ThreadId() const {return m_dwThreadId;}
HANDLE ThreadHandle() const {return m_hThread;}
protected :
virtual DWORD ThreadProc() {return 0;}
DWORD m_dwThreadId;
HANDLE m_hThread;
DWORD m_dwCreatingThreadID;
UINT m_uThreadDeadMsg;
static DWORD WINAPI ThreadProc(LPVOID pv)
{
if (!pv) return 0;
DWORD dwRet = reinterpret_cast(pv)->ThreadProc();
if (reinterpret_cast(pv)->m_uThreadDeadMsg)
PostThreadMessage(reinterpret_cast(pv)->m_dwCreatingThreadID,
reinterpret_cast(pv)->m_uThreadDeadMsg, 0, dwRet);
return dwRet;
}
};
template <class T_OWNER>
class CSyncCall : public Thread
{
public:
CSyncCall():m_owner(NULL), m_method(NULL),m_param(0){}
typedef void (T_OWNER::*METHOD_PTR)(LPVOID);
bool Call(T_OWNER *owner,METHOD_PTR method,LPVOID param)
{
m_owner = owner;
m_method = method;
m_param = param;
return Create().Valid();
}
virtual DWORD ThreadProc()
{
if(m_owner)
(m_owner->*m_method)(m_param);
return 0;
}
private:
T_OWNER *m_owner;
METHOD_PTR m_method;
LPVOID m_param;
};
}
#define SyncInvoke(cls, method, param)
{
SyncInvoker::CSyncCall syncCall;
syncCall.Call(this, method, param);
AtlWaitWithMessageLoop(syncCall.ThreadHandle());
}
#endif
SyncInvoker
使用一个名为 CSyncCall
的类和 Thread
在单独的线程中调用所需的调用;然后它等待直到调用完成。在等待期间,它使用 AtlWaitWithMessageLoop
保持消息传递。该宏允许将参数传递给线程调用,并且可以用于返回成功代码。虽然这可能看起来很复杂,但它非常直接,并且所有这些都隐藏在宏中。
尽管这个模板和宏看起来很复杂(实际上并不复杂),但上面显示的同步调用序列只是用宏包装起来,以使其在 GUI 中表现良好。
void CPricingServerDlg::OnStartGoodSimulation()
{
LPVOID params = 0;
SyncInvoke(CPricingServerDlg,
&CPricingServerDlg::NewPricingJob, ¶ms);
SyncInvoke(CPricingServerDlg,
&CPricingServerDlg::ReadSimulationParameters, ¶ms);
SyncInvoke(CPricingServerDlg, &CPricingServerDlg::RunLocally, ¶ms);
SyncInvoke(CPricingServerDlg,
&CPricingServerDlg::WriteExposureData, ¶ms);
SyncInvoke(CPricingServerDlg, &CPricingServerDlg::WriteResults, ¶ms);
}
示例应用程序
我提供了一个名为 PricingServer
的示例应用程序来演示如何使用 SyncInvoker
。构建示例后,运行它并单击“行为不端的 GUI”按钮。它为每个耗时的调用启动一个窗口。因为它没有使用 SyncInvoker
,您会发现无法移动窗口,进度条不动,如果您将另一个窗口拖到它上面,它直到调用完成才会重绘:一个糟糕的 GUI。尝试使用“行为良好的 GUI”按钮,一切都会按预期工作。窗口会重绘,并且在进行同步调用时可以移动。
一些注意事项
- 由于使用
SyncInvoker
时消息泵不再阻塞,因此您的 GUI 在调用期间将保持活动状态。因此,可以按下所有按钮并调用它们的处理程序。您需要确保考虑到这一点并适当锁定控件。 - 同样,用户可以在调用过程中关闭应用程序。
Start
和StopProgress
方法用于基于WM_TIMER
显示进度条。同样,这会被同步调用阻塞。- 您可能需要修改
AtlWaitWithMessageLoop
(在 atlbase.inl 中),使其不使用 INFINITE,而是使用一些以毫秒为单位的超时值,以便可以对调用施加超时。如果您这样做,请记住您需要考虑失败的调用及其所有相关事项。