Skip to content

Conversation

@tannergooding
Copy link
Member

@tannergooding tannergooding commented Dec 22, 2025

Unix systems (Linux and MacOS) were already using timers like CLOCK_MONOTONIC_COARSE and CLOCK_UPTIME_RAW and so allowed for higher responsiveness.

It was only Windows which was restricting itself to 10-16ms (typically 15.5ms) of responsiveness and which did not respect apps which set higher precision tick times. This was also then inconsistent with changes the OS itself made (in Win8+) to its various Wait APIs to no longer include sleep/hibernate time (bias) as part of their timeout checks.

Linux will fallback to CLOCK_MONOTONIC if CLOCK_MONOTONIC_COARSE isn't available. The non-coarse version is closer to QueryUnbiasedInterruptTimePrecise which is an "exact" rather than cached query.

MacOS doesn't try to use CLOCK_UPTIME_RAW_APPROX which is the "coarse" equivalent.

@jkotas
Copy link
Member

jkotas commented Dec 22, 2025

. Windows has historically (via GetTickCount64) included sleep/hibernation time and so QueryInterruptTime matches this. This also matches what CLOCK_MONOTONIC and CLOCK_MONOTONIC_COARSE

#119437 claims that it is not the case on Linux. (#119443 was an attempt to fix that, but it is less straightforward than one would think #119443 (comment) .)

@jkotas jkotas added area-System.Threading and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Dec 22, 2025
@tannergooding
Copy link
Member Author

#119437 claims that it is not the case on Linux. (#119443 was an attempt to fix that, but less straightforward than one would think #119443 (comment) .)

Ah, right. I seem to have gotten those mixed up when I was writing things down.

So then Windows is the only one that includes sleep time today. Then, notably, QueryInterruptTime is only available on Windows 10 and Server 2016+, while we still support Server 2012 R2. QueryUnbiasedInterruptTime does not include sleep time and is available on older versions.


I guess it becomes a question of

Do we want to match the historical Windows or the now historical Unix behavior in inclusion of sleep/hibernation time?

If we do (include sleep/hibernation), then on Windows (assuming .NET 11 still supports 2012 R2) then the fix is a bit more complex and needs to choose one of GetTickCount64 (older systems) or QueryInterruptTime (newer systems) and then MacOS and Linux would have to be updated (across the board) to factor in the sleep/hibernation time.

If we don't, then on Windows we can just use QueryUnbiasedInterruptTime on all systems and then leave Linux and MacOS "as is".

There's risk both ways and general inconsistency today. I do think it would be goodness to normalize here and for Windows to match more modern conventions and respect app preferences for higher frequency interrupt cadence, particularly since it maintains the status quo for apps that aren't opting to change their precision.

@jkotas
Copy link
Member

jkotas commented Dec 22, 2025

general inconsistency today

Do we think that the inconsistency between Environment.TickCount64 (includes suspend time) and timeouts (does not include suspend time) is a problem worth solving? Is there a standard that APIs like this follow by default on other platforms?

@tannergooding
Copy link
Member Author

Do we think that the inconsistency between Environment.TickCount64 (includes suspend time) and timeouts (does not include suspend time)

As best as I can tell, Unix isn’t including suspend time anywhere today while Windows always is, so the inconsistency here exists for TickCount64 between Windows and Linux. We then have a large number of timers and other checks (including GC) driven off TickCount64 (or the backing call for it).

is a problem worth solving?

I would, just going off intuition, expect most apps aren’t impacted by or even experiencing suspend/sleep events. Those that are would be more likely mobile or laptop scenarios where users are likely on Linux.

I do think it’d be worth making it consistent across platforms here and think that excluding sleep time would be the “better” route, despite it being the historical Windows precedent.

I would expect most users don’t factor in sleep time and would want to be explicit if they did want that, which seems to match what modern Windows is providing and what Unix provides via its various CLOCK kinds.

Is there a standard that APIs like this follow by default on other platforms?

Nothing formal that I’m aware of. Just most docs, tutorials, and other sources pushing users towards the APIs that don’t include sleep time by default and the ones that do having less common names.

————-

If we did go the route of normalizing and chose to not have TickCount64 include sleep time, then we just leave Linux and MacOS as is. We then use QueryUnbiasedInteruptTime on Windows, which solves the Server 2012 R2 questions and simplifies the lib dependency changes.

We could then consider some BiasedTickCount64 or better name, if we really felt the need, so that users could get the tick count + sleep time if desired. (But I do think we could wait for feedback on that)

now seems the “right time” to do such a break, if we are going to do it, given how early we are in the release cycle

@jkotas
Copy link
Member

jkotas commented Dec 22, 2025

Unix isn’t including suspend time anywhere today while Windows always is

I think the current state is:

  • Windows TickCount - includes sleep time
  • Windows timeouts (based on WaitHandle.Wait*) and timers (based on System.Threading.Timer) - do not include sleep time
  • Unix TickCount - does not include sleep time
  • Unix timeouts and timers - do not include sleep time

Does this match your understanding?

This suggests that Windows TickCount is the outlier and the smallest delta is to fix this outlier. It is aligned with your thinking that excluding sleep time would be the "better" route.

We could then consider some BiasedTickCount64

It would be functionally equivalent to DateTime.UtcNow. So the only reason for introducing an API like this would be performance. It is hard to believe that we would find good motivating scenario for it.

@tannergooding
Copy link
Member Author

Windows timeouts (based on WaitHandle.Wait*) and timers (based on System.Threading.Timer) - do not include sleep time

@jkotas, I don't think this is entirely true and rather it is a bit dependent on which flow path we do. As an example, lets look at the flow for WaitHandle.WaitOne(int millisecondsTimeout):


We have similar checks and flow for System.Threading.Timer, for the thread pool, for various tasks and managed wait helpers, etc. -- You can basically summarize most of our code as taking advantage of the Win32 asynchronous aware waiting APIs and then use Environment.TickCount64 when we are woken "early".

This is also an area where Win32 has itself taken breaking changes. For example, WaitForMultipleObjectsEx has this documentation

Windows XP, Windows Server 2003, Windows Vista, Windows 7, Windows Server 2008, and Windows Server 2008 R2: The dwMilliseconds value does include time spent in low-power states. For example, the timeout does keep counting down while the computer is asleep.

Windows 8 and newer, Windows Server 2012 and newer: The dwMilliseconds value does not include time spent in low-power states. For example, the timeout does not keep counting down while the computer is asleep.

So while in some scenarios we might not end up factoring in sleep time, other paths will end up falling back to checking Environment.TickCount64 and so do end up including the sleep time today, causing this weird handling which is particularly prevalent anytime a wait returns "early" due to an APC being queued (effectively a Win32 task continuation) or an I/O completion routine running.

@tannergooding
Copy link
Member Author

This suggests that Windows TickCount is the outlier and the smallest delta is to fix this outlier. It is aligned with your thinking that excluding sleep time would be the "better" route.

👍, I'll update the PR here to use QueryUnbiasedInterruptTime.

It would be functionally equivalent to DateTime.UtcNow. So the only reason for introducing an API like this would be performance. It is hard to believe that we would find good motivating scenario for it.

There's still quite a bit of difference here, namely in that it isn't synchronized to an external time source and so isn't impacted by things like clock adjustments.

However, I do agree that we would really need and want a motivating scenario as the reason to push such a thing forward. Users do have relatively trivial workarounds if they really do need such an API and it wouldn't be hard to have some common community package for it.

@tannergooding tannergooding changed the title Use QueryInterruptTime instead of GetTickCount64 to allow more responsive Windows apps when they opt-in Use QueryUnbiasedInterruptTime instead of GetTickCount64 to allow more responsive Windows apps when they opt-in Dec 23, 2025
@tannergooding tannergooding added breaking-change Issue or PR that represents a breaking API or functional change over a previous release. needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet labels Dec 23, 2025
@dotnet-policy-service
Copy link
Contributor

Added needs-breaking-change-doc-created label because this PR has the breaking-change label.

When you commit this breaking change:

  1. Create and link to this PR and the issue a matching issue in the dotnet/docs repo using the breaking change documentation template, then remove this needs-breaking-change-doc-created label.
  2. Ask a committer to mail the .NET Breaking Change Notification DL.

Tagging @dotnet/compat for awareness of the breaking change.

@tannergooding tannergooding marked this pull request as ready for review December 23, 2025 07:00
Copilot AI review requested due to automatic review settings December 23, 2025 07:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces GetTickCount64 with QueryUnbiasedInterruptTime across Windows implementations to enable higher timer responsiveness when applications opt into multimedia scenarios. Previously, Windows was restricted to 10-16ms resolution for backward compatibility, while Unix systems already used higher-resolution timers. The change also ensures consistency with Windows 8+ behavior of excluding sleep/hibernation time from timer calculations.

Key changes:

  • Replaces GetTickCount64 calls with QueryUnbiasedInterruptTime in native (C/C++) and managed (C#) code
  • Updates P/Invoke signature to use unsafe pointers with SuppressGCTransition for better performance
  • Simplifies TimerQueue.Windows.cs by removing Windows 8+ version check logic, as QueryUnbiasedInterruptTime is now used unconditionally

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/native/minipal/time.c Updated minipal_lowres_ticks() to call QueryUnbiasedInterruptTime instead of GetTickCount64 with proper unit conversion
src/libraries/System.Private.CoreLib/src/System/Threading/TimerQueue.Windows.cs Simplified to directly use Environment.TickCount64 by removing Windows 8+ conditional logic that previously called QueryUnbiasedInterruptTime
src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs Changed TickCount64 property implementation from GetTickCount64 to QueryUnbiasedInterruptTime with unsafe pointer usage
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.QueryUnbiasedInterruptTime.cs Updated P/Invoke signature to use unsafe ulong* parameter with SuppressGCTransition attribute for performance
src/coreclr/gc/windows/gcenv.windows.cpp Updated GetLowPrecisionTimeStamp() to call QueryUnbiasedInterruptTime instead of GetTickCount64

@jkotas
Copy link
Member

jkotas commented Dec 23, 2025

Windows timeouts (based on WaitHandle.Wait*) and timers (based on System.Threading.Timer) - do not include sleep time

@jkotas, I don't think this is entirely true and rather it is a bit dependent on which flow path we do.

Yes, I agree that there are inconsistencies. I believe that the behavior one gets most of the time does not include the sleep time today.

…yUnbiasedInterruptTime.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@jkotas
Copy link
Member

jkotas commented Dec 23, 2025

@EgorBot -windows_x64

using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
using System.Collections;

public class Bench
{
    [Benchmark]
    public long TickCount() => Environment.TickCount64;
}

@jkotas
Copy link
Member

jkotas commented Dec 23, 2025

@EgorBot -windows_intel

using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
using System.Collections;

public class Bench
{
    [Benchmark]
    public long TickCount() => Environment.TickCount64;
}

@jkotas
Copy link
Member

jkotas commented Dec 23, 2025

@EgorBo Looks like -windows_intel does not work anymore with EgorBot. Could you please take a look?

@tannergooding
Copy link
Member Author

A local benchmark gives:

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7462/25H2/2025Update/HudsonValley2)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores
.NET SDK 10.0.101
  [Host]     : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4
  DefaultJob : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4
Method Mean Error StdDev Ratio RatioSD
GetTickCount64 1.004 ns 0.0256 ns 0.0214 ns 1.00 0.03
QueryUnbiasedInterruptTime 1.512 ns 0.0279 ns 0.0261 ns 1.51 0.04
QueryUnbiasedInterruptTimePrecise 18.282 ns 0.1419 ns 0.1327 ns 18.22 0.40
QueryPerformanceCounter 17.994 ns 0.1955 ns 0.1733 ns 17.93 0.41

So it's theoretically 50% slower, but not in any kind of meaningful way. Both because its in the 1ns range but also because while Environment.TickCount64 is "faster", querying it any more frequently than every 1ms is relatively pointless since the delta will almost certainly be zero.

There's then also the general nuance that since this is a cached value you can have up to the timer resolution in error. That is, when you call GetTickCount64() or QueryUnbiasedInterruptTime) it reads the last update. If the update frequency is every 15ms, then you may be reading a value that happened up to 14.9999... ms ago. So comparing two such tick counts may return a 15ms difference but could have only had 1ms of time elapsed (i.e. you may get a delta of 15ms after only 0.5ms has has actually elapsed).

As such, I included the timings for QueryUnbiasedInterruptTimePrecise and QueryPerformanceCounter just for additional measure, with all sources normalizing to 1ms "ticks". We couldn't use QueryUnbiasedInterruptTimePrecise on Server 2012 R2 and while this and QueryPerformanceCounter are approx 20x slower, they still have the same consideration as above in that this won't really impact any code since we aren't querying even every microsecond anyways. -- I do think the non-precise/coarse time is fine, just wanted to ensure its all laid out in this issue.

Source was:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Diagnostics;
using System.Runtime.InteropServices;

BenchmarkRunner.Run<Benchmarks>();

public class Benchmarks
{
    [Benchmark(Baseline = true)]
    public long GetTickCount64()
    {
        return (long)Kernel32.GetTickCount64();
    }

    [Benchmark]
    public unsafe long QueryUnbiasedInterruptTime()
    {
        ulong unbiasedTime;

        int result = Kernel32.QueryUnbiasedInterruptTime(&unbiasedTime);
        Debug.Assert(result != 0);

        return (long)(unbiasedTime / TimeSpan.TicksPerMillisecond);
    }

    [Benchmark]
    public unsafe long QueryUnbiasedInterruptTimePrecise()
    {
        ulong unbiasedTime;
        Kernel32.QueryUnbiasedInterruptTimePrecise(&unbiasedTime);
        return (long)(unbiasedTime / TimeSpan.TicksPerMillisecond);
    }

    private static readonly double s_tickFrequency = (double)(TimeSpan.TicksPerSecond / TimeSpan.TicksPerMillisecond) / Stopwatch.Frequency;

    [Benchmark]
    public long QueryPerformanceCounter()
    {
        long timestamp = Stopwatch.GetTimestamp();
        return (long)(timestamp * s_tickFrequency);
    }
}

internal partial class Kernel32
{
    [LibraryImport("Kernel32")]
    [SuppressGCTransition]
    public static partial ulong GetTickCount64();

    [LibraryImport("Kernel32")]
    [SuppressGCTransition]
    public static unsafe partial int QueryUnbiasedInterruptTime(ulong* unbiasedTime);

    [LibraryImport("api-ms-win-core-realtime-l1-1-1")]
    [SuppressGCTransition]
    public static unsafe partial void QueryUnbiasedInterruptTimePrecise(ulong* unbiasedTime);
}

@EgorBo
Copy link
Member

EgorBo commented Dec 23, 2025

@EgorBo Looks like -windows_intel does not work anymore with EgorBot. Could you please take a look?

Ah, the bot didn't expect code snippets without trailing triple-ticks, not sure it's a valid markdown, but I'll handle that in the bot too. Successful run: EgorBot/runtime-utils#573 (comment)

Copy link
Member

@jkotas jkotas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you!

It will need follow up doc updates in addition to the breaking change notice.

@tannergooding
Copy link
Member Author

Breaking change doc is here: dotnet/docs#50755

It includes the relevant callout that we also need to update the docs page for Environment.TickCount and Environment.TickCount64.

@tannergooding tannergooding merged commit 023ad3f into dotnet:main Dec 24, 2025
162 of 164 checks passed
@tannergooding tannergooding deleted the fix-122680 branch December 24, 2025 06:19
@github-actions
Copy link
Contributor

📋 Breaking Change Documentation Required

Create a breaking change issue with AI-generated content

Generated by Breaking Change Documentation Tool - 2025-12-24 06:22:14

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Threading breaking-change Issue or PR that represents a breaking API or functional change over a previous release. needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TickCount64's Fixed ~15ms Resolution Hurts .NET's Semi-Realtime Responsiveness Despite timeBeginPeriod(1)

3 participants