你为什么会对获得小于1毫秒精度的系统时间感兴趣?在我工作期间,我发现有必要去确定我的进程里不同线程执行引发的事件的顺序。还需要把这些事件同绝对时间相 关联,但注意到系统时间的实际精度是不会超过10毫秒粒度的。
在本文随后的内容中,我将解释该系统时间精度的限制,解决的步骤,以及某些一般缺陷。例子程序的实现可以从本文开始链接处下载。这些文件的源代码是在 Visual C++? 7.1 和 Windows? XP 专业版下编写测试的。在编写本文时,我频繁地提到 Windows NT? 操作系统家族(Windows NT 4.0, Windows 2000, 或者 Windows XP)产品,而不是某一个特定的版本。 本文中用到的 Win32? APIs 的参数类型及用法,参见 MSDN library/Platform SDK 文档。
究竟谁有这样的需求?
最近我用“Windows NT millisecond time resolution”作为关键字在 Internet 上搜索了一番, 得到了 400 多个满足条件的结果。其中大多数是讨论如何获得高于10毫秒精度的系统时间,或者是如何让一个线程的休眠时间小于10毫秒。本文我将专注于为什么获得一个高于10毫秒精度的系统时间 会如此困难。你可能认为用 GetSystemTime API 很容易解决问题,这个 API 函数返回一个SYSTEMTIME 结构,该结构包含一个 wMilliseconds 域,在 MSDN 文档中说它保存 当前的毫秒时间。但实际上并不象这么简单。那么用 GetSystemTimeAsFileTime 获取 100 纳秒的精度如何呢?就让我们从一个小试验 开始吧:尝试重复获取系统时间,将它格式化并输出到屏幕上(见 Figure 1 )。
我的目标不是纳秒,而仅是毫秒精度,它应该能够从 SYSTEMTIME 结构中判断。让我们看一下输出结果:
01.
20:12:23.479
02.
20:12:23.479
03.
20:12:23.494
04.
20:12:23.494
05.
[...有很多被移去了...]
06.
20:12:23.494
07.
20:12:23.509
08.
20:12:23.509
09.
20:12:23.509
10.
...
正如你所看到的,我所能得到的最好的精度是15毫秒,这是 Windows NT 时钟周期的长度。每过一个时钟周期,Windows NT都会更新系统时间。Windows NT调度器也会 突然启动并可能选择一个新的线程来执行。关于这方面的更多信息,请看《Inside Windows 2000》第三版(Microsoft Press?, 2000),作者是 David Solomon 和 Mark Russinovich。
如果你运行我刚才所示的代码,你也许会看到时间大约是每10毫秒更新一次。如果是那样,可能意味着你是在单处理器的机器上运行 Windows NT,其时钟周期通常为10毫秒。正如你所看到的, 在这种方法中,系统时间更新频率不够快,不足以成为一种为我所用的技术。下面我们就来尝试找一个解决方案。
最初的尝试
当你询问如何得到一个比10毫秒精度更好的系统时间时,你也许会得到下面这样的回答:使用性能计数器,并让性能计数器值和即时变化的系统时间同步。结合这些值来计算一个 精度极高的当前时间。Figure 2 显示了实现方法。
性能计数器是一个高精度的硬件计数器,它能高精确、低开销地计量一个短周期时间。我通过在一个紧凑循环内不断重复把性能计数器值和对应的系统时间进行同步,等待系统时间变化。当系统时间 以变,我就保存计数器的值及系统时间。
使用这两个值作为参考,就有可能计算出一个高精度的当前系统时间(详情见 Figure 2 中的get_time),看一下结果:
01.
...
02.
21:23:22.296
03.
21:23:22.297
04.
21:23:22.297
05.
21:23:22.298
06.
21:23:22.298
07.
21:23:22.299
08.
21:23:22.300
09.
21:23:22.300
10.
21:23:22.301
11.
21:23:22.301
12.
21:23:22.302
13.
21:23:22.302
14.
21:23:22.303
15.
...
尽管它看起来非常成功,但这个实现却有几个问题:同步实现(函数被命名为 "simplistic_synchronize"的一个很好的理由);QueryPerformanceFrequency 报告的频率 ;系统时间变化缺乏保护。在接下来的章节中,我们会考虑这些问题的一些可能的改进。
实现同步的可靠方法
该同步实现没有考虑 Windows NT 调度器的抢先问题。例如,它无法保证在下面的两行代码之间不会发生线程上下文的切换,从而导致一个未知时间周期的延迟:
1.
::GetSystemTimeAsFileTime(&ft1);
2.
::QueryPerformanceCounter(&li);
大多时候只要满足下面的条件,这个过分单纯化的同步函数还是成功的:
当前线程不会被优先级更高的线程抢先进入就绪状态;
当前线程的时间片永远不会结束
很少有硬件中断(不同于时钟中断自身)
为此,最简明的解决方案是将进程的优先级提升为 REALTIME_PRIORITY_CLASS,将线程的优先级提升为 THREAD_PRIORITY_TIME_CRITICAL,从而阻止在同步期间线程被抢先。不幸的是,对于硬件中断你没有什么可做的,但行为良好的驱动程序应该处理它们的中断,排队延期的过程调用(deferred procedure call, DPC),甚至以微秒级次序处理DPC。问题是你不能保证系统内所有驱动程序的行为都良好。事实上,即使在你系统里只有乖巧听话的驱动程序,你仍然会有许多中断。
尽管如此,我们还是有一个可靠的同步方法,不必提升进程和线程的优先级。Figure 3 是这个方法实现步骤的基本流程图
Figure 3 可靠的同步
你需要不断地检查看系统时间是否变化,就像 Figure 2 所示的 simplistic_synchronize 实现一样。同先前实现的最大不同之处是你现在也要用性能计数器本身去验证你始终保持于希望的精确级别。这听起来很简单,但仔细看看 Figure 3 便会看出它并不像想象的那么简单。需要进一步的解释,比如你为什么在 prev_diff 变量中保存性能计数器最近两个值之间的差异。原因是从系统时间被保存到t1的点到计数器值被保存到p1的点,系统时间可能会有潜在的变化而没有被检测到,直到下一次内部循环执行(才能检测到系统时间变化)。
接下来,你可能错误地假设在最新的两个计数器值之间(注:P1->P0)时间变化了,而实际上却没有。为了对此进行安全保证,你应该假定系统时间变化要么在最新的两个计数器值(注:即prev_diff)之间;要么在先前的两个计数器值之间(除了在循环内部发生了不可能的事件――该事件通过内部循环改变了最开始的时间)。在同步末尾,实现一个计数器值的调整;这可以保证返回值能够在希望的精度之内。Figure 4 显示了这个过程
Figure 4 计算
这个同步方法需要多次迭代完成,但实际上还不能证明有问题。有关同步的更多信息及其精确性,你应该看一下本文副题 “同步:有多好?”。
频率问题
尽管我们已经有了一个好的开端,仍有些问题需要解决。假设你及时在某些特定环节执行这个同步操作。然后,无论何时,只要你需要高精度的时间,就调用get_time。如早先讲述,QueryPerfomanceCounter 报告的频率被用于以高精度计算当前系统时间。由 get_time 报告的时间一定会同实际的时钟时间发生很大偏差的,得到一个比你所获得的精度大得多的值。这是因为性能计数器天生不是被用来计量长周期时间的。
我进行了一个小测试考察这个影响会有多大,以 2 微秒作为可接受的同步极限。(我选择 2 微秒是因为我的 双 PII 400HZ CPU 机子能得到最好结果),Figure 5 显示了这个结果。
Figure 5 同步测试
测试结果证明,仅在 110 秒后我的高精度时钟就偏离了实际系统时间1毫秒。速算一下表明性能计数器频率报告中存在一个大约百万分之九 的错误。一个0.000009的错误听起来很微小,但是如果你想报告一个微秒以下的时间这就是一个很大的冲击了。起初,我有两个想法。第一,用户负责定期的再同步,而且由此必须决定多长时间做一次。第二,同步由一个后台线程每n秒执行一次。
进行第一个想法的测试之前,我就决定反对它了。第二个想法似乎更加可行,我所能预见的唯一问题就是在客户端和同步线程之间的必须的同步会产生一些开销。使用上的简单总是会增加复杂性和开销的。
本文提供的下载例子中实现了用后台线程来同步性能计数器和系统时间。Figure 6 解释了这个实现如何设法让自己同实际系统时间保持接近(注意纵坐标现在被设为+/-100微秒)。
Figure 6 同步例子
Figure 6 显示了某个 13 分钟时段高精度时间偏离系统时间的情况。蓝线显示的是在偏离值达到所允许的系统时间偏离值(本例子中是 50 微秒)之前应用周期性再同步的情况。它也表明每次执行后在同步之间的时间增加值。这是因为当前实现的时间供应器适应了性能计数器所报告的频率计量错误,并不断地将之应用到内部的高精度时间计算上。
虽然蓝线显示的数据应用了平滑过滤,黄线显示了与系统时间偏差的原始数据。这个过滤是实时完成的,并且这是实际用于决定性能计数器真正频率以及高精度时间与系统时间之间偏离的数据。更多细节,请见下载的源代码。
防止系统时间受到更改
另外还有系统时间变化的问题。无论何时发生这种事情,你必须立即再同步以便保证计算的时间是正确的。在 Windows 2000 和 Windwos XP 下这到这一点并不困难,因为每当设置系统系统时间时,系统总会广播一个 WM_TIMECHANGE 消息到所有的顶层窗口。不幸的是,在 Windows NT 以及更早的版本这不是被强制的,尽管在 SDK 文档中确实如是说:在改变系统时间后,应用程序应该发送这个消息到所有的顶层窗口。注意这个句子使用的是“应该”,所以你不能依赖每个人都这么做。
为了透彻地理解这个问题,我应该说改变系统时间对于任何应用程序来说不什么特别的事情。为了改变系统时间或相关的配置,需要启用 SE_SYSTEMTIME_NAME 优先权。如果用户没有启用这个权利,你可以在一个管理员帐户下运行程序,要管理员将这个程序安装为 Windows NT服务,或者要管理员给运行该程序的帐户一个必须的权限。例如,对于 Windows NT 4.0 而言,你最希望的是系统管理员不会或者不允许安装病态程序(即改变系统时间而不知会其它应用程序)。
所以你如何实际处理 WM_TIMECHNAGE 消息呢?既然你已经有了一个用于周期性同步的线程,唯一你要做的事情就是让你的线程创建一个不可见的顶层窗口,并且,除了定期同步外还要运行一个消息循环。
时间调整
与 Windows NT 维护系统时间有关的还有另外一个问题。为了帮助软件例如网络时间协议(Network Time Protocol, NTP)客户端同外部资源保持时间同步,Windows NT 暴露了一个SetSystemTimeAdjustment API。这个API有两个参数,以100纳秒为单位的时间调节器本身以及一个布尔值,它指示 Windows NT 是否禁用时间调节器。当启用时间调节器时,系统会在每个时钟中断时加上指定的时间调节器的值。当禁用时,系统会用添加缺省的时间增量取而代之(在本文中它与时钟间隔一样),更多详情见平台SDK文档。
但是还有两个问题。首先启用(改变)时间调节器改变了参考频率――时间流。第二,也是一个较大的问题,就是当系统时间被修改后,系统不发送启用或禁止通知。即使以最小的 156250 个单位(1单位100纳秒)缺省时间增量改变某个系统上的时间调节器,也将导致参考频率 6.4PPM (1/156250) 的改变。再一次的,听起来可能不多,但是考虑一下你要在50微秒内阻止系统时间起变化,那意味着几秒之后如果没有进行再同步,你就会超过极限。
为了减少这类调整的冲击,时间供应器必须监视当前时间调节器的设置。不用借助于操作系统本身,通过调用SetSystemTimeAdjustment 的伙伴 API GetSystemTimeAdjustment 来实现。在足够短的间隔内不断地执行这个检查并且根据需要调整内部频率,你就能够避免偏离系统时间太远。
时间供应器
现在你已经对问题的各个方面有了较好的理解,我将对下载代码中的 time_provider 类作一个简单介绍。这个时间供应器是以参数化单模式方式实现的,为客户提供了一个高精度,持续更新的时间:
01.
template
<
02.
typename
counter_type,
03.
int
KEEP_WITHIN_MICROS = 100,
04.
int
SYNCHRONIZE_THREAD_PRIORITY = THREAD_PRIORITY_BELOW_NORMAL,
05.
int
TUNING_LIMIT_PARTSPERBILLION = 100,
06.
int
MAX_WAIT_MILLIS = 10000,
07.
int
MIN_WAIT_MILLIS = 100
08.
>
09.
class
time_provider
使用时间供应器获得当前时间类似于使用 Windows API:
1.
typedef
hrt::time_providertime_provider_t;
2.
time_provider_t& provider=time_provider_t::instance();
3.
SYSTEMTIME st;
4.
provider.systemtime(&st);
Figure 7 解释了time_provider 类可用的模板参数、类型定义和成员函数。你可能会对将不同的调谐参数被指定为模板参数感到奇怪。从我的观点来看,他们全都是设计参数,并且可以在编译时,根据你的应用程序的需求来确定。
Figure 8 的代码示范了使用 time_provider 类在一个小循环中收集原始时间的例子,然后转换和输出。在下载的源代码中你可以找到另外一个使用多线程的例子(在多线程环境中示范了同样的想法)。
性能因子
那么使用 time_provider 类获得系统时间的开销有多大呢?当你必须计算时间而不只是获取时间时,一些额外的工作是不可避免的。如果你确实关心代码某些临界部分中的性能,使用 Figure 8 中所示的涉及原始计数器值的技术。使用原始值让你延迟系统时间转化,这样不会立即产生额外的开销(调用收集计数器本身的值除外,当然,这是不可避免的)。
Figure 9 的表格中显示得很清楚,它给出了一个 Win32 API 相对于 time_provider 的性能评估。表格中的数字是相对于在Windows XP 对称多处理器(SMP)系统上 GetSystemTime 执行时间的百分比(括号中的数字对应单处理器系统)。
我在本文前面曾提到,调用 QueryPerformanceCounter 的代价是不能忽略的,对于单处理器系统尤其如此。使用性能计数器API 调用的执行时间在对称多处理系统上(SMP)通常要快得多。这是因为大多数对称多处理系统的性能计数器中都实现了奔腾时戳计数器(time stamp counter, TSC),与单处理器系统实现比较调用开销相对较低。
对于性能我稍微有点失望,即便没有努力去优化计算。为了获得较好的性能而丧失了可移植性,你可能尝试使用其它计数器。time_provider 类在计数器类型上是参数化的,可用于其它高精度计数器。下载的源代码中还有另外一个实验类 tsc_counter ,可以直接使用奔腾 TSC 。对这个类的初步测试表明:它比使用性能计数器 API 有好得多的性能,甚至是(比性能计数器)在SMP机器上。当进行与 Figure 9 中同样的测试时,tsc_counter 版本的时间供应器时钟在 33%(文件时间),133%(系统时间)和 5.9%(原始时间)。
未来方向
当前的实现还有许多潜在的问题――鉴于问题的复杂性,对此不要感到惊讶。由于硬件兼容性所引起的问题,该代码不可以用在任何可获得的系统上,比如省电,CPU 超频以及非持续性计数器。如果在这些条件下你找到办法使这个供应器更可靠,请让我知道。在决定使用该代码之前你应该知道你的硬件平台。
为 .NET 和 COM 进行包装肯定是可行的,允许时间供应器在除了C++语言之外语言中使用。实际上我已经实现了一个作为 COM 进程内服务器的时间供应器。
结论
如果你现在认为你可以获得几乎任意精度的系统时间,给一个小警告:不要忘记像 Windows NT 这样的抢先式多任务系统,最好的情况下,你获得的时戳仅仅是读取性能计数器所花时间并将所读内容转化为绝对时间的时间差。最坏的情况下,时间流失会很容易地达到数十毫秒之多。
尽管这有可能预示着你所作的一切都毫无用处,但同时也不见得真的就如此。即使执行对 Win32 API GetSystemTimeAsFileTime (或者 Unix 下的 gettimeofday)的调用也受制于同样的条件,所以你实际做的不会比那更遭。在大多数情况下,你会得到好的结果。只是不要对基于 Windows NT 的时间戳有任何实质性的预言。
背景知识
时间函数(Time Functions) :Inside Windows 2000,第三版,作者 David Solomon 和 Mark Russionvich(Microsoft Press, 2000)
性能计数器值可能会意外地向前跳跃(Performance Counter Value May Unexpectedly Leap Forward)
结束语
性能计数器(Performance Counter)的一些介绍:
在一些计算机硬件系统中,包含有高精度运行计数器,利用它可以获得高精度定时间隔,其精度与 CPU 的时钟频率有关。采用这种方法的步骤如下:
1、首先调用 QueryPerformanceFrequency 函数取得高精度运行计数器的频率f。单位是每秒多少次(n/s),此数一般很大。
2、在需要定时的代码的两端分别调用 QueryPerformanceCounter 以取得高精度运行计数器的数值n1,n2。两次数值的差值通过f换算成时间间隔,t=(n2-n1)/f。
作者简介
Johan Nilsson是在 Esrange 的瑞士空间公司的一个系统工程师,位于北极圈之上。自从Windows NT 4.0发布以来他就一直使用C++为Windows NT开发软件,从Windows 3.1起为Windows/DOS编程。和他联系:johan.nilsson@esrange.ssc.se
联系客服