.NET中最精确的计时器是什么?

45

运行以下(略微伪装的)代码将产生以下结果。我对定时器的不准确感到震惊(每个Tick增加约14毫秒)。

有没有更精确的方法?

void Main()
{
   var timer = new System.Threading.Timer(TimerCallback, null, 0, 1000);
}

void TimerCallback(object state)
{
   Debug.WriteLine(DateTime.Now.ToString("ss.ffff"));
}

Sample Output:
...
11.9109
12.9190
13.9331
14.9491
15.9632
16.9752
17.9893
19.0043
20.0164
21.0305
22.0445
23.0586
24.0726
25.0867
26.1008
27.1148
28.1289
29.1429
30.1570
31.1710
32.1851

4
你应该将日期时间存储在数组中,并在程序结束时编写它,以免受到 Debug.WriteLine() 的干扰。 - Elo
11个回答

28

我还编写了一个精度为1毫秒的类。我从Hans Passant的论坛代码中获取了灵感:
https://social.msdn.microsoft.com/Forums/en-US/6cd5d9e3-e01a-49c4-9976-6c6a2f16ad57/1-millisecond-timer
并将其封装在一个类中,以便在您的窗体中更轻松地使用。如果需要,您可以轻松设置多个计时器。在下面的示例代码中,我使用了2个计时器。我已经进行了测试,它可以正常工作。

// AccurateTimer.cs
using System;
using System.Windows.Forms;
using System.Runtime.InteropServices;

namespace YourProjectsNamespace
{
    class AccurateTimer
    {
        private delegate void TimerEventDel(int id, int msg, IntPtr user, int dw1, int dw2);
        private const int TIME_PERIODIC = 1;
        private const int EVENT_TYPE = TIME_PERIODIC;// + 0x100;  // TIME_KILL_SYNCHRONOUS causes a hang ?!
        [DllImport("winmm.dll")]
        private static extern int timeBeginPeriod(int msec);
        [DllImport("winmm.dll")]
        private static extern int timeEndPeriod(int msec);
        [DllImport("winmm.dll")]
        private static extern int timeSetEvent(int delay, int resolution, TimerEventDel handler, IntPtr user, int eventType);
        [DllImport("winmm.dll")]
        private static extern int timeKillEvent(int id);

        Action mAction;
        Form mForm;
        private int mTimerId;
        private TimerEventDel mHandler;  // NOTE: declare at class scope so garbage collector doesn't release it!!!

        public AccurateTimer(Form form,Action action,int delay)
        {
            mAction = action;
            mForm = form;
            timeBeginPeriod(1);
            mHandler = new TimerEventDel(TimerCallback);
            mTimerId = timeSetEvent(delay, 0, mHandler, IntPtr.Zero, EVENT_TYPE);
        }

        public void Stop()
        {
            int err = timeKillEvent(mTimerId);
            timeEndPeriod(1);
            System.Threading.Thread.Sleep(100);// Ensure callbacks are drained
        }

        private void TimerCallback(int id, int msg, IntPtr user, int dw1, int dw2)
        {
            if (mTimerId != 0)
                mForm.BeginInvoke(mAction);
        }
    }
}

// FormMain.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace YourProjectsNamespace
{
    public partial class FormMain : Form
    {
        AccurateTimer mTimer1,mTimer2;

        public FormMain()
        {
            InitializeComponent();
        }

        private void FormMain_Load(object sender, EventArgs e)
        {
            int delay = 10;   // In milliseconds. 10 = 1/100th second.
            mTimer1 = new AccurateTimer(this, new Action(TimerTick1),delay);
            delay = 100;      // 100 = 1/10th second.
            mTimer2 = new AccurateTimer(this, new Action(TimerTick2), delay);
        }

        private void FormMain_FormClosing(object sender, FormClosingEventArgs e)
        {
            mTimer1.Stop();
            mTimer2.Stop();
        }

        private void TimerTick1()
        {
            // Put your first timer code here!
        }

        private void TimerTick2()
        {
            // Put your second timer code here!
        }
    }
}

这段代码的原作者是如何知道在哪里查找这个多媒体API的呢?我永远想不到。 - Gaurav
不错的答案,但是你可以同时使用最多16个MM定时器。 - Darxis
谢谢!如果我在商业产品中使用这段代码,会有许可问题吗? - Ryan Kane
我也正在使用这个实现,但是为了防止不支持winm.dll,我还使用了Kernel32 Tick。 在我的游戏服务器中运行得非常好^^ - Zorkind

23

我认为其他的回答没有解释清楚为什么每次迭代的操作中会有14毫秒的滞后。这不是因为系统时钟不准确(而且DateTime.Now并不不准确,除非你关闭了NTP服务或者设置了错误的时区或做了一些傻事!它只是不精确)。

精确计时器

即使使用不准确的系统时钟(使用DateTime.Now,或将太阳能电池连接到ADC以告诉你太阳在天空中的高度,或将时间分配给高峰潮之间,等等),遵循此模式的代码将平均具有零滞后(平均每秒之间的滴答声完全准确):

var interval = new TimeSpan(0, 0, 1);
var nextTick = DateTime.Now + interval;
while (true)
{
    while ( DateTime.Now < nextTick )
    {
        Thread.Sleep( nextTick - DateTime.Now );
    }
    nextTick += interval; // Notice we're adding onto when the last tick  
                          // was supposed to be, not when it is now.
    // Insert tick() code here
}

如果您复制和粘贴此代码,请注意一种情况:当您的滴答代码需要比interval更长的时间来执行时,要小心。我将把这留给读者作为练习,以找到让其跳过尽可能多的拍子,直到nextTick落入未来的简单方法。

计时器不准确

我猜测 Microsoft 对 System.Threading.Timer 的实现遵循这种模式。即使使用完美精确和完美准确的系统计时器(因为执行加操作也需要时间),该模式仍然会产生扭曲:

var interval = new TimeSpan(0, 0, 1);
var nextTick = DateTime.Now + interval;
while (true)
{
    while ( DateTime.Now < nextTick )
    {
        Thread.Sleep( nextTick - DateTime.Now );
    }
    nextTick = DateTime.Now + interval; // Notice we're adding onto .Now instead of when
                                        // the last tick was supposed to be. This is
                                        // where slew comes from.
    // Insert tick() code here
}

因此,对于可能有兴趣制作自己的计时器的人,不要遵循这个第二种方法。

精确时间测量

正如其他帖子所说,Stopwatch类提供了很高的时间测量精度,但如果遵循错误的模式,则对准确性没有任何帮助。但是,正如@Shahar所说的那样,你永远不会得到一个完美精确的计时器,因此,如果你追求完美的精度,就需要重新考虑。

声明

请注意,微软并没有谈论System.Threading.Timer类的内部情况,所以我只是在根据我的经验猜测,但如果它像鸭子一样嘎嘎叫,那么它很可能就是一只鸭子。此外,我意识到这已经过去几年了,但这仍然是一个相关(我认为也是未回答的)问题。

编辑:将链接更改为@Shahar的答案

编辑:微软在线公开了许多东西的源代码,包括System.Threading.Timer,供有兴趣了解微软如何实现这个衰变计时器的人参考。


1
有一篇很好的MSDN文章,但你需要使用wayback机器来查看它。"比较.NET Framework类库中的计时器类" 计时器的一个特点是“节拍的品质”,这正是你的“准确计时器”段落所讲述的内容,如果有人想要更多信息。 - Scott Chamberlain
3
在“准确计时器”示例中,如果间隔小于Windows默认计时器周期(即15.625毫秒或64个tick/秒),则Sleep()方法的时间跨度参数可能为负数,并且会抛出异常(甚至更糟糕的是,会无限期地休眠,因为-1 = Timeout.infinite)。 - tigrou
2
我作为@HerryYT的评论编辑发布:注意:Thread.Sleep(x),其中x > 1可能会由于其任务而添加一些额外的延迟,而不是持续使用CPU核心。要解决此问题,您可能希望改用Thread.Sleep(0);(这将减少延迟,因为它不涉及上下文切换并将释放CPU时间)。 - Theodor Zoulias
请注意,当nextTick - DateTime.Now的结果为-1毫秒时,此代码可能会永久停止您的线程。在Thread.Sleep();中,-1表示无限。您最好加以防范。 - hina10531

17

要进行精确时间测量,您需要使用Stopwatch类。请参阅MSDN了解更多信息。


高精度低开销。您可以使用Stopwatch.StartNew()创建一个新的计时器,使用timer.Stop()停止它。如果您什么都不做,那么只会经过1或2个滴答声。 - yoyo
51
为什么这被标记为答案?我相信问题要求的是更准确的计时器而不是准确的测量工具。他的测量已经足够好了 - 计时器每次回调都会延迟最多15毫秒,无论你如何测量它。 - ILIA BROUDNO
答案的链接已失效。 - user2173353
答案的链接已失效。 - user2173353

12

几年后,但是在这里这是我想出来的东西。它会自动对齐,通常精度在1ms以下。简而言之,它从低CPU负荷的Task.Delay开始,然后逐步向更高CPU负荷的spinwait转移,通常精度约为50µs(0.05ms)。

static void Main()
{
    PrecisionRepeatActionOnIntervalAsync(SayHello(), TimeSpan.FromMilliseconds(1000)).Wait();
}

// Some Function
public static Action SayHello() => () => Console.WriteLine(DateTime.Now.ToString("ss.ffff"));
        

public static async Task PrecisionRepeatActionOnIntervalAsync(Action action, TimeSpan interval, CancellationToken? ct = null)
{
    long stage1Delay = 20 ;
    long stage2Delay = 5 * TimeSpan.TicksPerMillisecond;
    bool USE_SLEEP0 = false;

    DateTime target = DateTime.Now + new TimeSpan(0, 0, 0, 0, (int)stage1Delay + 2);
    bool warmup = true;
    while (true)
    {
        // Getting closer to 'target' - Lets do the less precise but least cpu intesive wait
        var timeLeft = target - DateTime.Now;
        if (timeLeft.TotalMilliseconds >= stage1Delay)
        {
            try
            {
                await Task.Delay((int)(timeLeft.TotalMilliseconds - stage1Delay), ct ?? CancellationToken.None);
            }
            catch (TaskCanceledException) when (ct != null)
            {
                return;
            }
        }

        // Getting closer to 'target' - Lets do the semi-precise but mild cpu intesive wait - Task.Yield()
        while (DateTime.Now < target - new TimeSpan(stage2Delay))
        {
            await Task.Yield();
        }

        // Getting closer to 'target' - Lets do the semi-precise but mild cpu intensive wait - Thread.Sleep(0)
        // Note: Thread.Sleep(0) is removed below because it is sometimes looked down on and also said not good to mix 'Thread.Sleep(0)' with Tasks.
        //       However, Thread.Sleep(0) does have a quicker and more reliable turn around time then Task.Yield() so to 
        //       make up for this a longer (and more expensive) Thread.SpinWait(1) would be needed.
        if (USE_SLEEP0)
        {
            while (DateTime.Now < target - new TimeSpan(stage2Delay / 8))
            {
                Thread.Sleep(0);
            }
        }

        // Extreamlly close to 'target' - Lets do the most precise but very cpu/battery intesive 
        while (DateTime.Now < target)
        {
            Thread.SpinWait(64);
        }

        if (!warmup)
        {
            await Task.Run(action); // or your code here
            target += interval;
        }
        else
        {
            long start1 = DateTime.Now.Ticks + ((long)interval.TotalMilliseconds * TimeSpan.TicksPerMillisecond);
            long alignVal = start1 - (start1 % ((long)interval.TotalMilliseconds * TimeSpan.TicksPerMillisecond));
            target = new DateTime(alignVal);
            warmup = false;
        }
    }
}


Sample output:
07.0000
08.0000
09.0000
10.0001
11.0000
12.0001
13.0000
14.0000
15.0000
16.0000
17.0000
18.0000
19.0001
20.0000
21.0000
22.0000
23.0000
24.0000
25.0000
26.0000
27.0000
28.0000
29.0000
30.0000
31.0000
32.0138 <---not that common but can happen
33.0000
34.0000
35.0001
36.0000
37.0000
38.0000
39.0000
40.0000
41.0000

7
值得一提的是,这个问题现在似乎已经被修复了。
使用 OP 的代码,在 .NET Core 3.1 中会得到如下结果:
41.4263
42.4263
43.4291
44.4262
45.4261
46.4261
47.4261
48.4261
49.4260
50.4260
51.4260
52.4261

2
另外值得一提的是,您可以使用AppContextSwitchOverrides在框架项目中启用新计时器。可以在您的app.config文件或编程方式中实现 AppContext.SetSwitch("Switch.System.Threading.UseNetCoreTimer", true); - Ceres

4

1
这里是基于Stopwatch实现的HighResolutionTimer https://dev59.com/k2w05IYBdhLWcg3wnjAk#45097518 - MajesticRa
答案的链接已经失效了。 - user2173353

4

桌面操作系统(例如Windows)不是实时操作系统。这意味着,您不能期望完全准确,并且您不能强制调度程序在您想要的确切毫秒触发您的代码。特别是在.NET应用程序中,它是非确定性的...例如,任何时候GC可以开始收集,JIT编译可能会稍微慢一点或快一点...


3

这并不是定时器不准确,而是DateTime.Now的公告容忍度为16毫秒。

相反,我会使用Environment.Ticks属性来测量此测试期间的CPU周期。

编辑:Environment.Ticks也基于系统计时器,可能存在与DateTime.Now相同的精度问题。建议选择StopWatch,正如其他回答者所提到的那样。


2
Environment.Ticks 现在是 Environment.TickCount - husayt
如果那是问题的话,时间不会漂移,而是抖动。 - Eike

2

这并不能使计时器更加准确(也就是说,不能确保回调之间的时间恰好为1秒),但如果你所需要的只是每隔一秒触发一次计时器,并且不会因为~14ms 的漂移问题而跳过秒数(就像 OP 在第17秒和第19秒之间的示例输出中演示的那样),那么你可以简单地将计时器更改为在回调触发时立即在下一个整秒开始触发(如果你关心的只是确保时间间隔不会偏离,则同样适用于下一个整分钟,下一个整小时等):

using System.Threading;

static Timer timer;

void Main()
{   
    // 1000 - DateTime.UtcNow.Millisecond = number of milliseconds until the next second
    timer = new Timer(TimerCallback, null, 1000 - DateTime.UtcNow.Millisecond, 0);
}

void TimerCallback(object state)
{   
    // Important to do this before you do anything else in the callback
    timer.Change(1000 - DateTime.UtcNow.Millisecond, 0);

    Debug.WriteLine(DateTime.UtcNow.ToString("ss.ffff"));
}

Sample Output:
...
25.0135
26.0111
27.0134
28.0131
29.0117
30.0135
31.0127
32.0104
33.0158
34.0113
35.0129
36.0117
37.0127
38.0101
39.0125
40.0108
41.0156
42.0110
43.0141
44.0100
45.0149
46.0110
47.0127
48.0109
49.0156
50.0096
51.0166
52.0009
53.0111
54.0126
55.0116
56.0128
57.0110
58.0129
59.0120
00.0106
01.0149
02.0107
03.0136

0

这里有另一种方法。在我的机器上精度可达5-20毫秒。

public class Run
{
    public Timer timer;

    public Run()
    {
        var nextSecond = MilliUntilNextSecond();

        var timerTracker = new TimerTracker()
        {
            StartDate = DateTime.Now.AddMilliseconds(nextSecond),
            Interval = 1000,
            Number = 0
        };

        timer = new Timer(TimerCallback, timerTracker, nextSecond, -1);
    }

    public class TimerTracker
    {
        public DateTime StartDate;
        public int Interval;
        public int Number;
    }

    void TimerCallback(object state)
    {
        var timeTracker = (TimerTracker)state;
        timeTracker.Number += 1;
        var targetDate = timeTracker.StartDate.AddMilliseconds(timeTracker.Number * timeTracker.Interval);
        var milliDouble = Math.Max((targetDate - DateTime.Now).TotalMilliseconds, 0);
        var milliInt = Convert.ToInt32(milliDouble);
        timer.Change(milliInt, -1);

        Console.WriteLine(DateTime.Now.ToString("ss.fff"));
    }

    public static int MilliUntilNextSecond()
    {
        var time = DateTime.Now.TimeOfDay;
        var shortTime = new TimeSpan(0, time.Hours, time.Minutes, time.Seconds, 0);
        var oneSec = new TimeSpan(0, 0, 1);
        var milliDouble = (shortTime.Add(oneSec) - time).TotalMilliseconds;
        var milliInt = Convert.ToInt32(milliDouble);
        return milliInt;
    }
}

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