高精度定时器

实现 .NET 上的高精度定时,详细的原理和实现方式说明见博客。这里仅描述使用方法。

安装

Tundra 小功能在GKarch.Tundra.Misc包中,使用 NuGet 方式通过包管理器 UI 或者命令行安装:

PM> Install-Package GKarch.Tundra.Misc

然后可以在 console 工程中使用下面代码试验(需要using GKarch.Tundra.Timers):

var last = 0d;
var watch = new Stopwatch();    // 用来计时
var timer = new HybridTimer();  // 混合定时器
timer.Tick = t => {             // 每次触发执行的委托
    var current = watch.Elapsed.TotalMilliseconds;
    var delta = current - last;
    last = current;
    Console.Write("{0:#.00}\t", delta);  // 输出本次执行与上一次执行的时间间隔
};
timer.Start();
watch.Start();
Console.ReadLine(); // 回车结束

这就是HybridTimer默认的设置,使用固定时间框架模式、10ms 间隔、自旋等待

使用

在命名空间GKarch.Tundra.Timers中,提供了两种定时器HybridTimerMmTimerHybridTimer可以混合使用阻塞与自旋完成高精度定时,而MmTimer是对系统多媒体定时器的封装。它们都实现了ITimer接口:

/// <summary>定时器接口</summary>
public interface ITimer : IDisposable
{
    /// <summary>是否启用</summary>
    bool Enabled { get; set; }

     /// <summary>触发间隔时间</summary>
    int Interval { get; set; }

    /// <summary>开始</summary>
    bool Start();

    /// <summary>停止</summary>
    void Stop();

    /// <summary>触发时的事件</summary>
    Action<ITimer> Tick { get; set; }
}

这个接口与一般定时器差不多,应该很容易理解。需要注意Tick没有使用event关键字作为标准的事件,是因为高精度定时的场景下,一般不需要用到标准事件那种更“重”的设施,单纯的委托就可以了。

然后,Interval属性对于不同的定时器有不同的单位,对于HybridTimer是微秒(μs),对于MmTimer是毫秒(ms)。

MmTimer

MmTimer类是对系统多媒体定时器的封装,使用了timeSetEvent / timeKillEventAPI 来实现高精度定时。它的功能由系统提供,精度和稳定性都不错,但是不能进行更多的控制。

可以把前面例子中构造HybridTimer改为MmTimer,也可以通过构造方法的interval参数或者Interval属性来设置时间间隔,比如:

var timer = new MmTimer(1); // 1ms 多媒体定时器

Console.Write的时间并不稳定,可能会超过 1ms 时间,影响输出结果。

多媒体定时器运行在系统提供的线程(不能命名,不方便调试),只能使用固定时间框架模式阻塞等待。它在需要 1ms 精度,又不能自旋的场景下很适合使用。但是如果需要其它触发模式、自旋等待或者更细致的控制,则可以使用混合定时器HybridTimer

HybridTimer

HybridTimer可以进行更多的控制,它除了ITimer接口外,还实现了IHybridTimer接口:

/// <summary>混合定时器接口</summary>
public interface IHybridTimer : ITimer
{
    /// <summary>触发模式</summary>
    TriggerMode TriggerMode { get; set; }

    /// <summary>阻塞模式</summary>
    BlockMode BlockMode { get; set; }

    /// <summary>线程优先级</summary>
    ThreadPriority Priority { get; set; }

    /// <summary>停止并等待定时器结束</summary>
    bool StopAndWait(int timeout);

    /// <summary>中止定时器线程</summary>
    void Abort();  // 危险方法,一般不应使用 !
}

HybridTimer的时间单位是微秒(μs),也就是 1000 代表 1ms。这是因为自旋等待可以实现低于 1ms 的精度。

触发模式

触发模式TriggerMode是一个枚举,包含以下三个值:

  • FixedFrame:固定时间框架(默认值)
  • DelayableFrame:可推迟时间框架
  • FixedWait:固定等待时间

还是之前的例子,在创建timer实例后添加以下代码:

timer.TriggerMode = TriggerMode.FixedWait;  // 固定等待时间

运行可以看到输出的结果会大于 10ms,这是因为固定 10ms 的等待时间,触发时进行的计时和控制台输出占用的时间会独立出来。

阻塞模式

阻塞模式BlockMode是一个枚举,包含以下四个值:

  • None:不进行阻塞,自旋等待(默认值)
  • Sleep:使用Thread.Sleep进行阻塞
  • WaitHandle:使用WaitHandle.WaitOne进行阻塞
  • Poll:使用Socket.Poll进行阻塞

继续之前的例子,在创建timer实例后添加以下代码:

timer.BlockMode = BlockMode.WaitHandle;  // 使用等待句柄阻塞

然后可以调节Interval的值查看效果(注意:单位是微秒)。

之所以提供这么多方式是因为需求场景可能很多,机器的软硬件环境也不一样,可以针对性的使用不同的阻塞方式,以达到最好的效果。但是对于 1ms 左右的精度,这三种阻塞都是力不从心的,必须使用MmTimer或者默认的None自旋来实现。

线程优先级

HybridTimer会开启独立的定时器线程,定时、计时还有Tick事件会在这个独立的线程上完成。Priority属性的默认值是ThreadPriority.Highest最高优先级(详见这里)。

配置与约定

配置

依赖于 Glacier 的配置功能,使用timer:pollPort设置Poll阻塞模式绑定的端口,例如:

<configuration>
  <appSettings>
    <add key="glacier:abbr" value="g"/>
    <add key="g:timer:pollPort" value="7777"/>
  </appSettings>
</configuration>

IoC / DI 约定

依赖于 Glacier 的IoC / DI 功能,注册如下服务:

服务 实现 名称
MmTimer MmTimer -
HybridTimer HybridTimer -
ITimer HybridTimer -
ITimer MmTimer mm
ITimer HybridTimer自旋模式 spin
ITimer HybridTimerSleep 阻塞 sleep
ITimer HybridTimerWaitHandle 阻塞 waithandle
ITimer HybridTimerPoll 阻塞 poll

使用时,例如:

var timer = GlacierCore.Resolver.Get<ITimer>("poll");

就是创建一个Poll方式阻塞的HybridTimer定时器,其它设置为默认值(10ms 间隔,固定时间框架),之后可以修改。

高级说明

高精度定时器的实现有一定复杂性,了解更多细节有助于使用。

微调

MmTimer依赖于系统,无法微调,而HybridTimer可以支持进行一定微调。在IHybridTimer接口上,还有一个属性:

/// <summary>保留用来进行自旋的时间</summary>
int? SpinReserve { get; set; }

事实上,之所以叫做“混合”定时器,就是因为它可以混合使用阻塞和自旋,在不消耗太多 CPU 的前提下达到高精度。所以在使用三种阻塞模式时需要控制使用多少时间进行自旋,值太大会导致退化为始终自旋,而值太小会因为阻塞的不确定性使精度下降。

默认情况下,阻塞模式内置了经过测试比较符合大部分场景的值,但由于环境的差异,可能有并不合适的情况。这时可以调节SpinReserve的值。

设置为null意味着使用默认值,其它值就代表保留用来进行自旋的时间,单位是微秒。一般可以从 500 到 3000 的范围进行调整,根据实测结果使用。

另外,在ITimer接口上,还有另一个触发事件属性:

/// <summary>触发时的事件</summary>
Func<ITimer, int> TickEx { get; set; }

这个属性需要的委托必须返回一个数字,它可以对定时器下一次触发时间进行调整。准确的说,它会和下一次触发时间相加,也就是如果为正,会推迟触发时间,为负则加快下次触发。它的用途是可以根据任务的时长来对定时器进行一定补偿,以调整触发的同步状态。

MmTimerHybridTimer都是单线程定时器,意味着任务都是在定时器线程上运行,如果任务时长超过了定时器间隔时间,那么下次触发就会推迟。使用者如果需要多线程执行任务,可以自行使用独立线程或线程池线程实现。

它们使用后台线程,不会阻止程序结束。它们所有的方法都是线程安全的。

生命周期

可以看到ITimer实现了IDisposable接口,所以可以使用Dispose方法销毁定时器实例。需要注意的是,如果定时器实例超出作用域,则不久就会被 GC 自动销毁,所以如果需要保证定时器工作,就要保持对它实例的引用。

由于定时器使用了系统资源,并且提升了系统时钟精度,所以最好在不需要的时候手动调用Dispose销毁,而不是等待 GC 完成。

MmTimer使用的系统多媒体定时器,会在开启时创建,停止 / 销毁时被释放。

HybridTimer中,使用的线程资源会在开启时创建,停止 / 销毁时被释放;如果使用WaitHandle的阻塞模式,则会使用ManualResetEvent资源,与定时器线程的生命周期相同;如果使用Poll的阻塞模式,则在定时器启动时会绑定 TCP localhost:7777 端口(可配置),目前的实现中会持续到程序结束。

异常

如果任务中抛出未处理的异常,定时器会通过 Glacier 的日志功能记录,并重新抛出该异常,这会导致定时器线程结束,定时器停止。

下一篇