C#中的高分辨率计时器

21

是否有一个高分辨率计时器可以在定时器到期时引发事件,就像System.Timer类一样?我需要一个高分辨率计时器,以每1毫秒触发一次Elapse事件。

我一直看到一些文章解释说Stopwatch可以测量高分辨率,但是我不想测量时间,我想创建一个每1毫秒的间隔。

.NET中是否有相应的东西或者我需要编写自己的高分辨率计时器?


当你使用winforms时,工具箱中有一个计时器。你可以将其间隔设置为1毫秒。还需要什么? - Matin Lotfaliee
你每毫秒想要做什么?毫秒并不是很长的时间。 - nvoigt
@MatinLotfaliee 我需要它确实在1ms的时间间隔内执行,但它现在没有。 - bas
“Tact time”是什么意思?一辆公交车不可能每毫秒都在不同的站点停靠... - Jon Skeet
1
另外,https://dev59.com/wG025IYBdhLWcg3wChOc#6254753 - John Smith
显示剩余4条评论
5个回答

27

据我所知,.NET框架中没有内置此功能。Windows通过Multimedia Timer API提供了高分辨率的定时器事件机制。下面是我快速编写的一个示例,看起来可以胜任工作。这里还有一个不错的示例

需要注意的是,此API更改系统范围的设置可能会降低系统性能,请谨慎使用。建议在测试过程中跟踪定时器触发的频率,以验证定时与要模拟的设备的计时相似。由于Windows不是实时操作系统,您系统上的负载可能会导致MM定时器延迟,从而在100毫秒的间隔中包含100个快速连续的事件,而不是间隔1毫秒的100个事件。关于MM定时器的一些更多阅读材料。

class Program
{
    static void Main(string[] args)
    {
        TestThreadingTimer();
        TestMultimediaTimer();
    }

    private static void TestMultimediaTimer()
    {
        Stopwatch s = new Stopwatch();
        using (var timer = new MultimediaTimer() { Interval = 1 })
        {
            timer.Elapsed += (o, e) => Console.WriteLine(s.ElapsedMilliseconds);
            s.Start();
            timer.Start();
            Console.ReadKey();
            timer.Stop();
        }
    }

    private static void TestThreadingTimer()
    {
        Stopwatch s = new Stopwatch();
        using (var timer = new Timer(o => Console.WriteLine(s.ElapsedMilliseconds), null, 0, 1))
        {
            s.Start();
            Console.ReadKey();
        }
    }

}

public class MultimediaTimer : IDisposable
{
    private bool disposed = false;
    private int interval, resolution;
    private UInt32 timerId; 

    // Hold the timer callback to prevent garbage collection.
    private readonly MultimediaTimerCallback Callback;

    public MultimediaTimer()
    {
        Callback = new MultimediaTimerCallback(TimerCallbackMethod);
        Resolution = 5;
        Interval = 10;
    }

    ~MultimediaTimer()
    {
        Dispose(false);
    }

    public int Interval
    {
        get
        {
            return interval;
        }
        set
        {
            CheckDisposed();

            if (value < 0)
                throw new ArgumentOutOfRangeException("value");

            interval = value;
            if (Resolution > Interval)
                Resolution = value;
        }
    }

    // Note minimum resolution is 0, meaning highest possible resolution.
    public int Resolution
    {
        get
        {
            return resolution;
        }
        set
        {
            CheckDisposed();

            if (value < 0)
                throw new ArgumentOutOfRangeException("value");

            resolution = value;
        }
    }

    public bool IsRunning
    {
        get { return timerId != 0; }
    }

    public void Start()
    {
        CheckDisposed();

        if (IsRunning)
            throw new InvalidOperationException("Timer is already running");

        // Event type = 0, one off event
        // Event type = 1, periodic event
        UInt32 userCtx = 0;
        timerId = NativeMethods.TimeSetEvent((uint)Interval, (uint)Resolution, Callback, ref userCtx, 1);
        if (timerId == 0)
        {
            int error = Marshal.GetLastWin32Error();
            throw new Win32Exception(error);
        }
    }

    public void Stop()
    {
        CheckDisposed();

        if (!IsRunning)
            throw new InvalidOperationException("Timer has not been started");

        StopInternal();
    }

    private void StopInternal()
    {
        NativeMethods.TimeKillEvent(timerId);
        timerId = 0;
    }

    public event EventHandler Elapsed;

    public void Dispose()
    {
        Dispose(true);
    }

    private void TimerCallbackMethod(uint id, uint msg, ref uint userCtx, uint rsv1, uint rsv2)
    {
        var handler = Elapsed;
        if (handler != null)
        {
            handler(this, EventArgs.Empty);
        }
    }

    private void CheckDisposed()
    {
        if (disposed)
            throw new ObjectDisposedException("MultimediaTimer");
    }

    private void Dispose(bool disposing)
    {
        if (disposed)
            return;
        
        disposed = true;
        if (IsRunning)
        {
            StopInternal();
        }
        
        if (disposing)
        {
            Elapsed = null;
            GC.SuppressFinalize(this);
        }
    }
}

internal delegate void MultimediaTimerCallback(UInt32 id, UInt32 msg, ref UInt32 userCtx, UInt32 rsv1, UInt32 rsv2);

internal static class NativeMethods
{
    [DllImport("winmm.dll", SetLastError = true, EntryPoint = "timeSetEvent")]
    internal static extern UInt32 TimeSetEvent(UInt32 msDelay, UInt32 msResolution, MultimediaTimerCallback callback, ref UInt32 userCtx, UInt32 eventType);

    [DllImport("winmm.dll", SetLastError = true, EntryPoint = "timeKillEvent")]
    internal static extern void TimeKillEvent(UInt32 uTimerId);
}

2
这个直接可用。我在生产代码中使用它并进行了归因。唯一的问题是,除非你自己创建回调委托并使用GCHandle.Alloc()来防止其被移动或删除,否则它会崩溃... - hurcan solter
1
我认为我遇到了这个确切的问题——在垃圾回收委托类型...MultimediaTimerCallback::Invoke上进行了回调。您能否提供更多关于您如何解决此问题的见解? - Lone Shepherd
我相信我已经修复了与计时器回调被垃圾回收相关的崩溃问题。 - Mike Zboray
我已经将代码发布在GitHub,因此任何未来的问题都可以在那里提出。 - Mike Zboray

2

我无法让Mike的解决方案运行起来,因此根据这篇codeproject文章,创建了一个基本包装器,基于Windows多媒体计时器实现。 https://www.codeproject.com/Articles/17474/Timer-surprises-and-how-to-avoid-them

public class WinMMWrapper
{
    [DllImport("WinMM.dll", SetLastError = true)]
    public static extern uint timeSetEvent(int msDelay, int msResolution,
        TimerEventHandler handler, ref int userCtx, int eventType);

    public delegate void TimerEventHandler(uint id, uint msg, ref int userCtx,
        int rsv1, int rsv2);

    public enum TimerEventType
    {
        OneTime = 0,
        Repeating = 1
    }

    private readonly Action _elapsedAction;
    private readonly int _elapsedMs;
    private readonly int _resolutionMs;
    private readonly TimerEventType _timerEventType;
    private readonly TimerEventHandler _timerEventHandler;

    public WinMMWrapper(int elapsedMs, int resolutionMs, TimerEventType timerEventType, Action elapsedAction)
    {
        _elapsedMs = elapsedMs;
        _resolutionMs = resolutionMs;
        _timerEventType = timerEventType;
        _elapsedAction = elapsedAction;
        _timerEventHandler = TickHandler;
    }

    public uint StartElapsedTimer()
    {
        var myData = 1; //dummy data
        return timeSetEvent(_elapsedMs, _resolutionMs / 10, _timerEventHandler, ref myData, (int)_timerEventType);
    }

    private void TickHandler(uint id, uint msg, ref int userctx, int rsv1, int rsv2)
    {
        _elapsedAction();
    }
}

以下是如何使用它的示例

class Program
{
    static void Main(string[] args)
    {
        var timer = new WinMMWrapper(100, 25, WinMMWrapper.TimerEventType.Repeating, () =>
        {
            Console.WriteLine($"Timer elapsed {DateTime.UtcNow:o}");
        });

        timer.StartElapsedTimer();

        Console.ReadKey();
    }
}

输出结果如下所示:

enter image description here

更新于2021-11-19:根据Chris的评论,添加了TimerEventHandler类成员。

1
很棒的答案,这个方法运行得非常好。我发现一个问题是TimerEventHandler在WinMMWrapper中被垃圾回收了。可以通过将处理程序设置为类级别变量并将其传递到timeSetEvent来解决这个问题。我猜这是一个常见的非托管代码问题。 - chris84948
嗨,计时器作为分辨率工作得非常好。但是它会随着垃圾回收器的出现而消失。我该如何防止这种情况发生? - Ozgur Saklanmaz

1
有一个选项:使用 Thread.Sleep(0)。调用 Thread.Sleep(1) 或使用 System.Threading.Timer 始终会受到系统计时器分辨率的影响。依赖其中之一可能不是最好的选择,在一天结束时,您的应用程序可能无法从 winmm.dll 调用 timeBeginPeriod(...)

以下代码可以在我的开发机器上解决到 +/- 10ns (0.10ms) ,但可能更高。它会在一个 CPU 核心上产生稳定的负载,将其使用率提高到 100%。没有实际的操作系统减速会发生,该代码通过尽早调用 Thread.Sleep 来放弃大部分 CPU 时间量:

var requiredDelayMs = 0.1;
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
while (true)
{
    if (sw.Elapsed.TotalMilliseconds >= requiredDelayMs) 
    {
      // call your timer routine
    }
    Thread.Sleep(0); // setting at least 1 here would involve a timer which we don't want to
}

如需更全面的实现,请参见我的其他答案


Thread.Yield()会降低上下文切换的机会。 Thread.Sleep(0)会导致上下文切换到优先级较低的进程。提高当前进程的优先级将减少Thread.Yield()本身上下文切换的机会。 - user2864740
此外,SpinWait 可以用于将所有内容打包在一起,甚至减少 Yield 上下文切换的机会:“它被精心实现以为一般情况提供正确的旋转行为,并在自身旋转足够长时间(大约需要内核转换所需的时间长度)后启动上下文切换。” - user2864740

0

-4
尝试创建新的 System.Threading.Thread 并使用 System.Threading.Thread.Sleep
var thrd = new Syatem.Threading.Thread(() => {
    while (true) {
        // do something
        System.Threading.Thread.Sleep(1); // wait 1 ms
    }
});

thrd.Start();

9
这并不保证线程会在1毫秒内迅速唤醒。 - Sriram Sakthivel
5
睡眠间隔实际上约为12-15毫秒。这是系统时钟提供的最小分辨率。 - Mike Zboray
2
请注意:我有两台 Windows 10 笔记本电脑(i5 和 i7),其中 i7 具有 1 毫秒的准确性,而 i5(MS Surface)使用 thread.sleep() 的准确性为 15.6 毫秒。因此,它在您的开发机器上可能有效,但在生产环境中可能无效。 - mark gamache
2
@markgamache 这在Windows 10中发生了变化;timeBeginPeriod的行为已经改变,因此定时可能会因Win10版本而异。 - Ruud van Gaal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接