You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义计时器与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 Receive call 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: QueryUnbiasedInterruptTime is 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 (like Stopwatch.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 elapsed event (using Async.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 QueryUnbiasedInterruptTime to fall back to Stopwatch.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 QueryUnbiasedInterruptTime fails (returns None) 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.27 07:03:12