自定义计时器与System.Timers.Timer的分辨率差异及问题咨询
Hey there, let's dig into your questions about this custom high-resolution timer and that interval "hack" you're using. First, let's break down the key concerns you raised:
Potential Issues with High-Resolution Custom Timers
Your implementation uses QueryUnbiasedInterruptTime for high-precision timing, which is great for accuracy, but there are a few tradeoffs to keep in mind:
- Increased CPU Overhead: A 1ms interval means your agent's
Receivecall will wake up extremely frequently. Even if the callback work is minimal, the constant context switching between the agent thread and system scheduler can eat up CPU cycles—especially noticeable on systems with limited resources or when running alongside other CPU-intensive tasks. - Platform Limitations:
QueryUnbiasedInterruptTimeis a Windows-only Win32 API (supported on Vista+). If you ever need to port this timer to Linux or macOS, you'll need a fallback (likeStopwatch.GetTimestamp(), which uses platform-specific high-resolution timers). - Power Management Interference: On laptops or battery-powered devices, system power-saving modes often throttle clock precision to reduce energy usage. This can degrade your timer's accuracy or cause unexpected jumps in interval timing when the system switches between power profiles.
- Callback Execution Delays: While your agent decouples the timer scheduling from the
elapsedevent (usingAsync.Start), if the event handler itself takes longer than your interval to execute, you could end up with backlogged work. Even though the timer scheduling stays on track, the actual event processing might pile up.
Is That "Interval < 1ms" Hack Necessary?
Short answer: Yes, but it can be made more elegant. Here's why it's needed:
When calculating nextInterval, if the time since the last start is already very close to (or exceeds) your target interval, you end up with a near-zero (or negative) timespan. Passing that to MailboxProcessor.Receive would trigger an immediate wakeup, creating a tight loop that hogs CPU.
That said, instead of a hardcoded "replace with full interval" hack, you could calculate the next trigger time based on a fixed sequence rather than a dynamic offset. This avoids the near-zero interval scenario entirely and prevents drift over time:
let getNextInterval startTime currentTick = let elapsedTicks = currentTick - startTime // Calculate how many full intervals have passed since start let intervalsCompleted = elapsedTicks / intervalTicks // Compute the exact tick of the next scheduled trigger let nextTriggerTick = startTime + (intervalsCompleted + 1) * intervalTicks // Return the time until that next trigger TimeSpan.FromTicks(nextTriggerTick - currentTick)
This approach aligns each trigger to a fixed cadence relative to the initial start time, so even if one iteration is delayed, the next trigger jumps back to the original schedule (no cumulative drift). You'd still want to add a guard for negative timespans (in case of extreme delays), but it's a cleaner alternative to the hack.
Why the Difference From System.Timers.Timer?
Your observation about System.Timers.Timer using lower-precision timing is spot-on, but it's not just about DateTime.Ticks. On Windows, System.Timers.Timer relies on CreateTimerQueueTimer, which by default uses the system's base clock interrupt (typically 15.625ms). To get high-resolution behavior from it, you'd need to call timeBeginPeriod(1) to request 1ms precision from the system. Your custom timer skips this by directly using QueryUnbiasedInterruptTime and leveraging the mailbox processor's more granular timeout handling, hence the drastically different trigger counts.
Quick Code Optimizations
Looking at your implementation, a couple of small tweaks could help:
- Add a platform check for
QueryUnbiasedInterruptTimeto fall back toStopwatch.GetTimestamp()on non-Windows systems (for cross-platform support). - Expose a configurable minimum interval instead of hardcoding 1ms, so users can adjust based on their use case.
- Add logging or metrics for when
QueryUnbiasedInterruptTimefails (returnsNone) to catch edge cases where high-resolution timing isn't available.
Here's your formatted code for reference:
open System module private Timer = open System.Runtime.InteropServices open System.Runtime.Versioning [<DllImport("kernel32.dll")>] [<ResourceExposure(ResourceScope.None)>] extern bool QueryUnbiasedInterruptTime (int64& value) let inline private queryUnbiasedInterruptTime () = let mutable ticks = 0L if QueryUnbiasedInterruptTime &ticks then Some ticks else None /// Get the current timestamp in 100-ns increments let getTicks () = match queryUnbiasedInterruptTime() with | Some ticks -> ticks | _ -> DateTime.UtcNow.Ticks type private TimerMessage = | Start | Stop | Wait of AsyncReplyChannel<unit> type ImmutableTimer (interval: TimeSpan, ?timeout: TimeSpan) = let zero = TimeSpan.FromMilliseconds(0.0) let one = TimeSpan.FromMilliseconds(1.0) let elapsed = Event<unit>() let intervalTicks = interval.Ticks let getNextInterval startTime = match intervalTicks - (Timer.getTicks() - startTime) |> TimeSpan.FromTicks with // This feels like a hack, but is required to fix a near-zero next interval | x when x < one -> interval | x when x > zero -> x | _ -> zero let agent = MailboxProcessor<TimerMessage>.Start <| fun inbox -> let rec loop isStarted (waiter: AsyncReplyChannel<unit> option) endTime (nextInterval: TimeSpan) = async { let startTime = Timer.getTicks() try let! message = inbox.Receive(nextInterval.TotalMilliseconds |> Math.Ceiling |> int) match message with | Start -> match timeout with | Some time -> return! getNextInterval startTime |> loop true waiter (Timer.getTicks() + time.Ticks |> DateTime.FromFileTimeUtc |> Some) | None -> return! getNextInterval startTime |> loop true waiter None | Stop -> match waiter with | Some channel -> channel.Reply() | None -> () | Wait channel -> return! getNextInterval startTime |> loop isStarted (Some channel) endTime with | _ -> if isStarted then Async.Start <| async { elapsed.Trigger() } match endTime with | Some time -> if DateTime.FromFileTimeUtc(Timer.getTicks()) < time then return! getNextInterval startTime |> loop isStarted waiter endTime else match waiter with | Some channel -> channel.Reply() | None -> () | None -> return! getNextInterval startTime |> loop isStarted waiter endTime } interval |> loop false None None let start () = agent.Post Start let stop () = agent.Post Stop let wait () = agent.PostAndReply Wait let asyncWait () = agent.PostAndAsyncReply Wait new (interval, ?timeout) = match timeout with | Some t -> ImmutableTimer(interval, t) | None -> ImmutableTimer(interval)
内容的提问来源于stack exchange,提问作者Aaron M. Eshbach

