针对windows系统的高精度定时器

时间:2022-06-01 18:06:07

背景

  在实际工作过程中,比如与硬件交互、媒体视频播放、性能分析以及多线程任务同步时,可能需要在windows平台下实现ms级别精度的定时器。然而,这种需求虽然存在,但是由于windows系统并不是一个实时操作系统,实现这种精度的定时器,并不是一件容易的事情。

Windows 不是实时操作系统,所以任何方案都无法绝对保证定时器的精度,只是能尽量减少误差。所以,系统的稳定性不能完全依赖于定时器,必须考虑失去同步时的处理。

等待策略

  要想实现高精度定时器,必须需要等待和计时两种基础功能,等待用来跳过一定时间间隔,计时可以进行时间检查,用以调整等待事件。
等待策略实际就是两种:
1. 自旋等待:让CPU空转消耗时间,占用大量CPU时间,但是时间高度可控。
2. 阻塞等待:线程进入阻塞状态,让出CPU时间片,在等待一定时间后再由操作系统调度回到运行状态。阻塞时不占用CPU,然而需要操作系统调度,时间难以控制。
  可以看到二者各有优劣,应该按照不同需求进行不同的实现。
所以难点在于等待策略,下面先分析简单的自旋等待。

自旋等待

伪代码如下:


等待开始时间=当前计时
while((当前计时-等待开始时间)<需要等待的时间)
{
自旋;
}

阻塞等待

  阻塞等待会把控制权交给操作系统,这样就必须确保操作系统能够及时的将定时器线程调度回运行状态。默认情况下,Windows的系统定时器精度为15.625ms,也就是说时间切片是这个尺寸。如果线程阻塞,出让其时间片进行等待,再被调度运行的时间至少是一个切片15.625ms。那么必须减少时间切片的长度,才能实现更高的精度。
 一般实现阻塞等待都用的是thread库的sleep函数,实测sleep函数的精度不高,估计也就ms级的,也就是可能会产生+1ms的误差。
 sleep会出让剩余的cpu时间片给优先级相同的线程,而yield则是出让剩余的cpu时间片给运行在同一核心上的线程。在出让的时间片结束后,其会被重新调度。一般情况下,整个过程可以在1ms内完成。

另外,sleep或者yield在CPU高负载情况下非常不稳定,实测可能会阻塞高达几ms的时间,所以可能会产生更多的误差,因此误差修正最好采用自旋方式实现。

定时器实现

  需要注意的是,无论自选还是阻塞,显然定时器都应该运行在独立的线程,不能干扰使用方线程工作。而对于高精度定时器而言,触发事件以执行任务的线程一般都在定时器线程内,而不是再使用独立的任务线程。
  这是因为在高精度定时场景下,任何任务的时间开销很可能大于定时器的时间间隔,如果默认就在其他线程执行任务,可能导致占用大量线程。所以应该把控制权交给用户,让用户在需要的时候自行调度任务执行的线程。

触发模式

  由于在定时器线程执行任务,所以定时器的触发就产生了三种模式。以下是三种模式的伪代码:

  1. 固定时间框架
    比如间隔10ms,任务 7-12ms,则会按照等待 10ms 、任务 7ms、等待 3ms、任务 12ms(超时 2ms 失去同步)、任务 7ms、等待 1ms(回到同步)、任务 7ms、等待 3ms、… 进行。就是尽量按照设定好的时间框架来执行任务,只要任务不是始终超时,就可以回到原本的时间框架上。
var 下一帧时间=0
while(定时器开始)
{
下一帧时间+=间隔时间
while(当前时间<下一帧时间)
{
等待;
}
触发任务;
}

2.可推迟时间框架
上面的例子会按照等待 10ms 、任务 7ms、等待 3ms、任务 12ms(超时,推迟时间框架 2ms)、任务 7ms、等待 3ms、… 进行。超时的任务会推迟时间框架。

var 下一帧时间 = 0;
while(定时器开启)
{
下一帧时间 += 间隔时间;
if (下一帧时间 < 当前计时)
下一帧时间 = 当前计时
while (当前计时 < 下一帧时间)
{
等待;
}
触发任务;
}

3.固定等待框架
上面的例子会按照等待 10ms、任务 7ms、等待 10ms、任务 12ms、等待 10ms、任务 7ms… 进行。等待时间始终不变。

while(定时器开启)
{
var 等待开始时间 = 当前计时;
while ((当前计时 - 等待开始时间) < 间隔时间)
{
等待;
}
触发任务;
}
// 或者:
var 下一帧时间 = 0;
while(定时器开启)
{
下一帧时间 += 间隔时间;
while (当前计时 < 下一帧时间)
{
等待;
}
触发任务;
下一帧时间 = 当前计时;
}

  在while循环中的等待可以使用自旋或阻塞,也可以结合它们来达到精度、稳定性和 CPU 开销的平衡。
  另外,由上面的伪代码可以看出,这三种模式的实现可以统一,能够做到根据情况切换。

实现

  为了实现高精度的定时器,我们不采用阻塞模式(sleep函数和yield函数),而是采用QueryPerformanceFrequency()和QueryPerformanceCounter()函数。这两个函数是Visual C++提供并且仅供Windows 95及其后续版本使用,其精度与CPU的时钟频率有关,它们要求计算机从硬件上支持精确定时器。。QueryPerformanceFrequency()函数和QueryPerformanceCounter()函数的原型如下:

BOOL QueryPerformanceFrequency(LARGE_INTEGER *lpFrequency);
BOOL QueryPerformanceCounter(LARGE_INTEGER *lpCount);

上述两个函数的参数的数据类型LARGE_INTEGER既可以是一个8字节长的整型数,也可以是两个4字节长的整型数的联合结构,其具体用法根据编译器是否支持64位而定。该类型的定义如下:

typedef union _LARGE_INTEGER
{
struct
{
DWORD LowPart; // 4字节整型数
LONG HighPart; // 4字节整型数
};
LONG QuadPart; // 8字节整型数
}LARGE_INTEGER;

使用QueryPerformanceFrequency()和QueryPerformanceCounter()函数进行精确定时的步骤如下:

  1. 首先调用QueryPerformanceFrequency()函数取得高精度运行计数器的频率f,单位是每秒多少次(n/s),此数一般很大;
  2. 在需要定时的代码的两端分别调用QueryPerformanceCounter()以取得高精度运行计数器的数值n1、n2,两次数值的差值通过f换算成时间间隔,t=(n2-n1)/f,当t大于或等于定时时间长度时,启动定时器;
    下面给出一个简单的计算时间的例子:
LARGE_INTEGER m_liPerfFreq={0};
//获取每秒多少CPU Performance Tick
QueryPerformanceFrequency(&m_liPerfFreq);
LARGE_INTEGER m_liPerfStart={0};
QueryPerformanceCounter(&m_liPerfStart);
for(int i=0; i< 100; i++)
cout << i << endl;
LARGE_INTEGER liPerfNow={0};
// 计算CPU运行到现在的时间
QueryPerformanceCounter(&liPerfNow);
int time=( ((liPerfNow.QuadPart - m_liPerfStart.QuadPart) * 1000)/m_liPerfFreq.QuadPart);
char buffer[100];
sprintf(buffer,"執行時間 %d millisecond ",time);
cout<<buffer<<endl;

下列再由此实现1ms的精确定时:

  LARGE_INTEGER litmp; 
LONGLONG QPart1,QPart2;
double dfMinus, dfFreq, dfTim;
QueryPerformanceFrequency(&litmp);
dfFreq = (double)litmp.QuadPart;// 获得计数器的时钟频率
QueryPerformanceCounter(&litmp);
QPart1 = litmp.QuadPart;// 获得初始值
do
{
QueryPerformanceCounter(&litmp);
QPart2 = litmp.QuadPart;//获得中止值
dfMinus = (double)(QPart2-QPart1);
dfTim = dfMinus / dfFreq;// 获得对应的时间值,单位为秒
}while(dfTim<0.001);

  根据上述定时器,再结合之前的定时与任务的框架,就可以实现高精度的定时器,精度可以达到us级别。基本上可以满足大部分的对实时要求很高任务了。