I'm implementing a capture loop with a FPS/FPM/FPH (seconds, minutes, hours) control. Meaning that the user can capture something normally or as a time-lapse.
Here's my code:
private System.Threading.CancellationTokenSource _captureToken;
private List<long> _timeList = new List<long>();
private void CaptureRun(int interval)
{
var sw = new Stopwatch();
while (_captureToken != null && !_captureToken.IsCancellationRequested)
{
sw.Restart();
//Capture happens here...
//With or without my capture code, the result is the same (the difference in average time).
//So I removed this part of the code to make it easier to understand.
//If behind wait time, wait before capturing again.
if (sw.ElapsedMilliseconds < interval)
System.Threading.SpinWait.SpinUntil(() => sw.ElapsedMilliseconds >= interval);
_timeList.Add(sw.ElapsedMilliseconds);
}
}
//Code that starts the capture (simplified).
private void StopCapture()
{
_captureToken = new System.Threading.CancellationTokenSource();
Task.Run(() => CaptureRun(16), _captureToken.Token);
}
//Code that stops the capture.
private void StopCapture()
{
if (_captureToken != null)
{
_captureToken.Cancel();
_captureToken.Dispose();
_captureToken = null;
}
}
The issue being that if the interval is set to 60 FPS (16ms), the resulting capture time averages in 30ms. But if I set the capture interval to 15ms (> 60 FPS), it averages in 15ms as expected.
I wonder why that happens and if it's possible to improve the code.
Based on Alois' comment, I managed to create this extension class:
internal class TimerResolution : IDisposable
{
#region Native
[StructLayout(LayoutKind.Sequential)]
private readonly struct TimeCaps
{
internal readonly uint MinimumResolution;
internal readonly uint MaximumResolution;
};
internal enum TimerResult : uint
{
NoError = 0,
NoCanDo = 97
}
[DllImport("winmm.dll", EntryPoint = "timeGetDevCaps", SetLastError = true)]
private static extern uint GetDevCaps(ref TimeCaps timeCaps, uint sizeTimeCaps);
[DllImport("ntdll.dll", EntryPoint = "NtQueryTimerResolution", SetLastError = true)]
private static extern int QueryTimerResolution(out int maximumResolution, out int minimumResolution, out int currentResolution);
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod")]
internal static extern uint BeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll", EntryPoint = "timeGetTime")]
internal static extern uint GetTime();
[DllImport("winmm.dll", EntryPoint = "timeEndPeriod")]
internal static extern uint EndPeriod(uint uMilliseconds);
#endregion
#region Properties
/// <summary>
/// The target resolution in milliseconds.
/// </summary>
public uint TargetResolution { get; private set; }
/// <summary>
/// The current resolution in milliseconds.
/// May differ from target resolution based on system limitation.
/// </summary>
public uint CurrentResolution { get; private set; }
/// <summary>
/// True if a new resolution was set (target resolution or not).
/// </summary>
public bool SuccessfullySetResolution { get; private set; }
/// <summary>
/// True if a new target resolution was set.
/// </summary>
public bool SuccessfullySetTargetResolution { get; private set; }
#endregion
/// <summary>
/// Tries setting a given target timer resolution to the current thread.
/// If the selected resolution can be set, a nearby value will be set instead.
/// This must be disposed afterwards (or call EndPeriod() passing the CurrentResolution)
/// </summary>
/// <param name="targetResolution">The target resolution in milliseconds.</param>
public TimerResolution(int targetResolution)
{
TargetResolution = (uint) targetResolution;
//Get system limits.
var timeCaps = new TimeCaps();
if (GetDevCaps(ref timeCaps, (uint) Marshal.SizeOf(typeof(TimeCaps))) != (uint) TimerResult.NoError)
return;
//Calculates resolution based on system limits.
CurrentResolution = Math.Min(Math.Max(timeCaps.MinimumResolution, TargetResolution), timeCaps.MaximumResolution);
//Begins the period in which the thread will run on this new timer resolution.
if (BeginPeriod(CurrentResolution) != (uint) TimerResult.NoError)
return;
SuccessfullySetResolution = true;
if (CurrentResolution == TargetResolution)
SuccessfullySetTargetResolution = true;
}
public void Dispose()
{
if (SuccessfullySetResolution)
EndPeriod(CurrentResolution);
}
}
Just use the extension class in a using
block and put it inside whatever you want to run in another timer resolution:
using (var resolution = new TimerResolution(1))
{
//...
}
You see a 15 ms delays when sleeping for 15ms and a 30 ms delays when sleeping for 16ms because SpinWait uses under the hood Environment.TickCount which relies on the system clock which has apparently on your system a 15ms resolution. You can set the system wide timer resolution by using timeBeginPeriod. See
and
for more in depth information about the clock resolution. You can check your current sytem wide timer resolution with clockres from sysinternals.
See example output:
C:>clockres
Clockres v2.1 - Clock resolution display utility
Copyright (C) 2016 Mark Russinovich
Sysinternals
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
**Current timer interval: 15.625 ms**
When a WPF application is running (e.g. Visual Studio)
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
**Current timer interval: 1.000 ms**
then you get a 1ms resolution because every WPF application changes the clock resolution to 1ms. This is also used by some guys as workaround to "fix" the issue.