简介面向C#开发者的高精度毫秒定时器示例工程解决Windows平台下普通定时器精度不足、难以满足上位机毫秒级任务调度的问题。压缩包共41个文件大小约368KB以7个C#源码文件为核心附带解决方案、项目配置、NuGet依赖包precisiontimer.net及调试输出并有一份PPT格式学习笔记可对照B站视频和CSDN博客中的讲解梳理实现思路。整个工程结构完整用Visual Studio打开即可生成可执行程序便于动手实测定时效果。已有827人学习/下载适合需要掌握多媒体定时器或高精度时钟机制的进阶开发者。结合示例代码与笔记读者不仅能修改运行验证精度还可将时间校准、回调调度等关键逻辑迁移到自己的上位机项目节省自行摸索和定位问题的时间。1. C# 高精度毫秒定时器为什么系统定时器到不了 1ms而它能做到最近在做一个模拟项目X需求是每 1ms 采样一次传感器并回写控制量。第一版很天真地用System.Timers.TimerInterval 设成 1结果实测触发间隔几乎都落在 15.6ms 附近一晚上跑出来的曲线全是锯齿。换成System.Threading.Timer也没有本质改善。问题不在写代码的人而在 Windows 默认把系统时钟 tick 打成 15.625ms普通定时器只能在这个时间片的整数倍上被唤醒。要做 C# 高精度毫秒定时器就必须先搞清楚 Windows 定时器精度这件事怎么把时钟临时切到 1ms怎么用 Stopwatch 做微秒级对齐以及绕开 .NET 自带 Timer 在毫秒场景下的各种“慢半拍”。这套方案适合两类读者一类是做数据采集、串口/网口周期通信、运动控制这类有硬实时需求的工控开发者另一类是只想让游戏逻辑、音视频同步中的周期循环尽量准的 .NET 开发者。读完你会拿到能跑、能测、能自己排查问题的实现思路而不是收藏夹里吃灰的代码片段。2. 为什么 System.Timers.Timer 在 Windows 上连 1ms 都做不到时钟分辨率与定时器家族对比2.1 系统时钟 tick 是 15.625ms不是 1ms先说结论Windows 默认的时钟 tick 间隔是 15.625ms换算成频率是 64Hz。这些年系统会做动态时钟调节空闲时可能更长有媒体播放或游戏运行时可能短到 1ms但“默认稳定值”就是 15.6ms 附近。System.Timers.Timer、System.Threading.Timer这些托管定时器的底层靠的是内核定时器队列内核在每次 clock tick 时检查过期定时器并触发回调。如果你的 Interval 是 1ms而系统每 15.6ms 才检查一次那回调自然只能以约 15.6ms 的倍数被触发。这解释了一个很常见的现象把 Timer 的 Interval 从 1 改成 15实测间隔反而更稳定。因为 15.6ms 是系统检查周期的整数倍回调大概率落在预期的 15ms 附近而设成 1ms 时实际触发从 15.6ms 到 31.2ms 不等更糟糕。为什么 Windows 不把时钟调成 1ms省电。每毫秒一次时钟中断意味着 CPU 每秒醒来 1000 次空闲功耗明显上升。所以系统默认选了一个足够维持 UI 和普通后台任务的低频 tick。真正需要高精度的应用会主动调用timeBeginPeriod(1)请求把系统时钟提高到 1ms这就是高精度定时器方案的地基。Stopwatch是另一条路的核心。它不依赖系统时钟 tick底层走的是 QueryPerformanceCounter叫高精度性能计数器粒度通常是微秒级。Stopwatch.IsHighResolution在绝大多数现代 PC 上是 true它只管“过去了多少时间”不负责“到点了叫我一声”。高精度定时器真正可靠的做法就是让 Stopwatch 负责计时间让某个线程负责“到点了去执行”二者配合。2.2 定时器方案对比参数、线程、精度一次看清定时器方案典型触发精度回调线程主要限制System.Timers.Timer15.6ms 量级ThreadPool 随机线程受系统时钟 tick 限制Interval1 无实际意义System.Threading.Timer15.6ms 量级ThreadPool同样受 tick 限制适合秒级任务Task.Delay 循环15.6ms 量级异步上下文精度差且每轮叠加误差winmm 多媒体定时器 timeSetEvent约 1ms独立系统线程需要 P/Invoke回调必须极短Stopwatch 混合等待微秒级对齐自定义线程占用 CPU需要处理线程调度选择依据只有两条要 1ms 精度且不想烧 CPU选多媒体定时器要微秒级对齐且能接受一个专用线程去跑选 Stopwatch 混合等待。中间档位在 Windows 上不存在靠Thread.Sleep(1)硬凑出来的所谓高精度定时器实测抖动通常有 3~10ms只能算能用算不上高精度。2.3 正确选型先问 “错过一个周期会怎样”高精度定时器不是灵丹妙药。在选型前先问自己错过一个周期数据是作废还是可控如果是采样漏了就要重新采集建议 Stopwatch 方案它能把丢周期概率压到最低如果是心跳、超时判断这类任务多媒体定时器足够误差 1~2ms 完全可以接受。还有一个实际问题回调里能不能做重活。高精度定时器的回调线程都不适合做大量计算或 IO。正确姿势是回调里只记时间戳、往缓冲区里写数据真正消耗时间的业务逻辑放到另一个工作线程。这个原则贯穿后面几个章节属于最重要的工程约束。3. 两种能跑起来的实现Stopwatch 混合等待循环与 winmm 多媒体定时器3.1 方案 AStopwatch 混合等待循环using System.Diagnostics; public class MixedWaitTimer : IDisposable { private readonly Stopwatch _sw Stopwatch.StartNew(); private readonly int _intervalMs; private long _nextTick; private volatile bool _running; private Thread? _thread; public event Action? Tick; public MixedWaitTimer(int intervalMs) { _intervalMs intervalMs; } public void Start() { _running true; // 先定好下一次触发的绝对时间点不要用累计 Sleep 去推 _nextTick _sw.ElapsedMilliseconds _intervalMs; _thread new Thread(Loop) { IsBackground true, Priority ThreadPriority.Highest // 只提高当前线程优先级 }; _thread.Start(); } private void Loop() { while (_running) { long now _sw.ElapsedMilliseconds; if (now _nextTick) { // 一旦滞后直接对齐当前时间不累计欠账 _nextTick now _intervalMs; Tick?.Invoke(); // 回调里只做记录重活交给别的线程 } else { long remaining _nextTick - now; if (remaining 5) Thread.Sleep(1); // 剩余超过 5ms:睡 1ms 让出 CPU else Thread.SpinWait(100); // 剩余 5ms 内:自旋等待避免被调度干扰 } } } public void Dispose() { _running false; _thread?.Join(1000); _thread null; } }这段代码的核心逻辑是用 Stopwatch 维护一个绝对时间轴每次循环只算“下一次应该触发的时刻”和“当前时刻”的差值。大于 5ms 时调用Thread.Sleep(1)让出 CPU小于等于 5ms 时进入自旋避免 Sleep 带来的调度不确定性。参数说明_intervalMs是目标触发间隔单位毫秒5ms 这个阈值是有讲究的既不能太大否则频繁进 Sleep精度变差也不能太小否则 CPU 被白白浪费建议取值 2~5msThread.SpinWait(100)的 100 次自旋大约只有几百纳秒主要作用是让当前线程在超时前保持运行状态同时不会触发线程上下文切换。和纯自旋方案相比这个混合等待的好处是空闲期 CPU 占用明显降低忙等只发生在临近触发点的最后几毫秒。实测在普通 PC 上负载很轻时 1ms 间隔能稳定到 1ms±0.1ms。缺点是自旋阶段仍然会占满一个核心不能指望它在低功耗设备上长时间跑。有一个容易被忽略的细节为什么不直接Thread.Sleep(_intervalMs - 1)因为 Sleep 的唤醒依赖系统时钟即便 timeBeginPeriod(1) 生效Sleep(1) 的实际暂停时间也会波动在 1ms 上下多个周期累积下来误差就不可控了。混合等待方案里 Sleep 只负责“大概睡到附近”剩余几毫秒用自旋精确对齐误差不再累积。3.2 方案 Bwinmm 多媒体定时器 timeSetEventusing System; using System.Runtime.InteropServices; public sealed class WinmmTimer : IDisposable { private const uint TIME_PERIODIC 1; private const uint TIME_CALLBACK_FUNCTION 0; private delegate void TimerCallback(uint timerId, uint msg, IntPtr user, IntPtr dw1, IntPtr dw2); [DllImport(winmm.dll, SetLastError true)] private static extern uint timeBeginPeriod(uint uMilliseconds); [DllImport(winmm.dll, SetLastError true)] private static extern uint timeEndPeriod(uint uMilliseconds); [DllImport(winmm.dll, SetLastError true)] private static extern uint timeSetEvent( uint uDelay, uint uResolution, TimerCallback callback, IntPtr user, uint fuEvent); [DllImport(winmm.dll, SetLastError true)] private static extern uint timeKillEvent(uint uTimerId); private TimerCallback? _callback; private uint _timerId; private bool _periodAdjusted; private readonly object _lock new object(); public event Action? Tick; public void Start(int intervalMs) { if (intervalMs 1) throw new ArgumentOutOfRangeException(nameof(intervalMs)); // 把系统时钟提高到 1ms这是多媒体定时器精度的前提 if (timeBeginPeriod(1) ! 0) throw new InvalidOperationException(timeBeginPeriod 调用失败); lock (_lock) { _callback (id, msg, user, dw1, dw2) Tick?.Invoke(); _timerId timeSetEvent((uint)intervalMs, 1, _callback, IntPtr.Zero, TIME_PERIODIC | TIME_CALLBACK_FUNCTION); if (_timerId 0) { timeEndPeriod(1); throw new InvalidOperationException(timeSetEvent 创建定时器失败); } _periodAdjusted true; } } public void Stop() { lock (_lock) { if (_timerId ! 0) { timeKillEvent(_timerId); _timerId 0; } if (_periodAdjusted) { timeEndPeriod(1); // 必须和 timeBeginPeriod(1) 配对 _periodAdjusted false; } } } public void Dispose() Stop(); }这段代码做的事情分两层第一层是timeBeginPeriod(1)把系统时钟分辨率切到 1ms第二层是调用timeSetEvent注册一个周期回调intervalMs 是触发周期第二个参数 1 表示允许的触发延迟误差也是 1ms。逻辑说明多媒体定时器的回调跑在独立系统线程上不占用我们的线程池所以在负载较重的进程里比 Sleep 方案稳定得多。参数说明uResolution传 1 表示允许回调时间点在目标时刻前后 1ms 内触发如果你能接受 2ms 的波动可以把该参数调成 2系统调度会更宽松TIME_PERIODIC表示周期触发TIME_CALLBACK_FUNCTION表示用回调函数方式通知。这里有几个工程上的强制要求。第一timeBeginPeriod和timeEndPeriod必须成对出现少一次timeEndPeriod就相当于把这个进程的系统时钟提升一直保留到进程退出对笔记本功耗影响很明显。第二_callback委托必须保存为字段不能是局部变量否则 GC 回收委托对象后原生回调可能会访问到已回收的托管对象这个是踩过的真实坑。第三回调里不要做阻塞操作比如加锁等别的线程因为那会让定时器线程卡住后续触发全部推迟。3.3 两种方案怎么选一个看 CPU一个看容错如果业务场景是每一拍都要严格对齐比如发脉冲给步进电机驱动用方案 A。它能保证触发时刻的误差主要来自自旋阶段的调度延迟通常小于 0.5ms而且完全由我们自己的线程控制。代价是 CPU 占用。如果业务场景是周期采集、周期上报漏一拍可以由下一拍补偿比如串口问答、心跳检测用方案 B。它省 CPU实现简单但精度被系统时钟的 1ms tick 限制住回调具体落在 1ms 内的哪一刻受系统负载影响。还可以叠加使用用方案 B 做主时序用 Stopwatch 打时间戳记录每个回调点的实际时刻。采集程序需要的是“数据对应哪个时刻”不是“回调必须在哪个精确时刻发生”这种打法实际很常见。4. 精度配套设置线程优先级、CPU 亲和性、GC 这三件套必须一起调4.1 timeBeginPeriod 只能临时用别把它当默认参数有人写完定时器发现精度不错就直接把timeBeginPeriod(1)放在程序初始化里永久执行这会让整个系统每秒多产生几百次时钟中断。笔记本会明显感觉风扇转得更早服务器上的邻居任务也会受干扰。正确的做法是只在需要跑高精度周期的阶段开启阶段结束立刻timeEndPeriod(1)。如果程序是多线程的timeBeginPeriod 按进程计数多个线程重复调用没问题但每个调用都要有对应的 timeEndPeriod否则计数归不了零。有一个需要了解的底细timeBeginPeriod是全局生效的你的进程调用了其他进程的 Sleep(1) 也会变准。反过来如果系统里有其他程序在跑高精度逻辑你的定时器精度也会跟着受益。所以排查精度问题时第一件事是检查当前系统时钟分辨率是多少。常见做法是调用GetSystemTimeAdjustment读当前时钟周期或者用一段循环测量Stopwatch时间跨度的最小值来判断。4.2 把定时器线程钉在固定核心上线程迁移是精度杀手。Windows 调度器会在不同核心间迁移线程迁移瞬间存在 TSC 同步和缓存失效的问题虽然现代 CPU 上影响不大但对 1ms 以内的稳定触发依然很明显。把定时器线程绑定到某个专属核心能减少调度器的随机性。在MixedWaitTimer.Loop()线程开头加一段 P/Invoke[DllImport(kernel32.dll)] private static extern IntPtr GetCurrentThread(); [DllImport(kernel32.dll)] private static extern IntPtr SetThreadAffinityMask(IntPtr hThread, IntPtr dwThreadAffinityMask); // Loop() 线程第一行执行0x02 表示绑定到 2 号核心 SetThreadAffinityMask(GetCurrentThread(), (IntPtr)0x02);逻辑说明把定时器线程固定到某个核心上避免它在多核间来回跑。参数说明0x02对应第二个逻辑核心也就是 1 号核心从 0 编号。如果机器是多路 CPU务必选择一个和业务线程不冲突的核心如果不确定核心编号可以先跑一个枚举逻辑处理器的代码确认核心编号后写死。注意绑核之后定时器线程只能在这一个核心上运行该核心上其他高负载线程会直接影响定时器的自旋精度所以最好选一个业务负载低的核心。线程优先级方面ThreadPriority.Highest已经够用不建议直接拉整个进程的优先级到 RealTime那会让系统 UI 和其他线程明显卡顿。绑定核心 Highest 优先级的组合在很多设备上实测能把抖动从 ±2ms 压到 ±0.3ms。4.3 回调里只做记账GC 暂停会把定时器直接打停GC 是托管定时器的隐形敌人。回调里如果每轮都Console.WriteLine、拼字符串、记录日志就会不断触发小对象分配。当 GC 启动时所有托管线程都可能被挂起等待回收定时器线程也不例外。一次 GC 挂起几百毫秒对于 1ms 周期的定时器来说就是错过几百拍。常见做法是让回调只做三件事取 Stopwatch 时间戳、写入预分配数组、置一个标志位。比如private readonly long[] _samples new long[4096]; private int _pos; private void LightTick() { _samples[_pos] Stopwatch.GetTimestamp(); // 预分配零 GC 分配 _pos (_pos 1) 0x0FFF; // 4096 取模用位运算比 % 更快 }逻辑说明回调只往固定数组里写数据不创建任何新对象所以 GC 永远不会因为定时器回调本身启动。参数说明_samples数组大小 4096按 1ms 频率可以覆盖约 4 秒的采样窗口0x0FFF是 4095 的十六进制保证索引在 0~4095 循环。业务侧每几百毫秒从数组尾部取走新数据即可。如果业务既要高频又要复杂处理就把数组换成无锁环形缓冲区工作线程消费一侧定时器线程只生产一侧。这个方法解决的不只是“回调别写日志”的问题更重要的是让实际业务逻辑脱离定时器线程。真正需要做的控制、发送、存储都移到工作线程定时器线程只负责保持节奏稳定性提升明显。5. 避坑记录四个高频问题与排查路径5.1 跑久了之后定时器整体漂移了几十毫秒现象前几分钟精度很好跑一两个小时后用 Stopwatch 对拍发现实际触发点比预期晚了 20~30ms而且这个误差还在增长。原因这是经典的“累加误差写错了”。如果在 Loop 里写成_nextTick _intervalMs而某一轮回调卡了 1.3ms多出来的 0.3ms 不会消失它会把下一轮的目标时间也往后推。一段时间下来每一轮的小误差累积成大偏移。解决在触发分支里改成对齐当前时间_nextTick now _intervalMs。这个改动保证每一轮都基于真实时钟对齐误差不会跨周期累积。代价是某轮如果延迟了 2ms那一轮相当于直接跳过了一个周期后续不再补账。对于大多数数据采集场景这个行为更合理。5.2 明明调用了 timeBeginPeriod(1)定时器还是 15.6ms现象winmm 定时器代码看起来没问题timeBeginPeriod(1)也执行了但实测回调间隔还是集中在 15.6ms 附近。原因检查一下是不是别的地方把系统时钟周期调回去了。某些程序在退出时执行timeEndPeriod如果它在你的 timeBeginPeriod 生效期间把计数归零系统会把时钟分辨率降回默认值。多层调用没配对是常见原因比如初始化被调用了两次退出只释放了一次。解决别用只调用一次的初始化方式做周期保护。每轮定时器启动前重新调用一次timeBeginPeriod(1)退出时也相应地调用timeEndPeriod(1)同时用日志记录每次调用的成对情况。另外如果程序里既有 winmm 定时器又有其他组件调用 timeEndPeriod排查时直接打印GetSystemTimeAdjustment看当前时钟周期比瞎猜快得多。5.3 笔记本合盖再打开定时器直接断了几十毫秒现象睡眠恢复后定时器回调继续跑但中间丢了 30~90ms 的 tick业务侧的数据出现一段缺口。原因睡眠期间整个系统的电源和时钟状态都被挂起唤醒后线程调度、TSC 校准、驱动恢复都需要时间期间定时器回调被系统挂起。这是硬件层面的行为不是代码能完全绕开的。解决不要在回调里依赖“每隔 N 次触发恰好等于 N 个周期”这个假设。业务逻辑要用每个回调的时间戳做间隔计算而不是用“次数 × 周期”去推断当前时刻。如果业务要求补齐睡眠期间的采样点常规做法是以恢复后的第一个时间戳为基准按周期往前反推补点而不是让定时器把欠的 tick 全发出来。5.4 GC 之后定时器丢片回调线程被挂起现象自旋方案跑得好好的一段时间后突然丢 10~30ms稍微调高分配频率丢得更频繁。原因回调里直接做了字符串拼接或集合操作这些操作在 1ms 间隔下每分钟产生大量小对象触发第 0 代 GC。GC 的 stop-the-world 挂起把定时器线程也暂停了。解决按 4.3 的做法回调改成无分配逻辑只写预分配数组和标志位。如果一时改不动现有代码可以先用一个中间层回调里只往 Channel 或队列里塞入时间戳业务线程异步消费。这个改动能解决 80% 的丢片问题。至于那种“回调里就是有大量日志输出”的实现建议直接重写别指望靠 GC 配置调好。6. 进阶验证定时器是否真的准——抖动分布测量与漂移微校正高精度定时器上线前必须做一次量化验证而不是“看起来差不多”就收工。我自己固定用的方法是跑 2000 次触发记录每次触发的实际间隔输出最小值、最大值、平均值。// 测量一个定时器触发函数 2000 次的实际间隔分布 public static void MeasureDistribution(Action waitOneTick, int count 2000) { long last Stopwatch.GetTimestamp(); double[] deltas new double[count]; for (int i 0; i count; i) { waitOneTick(); long now Stopwatch.GetTimestamp(); deltas[i] (now - last) * 1000.0 / Stopwatch.Frequency; last now; } Array.Sort(deltas); Console.WriteLine($min{deltas[0]:F3}ms max{deltas[count - 1]:F3}ms 平均{deltas.Average():F3}ms); }测量时注意waitOneTick里只能调用定时器触发函数本身不能把业务逻辑也带进去否则测出来的是“业务 定时器”的合成抖动。我的验收标准是平均间隔和设定值的偏差不超过 0.05ms最大值不超过设定值 2ms。超过这个范围就说明定时器线程被外部因素干扰了优先查线程优先级和 CPU 绑核。长期运行的漂移微校正也值得做。虽然 Stopwatch 本身不漂移但定时器触发间隔受系统调度影响偶尔会有一轮偏差被永久定格在时间轴上。在校验轮次做一次“微校正”是常规做法// 每 1000 次触发检查一次累计漂移超过 5ms 才校正 private long _tickCount; // 在 Tick 回调的最前面追加 if (_tickCount % 1000 0) { long now _sw.ElapsedMilliseconds; long expect _nextTick - _intervalMs; // 这一轮理论上应该触发的时刻 long drift now - expect; if (drift 5 || drift -5) _nextTick now _intervalMs; // 漂移过大时重新对齐 }这个微校正解决的是“偶尔一次异常调度导致后续节奏永久偏移”的问题。阈值 5ms 的意思是小于 5ms 的偏差当作正常抖动不去校正否则每次触发的随机抖动会被误当成漂移反而引入额外波动。校正后第一次触发的间隔会稍微偏长或偏短但整体节奏能回归正确位置。高精度定时器做多了以后我已经不再关心 API 名字本身而是留意三件事时钟 tick 当前是多少、定时器线程跑在哪个核、回调有没有做分配。前两件用工具一查就准第三件靠代码审查和经验属于最容易翻车的点。每次上线新采集程序我都会先跑一遍分布测量把 min/max/avg 打出来放进日志再决定要不要做下一步优化。按这个流程走C# 高精度毫秒定时器方案踩坑率会低很多。希望帮到你。本文还有配套的精品资源点击获取