定时器教程






4.91/5 (120投票s)
关于不同类型定时器的教程
目录
引言
本文的目的是展示不同类型定时器的实际用法。首先,我们将学习如何使用“标准”Win32 定时器,然后切换到多媒体定时器,并提及可等待定时器和队列定时器。我将尝试对这些解决方案进行一些通用比较。基于函数 QueryPerformanceFrequency
和 QueryPerformanceCounter
的所谓高精度定时器将不被考虑在内,因为它只能用于测量时间间隔,而不能用于以固定的时间间隔触发事件。
根据 MSDN 的说法,应用程序使用定时器在指定时间过去后调度窗口中的事件。这意味着,如果我们创建一个定时器并指定一个 uElapse
毫秒的时间间隔,它将每 uElapse
毫秒执行“某事”,直到我们销毁它。至于“某事”是什么,则取决于我们。
定时器是操作系统向程序员提供的有用功能。但是,它们很容易被误用:将定时器用于各种轮询(例如,每 200 毫秒检查一次用户是否在编辑框中输入了某些值),这几乎不是一个好主意。适合使用定时器的场景是那些不太依赖用户操作,而更多地依赖时间流逝的应用程序。
重要的是要理解定时器的精度是有限的。Windows 不是一个实时操作系统(Windows CE 除外),因此不要指望定时器能够处理非常小的时间间隔(例如 10 毫秒)。
标准 Win32 定时器
当提到定时器这个词时,几乎总是指这种类型的定时器。在本篇文章中,我使用Win32 定时器这个术语,只是为了区分它们与其他定时器。在某些文本中,这些定时器被称为用户定时器,因为它们不像可等待定时器和队列定时器那样是内核对象。
Win32 定时器是如何工作的?首先,我们创建一个定时器,指定其超时时间,并(可选地)将其附加到一个窗口。定时器创建后,它会将 WM_TIMER
消息发送到窗口消息队列,或者,如果没有指定窗口,则发送到应用程序队列。我们可以处理此消息来调用我们希望以固定时间间隔执行的代码。定时器将发送 WM_TIMER
消息,直到被销毁。
要创建定时器,我们将使用 Win32 函数
UINT_PTR SetTimer(HWND hWnd, UINT_PTR nIDEvent, UINT uElapse, TIMERPROC lpTimerFunc);
或其 MFC 等效函数
UINT CWnd::SetTimer(UINT_PTR nIDEvent, UINT nElapse,
void (CALLBACK EXPORT* lpfnTimer)(HWND, UINT, UINT_PTR, DWORD));
参数
-
hWnd
- 与定时器关联的窗口句柄;可以为NULL
,在这种情况下,将忽略nIDEvent
,返回值将用作定时器标识符。 nIDEvent
- 一个非零的定时器标识符。uElapse
- 定时器的超时间隔(以毫秒为单位)。lpTimerFunc
- 一个应用程序定义的处理WM_TIMER
消息的回调函数。可以为NULL
(通常情况下就是如此)。
返回值
- 定时器标识符。如果
hWnd
非NULL
,则它等于nIDEvent
。发生错误时,值为零。
在某个时候,我们将需要停止定时器的“滴答”声。我们可以通过销毁它来完成此操作
BOOL KillTimer(HWND hWnd, UINT_PTR uIDEvent);
或其 MFC 等效函数
BOOL CWnd::KillTimer(UINT_PTR nIDEvent);
参数
hWnd
- 与调用SetTimer
时相同的值uIDEvent
- 定时器标识符
返回值
- 如果函数成功,则返回
TRUE
;如果失败,则返回FALSE
从 CWnd
派生类使用 Win32 定时器的典型用法如下所示
void CTimersDlg::OnButtonBegin()
{
.
.
.
// create the timer
SetTimer (m_nTimerID, uElapse, NULL);
}
void CTimersDlg::OnButtonStop()
{
// destroy the timer
KillTimer(m_nTimerID);
}
void CTimersDlg::OnTimer(UINT nIDEvent) // called every uElapse milliseconds
{
// do something, but quickly
.
.
.
CDialog::OnTimer(nIDEvent);
}
如果我们每半小时需要检查一次收件箱是否有新邮件,Win32 定时器就足够了。然而,对于更精确的时间测量(经过时间小于 1 秒),这些定时器几乎不是解决方案。主要原因是定时器将 WM_TIMER
消息发布到消息队列,而我们永远无法确定何时会处理这条消息。现在,您可能会认为设置 lpTimerFunc
是解决此问题的方法,但事实并非如此。如果您指定了 lpTimerFunc
,默认窗口过程仅在处理 WM_TIMER
时才调用它。因此,我们仍然需要等待 WM_TIMER
被处理。
请注意,使用 Win32 定时器时,事件处理是从 UI 线程进行的。这一事实的好处是我们无需担心在定时器事件处理程序中破坏我们的数据;另一方面,WM_TIMER
处理程序中花费的时间会影响 UI 的响应能力。如果您不相信我,请尝试在 CTimersDlg::OnTimer()
中调用类似 ::Sleep(10000);
的内容。
多媒体定时器
在本文的原始版本(写于 8 年前)中,我详细描述了多媒体定时器。与此同时,它们已因队列定时器而过时。如果您对此感兴趣,请查看 Larry Osterman 的博客文章。无论如何,即使在我最初撰写本文时,选择多媒体定时器而不是队列定时器的唯一原因是因为后者是在 Windows 2000 中引入的,而 Windows 2000 是一个相对较新的系统。
多媒体定时器是一种高精度定时器,它不将任何消息发布到消息队列。相反,它直接在一个单独的线程上调用指定的回调函数(或者,它也可以设置或脉冲特定的事件,但该选项将在本文中不作介绍)。因此,它比标准的 Win32 定时器更精确,但也更危险。在这里,如果我们指定了短的超时时间,就没有消息队列来保护我们。
要在您的项目中 M使用多媒体定时器,您应该包含 Mmsystem.h,并将其与 Winmm.lib 链接。
使用多媒体定时器的第一步是设置定时器分辨率。什么是定时器分辨率?它决定了定时器的精度。例如,如果超时时间是 1000,分辨率是 50,多媒体定时器将每 950 - 1050 毫秒“滴答”一次。
这听起来不错。为什么我们不直接将定时器分辨率设置为零,从而获得绝对精确的定时器?这是因为不同的系统支持多媒体定时器分辨率的不同最小值。我们可以通过调用来获取此最小值
MMRESULT timeGetDevCaps(LPTIMECAPS ptc, UINT cbtc);
参数
ptc
- 指向TIMECAPS
结构的指针。它填充了有关定时器设备分辨率的信息cbtc
-TIMECAPS
的大小(sizeof (TIMECAPS)
)。
返回值
- 成功则为
TIMERR_NOERROR
,失败则为TIMERR_STRUCT
TIMECAPS
非常简单
typedef struct {
UINT wPeriodMin;
UINT wPeriodMax;
} TIMECAPS;
wPeriodMin
- 支持的最小分辨率wPeriodMax
- 支持的最大分辨率
我们需要将我们的最小分辨率保持在此范围内。现在,既然我们有了它,让我们设置分辨率。我们将通过调用函数来完成此操作
MMRESULT timeBeginPeriod(UINT uPeriod);
参数
uPeriod
- 最小定时器分辨率
返回值
- 成功则为
TIMERR_NOERROR
,如果指定的uPeriod
分辨率超出范围,则为TIMERR_NOCANDO
现在我们已经设置了分辨率,让我们创建定时器。多媒体定时器等效于 SetTimer
的函数如下所示
MMRESULT timeSetEvent(UINT uDelay, UINT uResolution,
LPTIMECALLBACK lpTimeProc, DWORD dwUser, UINT fuEvent);
参数
uDelay
- 事件延迟(以毫秒为单位)。与SetTimer
中的uElapse
非常相似uResolution
- 定时器事件的分辨率(以毫秒为单位)。lpTimeProc
- 指向我们希望定期调用的回调函数的指针dwUser
- 传递给回调函数的用户数据fuEvent
- 定时器事件类型。可以是TIME_ONESHOT
(在这种情况下,回调函数只调用一次)或TIME_PERIODIC
(用于周期性调用)
返回值
- 成功则为定时器事件的标识符,失败则为
NULL
让我们看一下回调函数。它的声明如下
void CALLBACK TimeProc(UINT uID, UINT uMsg, DWORD dwUser, DWORD dw1, DWORD dw2);
参数
uID
- 定时器 ID,由timeSetEvent
返回uMsg
- 保留lpTimeProc
- 指向我们希望定期调用的回调函数的指针dwUser
- 传递给回调函数的用户数据dw1, dw2
- 保留
最终,我们将需要销毁定时器。我们可以通过调用函数来完成此操作
MMRESULT timeKillEvent(UINT uTimerID);
参数
uTimerID
- 定时器 ID
返回值
- 成功则为
TIMERR_NOERROR
,如果指定的定时器事件不存在,则为MMSYSERR_INVALPARAM
还记得设置定时器分辨率吗?好了,完成定时器使用后,我们应该通过调用来“重置”定时器分辨率
MMRESULT timeEndPeriod(UINT uPeriod);
参数
uPeriod
- 与timeBeginPeriod
中的值相同
返回值
- 成功则为
TIMERR_NOERROR
,失败则为TIMERR_NOCANDO
上一章示例的多媒体定时器版本
void CTimersDlg::OnButtonBegin()
{
.
.
.
// Set resolution to the minimum supported by the system
TIMECAPS tc;
timeGetDevCaps(&tc, sizeof(TIMECAPS));
m_uResolution = min(max(tc.wPeriodMin, 0), tc.wPeriodMax);
timeBeginPeriod(resolution);
// create the timer
m_idEvent = timeSetEvent(
m_elTime,
resolution,
TimerFunction,
(DWORD)this,
TIME_PERIODIC);
}
void CTimersDlg::OnButtonStop()
{
// destroy the timer
timeKillEvent(m_idEvent);
// reset the timer
timeEndPeriod (m_uResolution);
}
void CTimersDlg::MMTimerHandler(UINT nIDEvent) // called every elTime milliseconds
{
// do what you want to do, but quickly
.
.
.
}
void CALLBACK TimerFunction(UINT wTimerID, UINT msg,
DWORD dwUser, DWORD dw1, DWORD dw2)
{
// This is used only to call MMTimerHandler
// Typically, this function is static member of CTimersDlg
CTimersDlg* obj = (CTimersDlg*) dwUser;
obj->MMTimerHandler(wTimerID);
}
上面显示的示例是为了模仿标准 Win32 定时器的处理方式。但实际上,我将多媒体定时器的功能封装在一个单独的类中,并建议您也这样做。
正如我之前提到的,多媒体定时器在一个单独的线程上运行。
可等待定时器
可等待定时器是在 Windows 98 和 Windows NT 4.0 中引入的,它们被设计用于需要阻塞一段时间的线程。这些定时器是内核对象,它们在指定的时间或固定的时间间隔被触发。它们可以指定回调函数(实际上是异步过程调用,或 APC),该函数在定时器被触发时被调用。此回调函数通常称为完成例程。
为了启用完成例程的执行,线程必须处于可警报状态(执行 SleepEx()
, WaitForSingleObjectEx()
, WaitForMultipleObjectsEx()
, MsgWaitForMultipleObjectsEx()
, SignalObjectAndWait()
函数)。实际上,这意味着在使用可等待定时器时,我们的主线程将阻塞。
要开始使用可等待定时器,我们必须打开一个现有定时器,或创建一个新的定时器。创建可以通过调用以下函数来完成
HANDLE CreateWaitableTimer(LPSECURITY_ATTRIBUTES lpTimerAttributes,
BOOL bManualReset, LPCTSTR lpTimerName);
参数
lpTimerAttributes
- 指向SECURITY_ATTRIBUTES
结构的指针,该结构指定了可等待定时器对象的安全描述符。可以为NULL
bManualReset
- 指定可等待定时器是手动重置还是自动重置lpTimerName
- 新定时器的名称。可以为NULL
返回值
- 可等待定时器对象的句柄
另一种可能性是打开一个现有的命名可等待定时器。
现在,当我们有了可等待定时器对象的句柄后,就可以做一些有用的事情了。要设置它,我们将使用函数
BOOL SetWaitableTimer(HANDLE hTimer, const LARGE_INTEGER *pDueTime,
LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine,
LPVOID lpArgToCompletionRoutine, BOOL fResume);
参数
hTimer
- 定时器对象的句柄pDueTime
- 指定何时将定时器状态设置为触发lPeriod
- 定时器的周期(以毫秒为单位),类似于SetTimer()
中的uElapse
pfnCompletionRoutine
- 指向完成例程的指针。可以为NULL
fResume
- 指定在定时器状态设置为触发时是否恢复处于暂停省电模式的系统。
返回值
- 如果函数成功,则返回非零值
最后,这是一个停止可等待定时器的函数
BOOL CancelWaitableTimer(HANDLE hTimer);
参数
hTimer
- 定时器对象的句柄
返回值
- 如果函数成功,则返回非零值
这次的示例将略有不同
void CTimersDlg::OnButtonBegin()
{
.
.
.
// create the timer
timerHandle = CreateWaitableTimer(NULL, FALSE, NULL);
// set the timer
LARGE_INTEGER lElapse;
lElapse.QuadPart = - ((int)elapse * 10000);
BOOL succ = SetWaitableTimer(timerHandle, &lElapse, elapse, TimerProc,
this, FALSE);
for (int i = 0; i < 10; i++)
SleepEx(INFINITE, TRUE);
CancelWaitableTimer(timerHandle);
CloseHandle (timerHandle);
}
void CTimersDlg::WaitTimerHandler() // called every elTime milliseconds
{
// do what you want to do, but quickly
.
.
.
}
void CALLBACK (LPVOID lpArgToCompletionRoutine,
DWORD dwTimerLowValue,
DWORD dwTimerHighValue)
{
// This is used only to call WaitTimerHandler
// Typically, this function is static member of CTimersDlg
CTimersDlg* obj = (CTimersDlg*) lpArgToCompletionRoutine;
obj->WaitTimerHandler();
}
正如您所见,我们现在没有 OnButtonStop()
。一旦我们设置了定时器,我们就必须将调用线程置于可警报状态并等待。这意味着在我们完成定时器之前,我们无法在主线程中执行任何操作。当然,没有什么能阻止我们启动一个单独的工作线程,而该线程不会被阻塞。
关于可等待定时器,我们可以得出什么结论?它们不消耗大量 CPU 时间,也不需要消息队列。主要问题是设置可等待定时器的线程必须将自己置于可警报状态,否则完成例程将永远不会被调用。
队列定时器
本文将介绍的最后一个 Windows 支持的定时器类型是队列定时器。它们是在 Windows 2000 中引入的。
队列定时器是轻量级的内核对象,位于定时器队列中。与大多数定时器一样,它们允许我们指定在指定到期时间到来时调用的回调函数。在这种情况下,操作由 Windows 线程池中的线程执行。
在这里,为了简单起见,我们不会创建自己的定时器队列。相反,我们将把我们的队列定时器放入操作系统提供的默认定时器队列中。
首先,我们需要创建一个定时器并将其添加到默认定时器队列。为此,我们将调用
BOOL CreateTimerQueueTimer(PHANDLE phNewTimer, HANDLE TimerQueue ,
WAITORTIMERCALLBACK Callback, PVOID Parameter, DWORD DueTime,
DWORD Period, ULONG Flags);
参数
phNewTimer
- 指向句柄的指针;这是一个输出值TimerQueue
- 定时器队列句柄。对于默认定时器队列,为NULL
Callback
- 指向回调函数的指针Parameter
- 传递给回调函数的参数DueTime
- 定时器首次触发之前的时间(以毫秒为单位)Period
- 定时器周期(以毫秒为单位)。如果为零,定时器只触发一次Flags
- 以下值的一个或多个(表格摘自MSDN)
WT_EXECUTEINTIMERTHREAD |
回调函数由定时器线程自身调用。此标志仅应用于短任务,否则可能会影响其他定时器操作。 |
WT_EXECUTEINIOTHREAD |
回调函数被排队到一个 I/O 工作线程。如果函数应该在一个等待可警报状态的线程中执行,则应使用此标志。 回调函数被排队为 APC。如果函数执行可警报等待操作,请确保处理重入问题。 |
WT_EXECUTEINPERSISTENTTHREAD |
回调函数被排队到一个永不终止的线程。此标志仅应用于短任务,否则可能会影响其他定时器操作。 请注意,目前没有永久性的工作线程,尽管只要有任何待处理的 I/O 请求,就不会终止任何工作线程。 |
WT_EXECUTELONGFUNCTION |
指定回调函数可以执行长时间等待。此标志有助于系统决定是否创建新线程。 |
WT_EXECUTEONLYONCE |
定时器只触发一次。 |
返回值
- 如果函数成功,则返回非零值
回调函数实际上非常简单
VOID CALLBACK WaitOrTimerCallback(PVOID lpParameter, BOOLEAN TimerOrWaitFired);
参数
lpParameter
- 指向用户定义数据的指针TimerOrWaitFired
- 对于定时器回调,始终为TRUE
要取消队列定时器,请使用函数
BOOL DeleteTimerQueueTimer(HANDLE TimerQueue, HANDLE Timer, HANDLE CompletionEvent);
参数
TimerQueue
- (默认)定时器队列的句柄Timer
- 定时器的句柄CompletionEvent
- 一个可选事件的句柄,当函数成功且所有回调函数完成时将被触发。可以为NULL
。
返回值
- 如果函数成功,则返回非零值
队列定时器的示例将在下面给出
void CTimersDlg::OnButtonBegin()
{
.
.
.
// create the timer
BOOL success = ::CreateTimerQueueTimer(
&m_timerHandle,
NULL,
TimerProc,
this,
0,
elTime,
WT_EXECUTEINTIMERTHREAD);
}
void CTimersDlg::OnButtonStop()
{
// destroy the timer
DeleteTimerQueueTimer(NULL, m_timerHandle, NULL);
CloseHandle (m_timerHandle);
}
void CTimersDlg::QueueTimerHandler() // called every elTime milliseconds
{
// do what you want to do, but quickly
.
.
.
}
void CALLBACK TimerProc(void* lpParametar,
BOOLEAN TimerOrWaitFired)
{
// This is used only to call QueueTimerHandler
// Typically, this function is static member of CTimersDlg
CTimersDlg* obj = (CTimersDlg*) lpParametar;
obj->QueueTimerHandler();
}
如您所见,队列定时器非常容易使用。我还可以补充一点,它们非常精确且“资源友好”。
正如我在本章开头提到的,队列定时器在 Windows 2000 及更高版本上受支持。如果您不想支持旧版 Windows,它们是完美的,并且应该取代多媒体定时器。
结论
整个故事的寓意是什么?
当您决定在应用程序中需要定时器时,在不同定时器变体之间进行选择应该不难。请遵循以下简单规则
- 如果您希望您的应用程序在所有 32 位 Windows 平台上运行,不需要高精度,并且回调操作足够快,不会影响 UI 响应能力,请使用标准的 Win32 定时器。
- 如果您希望您的应用程序在所有 32 位 Windows 平台上运行,并且需要高精度,请使用多媒体定时器。
- 如果您希望您的应用程序在 Windows 98/NT4 及更高版本上运行,需要较低的系统开销,并且可以承受阻塞调用线程,请使用可等待定时器。
- 如果您想要一个高精度、低开销、非阻塞定时器,可以在 Windows 2000 及更高版本上运行,请使用队列定时器。