1. Background
There are 3 classes about timers in C#:
(1) Defined in
(2) Defined in the class
(3) Defined in the class
Timer is used to trigger events at user-defined event intervals. Windows timers are designed for a single-threaded environment where UI threads are used to perform processing. It requires that the user code has an available UI message pump and is always operated in the same thread, or marshaling the call to another thread.
When using this timer, use the control's Tick event to perform a polling operation, or display the startup screen within a specified time. Whenever the Enabled property is set to true and the Interval property is greater than 0, a Tick event will be raised, and the time interval raised is set based on the Interval property.
It is applied in WinForm. It is implemented through the Windows message mechanism, similar to the Timer control in VB or Delphi, and is implemented internally using API SetTimer. His main disadvantage is that the timing is inaccurate and there must be a message loop, which is unavailable by the Console Application.
Very similar, they are implemented through .NET Thread Pool, which is lightweight and has no special needs for applications and messages. It can also be applied to WinForm, completely replacing the Timer control above.
However, its accuracy is not high (generally around 15ms), making it difficult to meet the needs of some scenarios.
Timers with precision below 10ms may be required when performing media playback, animation, performance analysis, and interacting with hardware. I will not discuss whether this demand is reasonable here. It is a problem that exists, and there are quite a few places to discuss it, which shows that this is a tangible demand. However, realizing it is not an easy task.
This does not involve timers at the kernel driver level, but only analyzes the implementation of high-precision timers at the application level in .NET hosting environments.
Windows is not a real-time operating system, so no solution can absolutely guarantee the accuracy of the timer, but can only minimize errors. Therefore, the stability of the system cannot rely entirely on the timer, and the processing when the synchronization is lost must be considered.
2. Waiting strategy
To implement a high-precision timer, you must have two basic functions: waiting and timing. Waiting is used to skip a certain time interval, and the timing can be checked for time to adjust the waiting time.
There are actually two types of waiting strategies:
Spin Waiting: Let the CPU idling consume time and take up a lot of CPU time, but the time is highly controllable.
Blocking and waiting: The thread enters the blocking state, transfers the CPU time slice, and waits for a certain period of time before the operating system dispatches back to the running state. The CPU is not occupied during blocking, but the operating system is required to schedule, which makes time difficult to control.
It can be seen that the two have their own advantages and disadvantages, and they should be implemented differently according to different needs.
The timing mechanism can be said to be only one of the functions that can be used, which is the Stopwatch class. It uses the system API QueryPerformanceCounter/QueryPerformanceFrequency internally for high-precision timing. It depends on hardware and its accuracy can reach up to tens of nanoseconds, making it ideal for implementing high-precision timers.
So the difficulty lies in the waiting strategy. Let’s analyze the simple spin waiting first.
2.1 Spin Wait
You can use (int iteration) to perform spin, that is, let the CPU idle in a loop, and the iteration parameter is the number of iterations. It is used in many synchronization constructs in the .NET Framework, which is used to wait for a short period of time to reduce the overhead of context switching.
It is difficult to calculate the time consumed based on iteration here, because CPU speed may be dynamic. So you need to use Stopwatch in combination. The pseudo-code is as follows:
var Wait for the start time = Current timing; while ((Current timing - Wait for the start time) < Time to wait) { Spin; }
Write it into actual code:
void Spin(Stopwatch w, int duration) { var current = ; while (( - current) < duration) (10); }
Here w is a Stopwatch that has been started. In order to demonstrate, the ElapsedMilliseconds property is simply used. The accuracy is milliseconds. Using the ElapsedTicks property can achieve higher accuracy (microseconds).
However, as mentioned earlier, this accuracy is high but at the expense of CPU time, so implementing a timer will allow a CPU core to work at full capacity (if the task being executed is not blocked). It is equivalent to wasting a core, which is not realistic at times (such as few cores or even single cores on virtual machines), so you need to consider blocking and waiting.
2.2 Blocking and waiting
Blocking waiting will hand over control to the operating system, so that it is necessary to ensure that the operating system can schedule the timer thread back to the running state in time. By default, the Windows system timer accuracy is 15.625ms, which means that the time slice is this size. If the thread is blocked, let its time slice wait, and then the time to be scheduled to run is at least one slice of 15.625ms. Then the length of the time slice must be reduced to achieve higher accuracy.
The system timer accuracy can be modified to 1ms through the system API timeBeginPeriod (it uses NtSetTimerResolution with no document given, and this API can be modified to 0.5ms). Use timeEndPeriod to restore when not needed.
Modifying the accuracy of the system timer has side effects. It will increase the overhead of context switching, increase power consumption, and reduce overall system performance. However, many programs have to do this because there is no other way to achieve higher timer accuracy. For example, many programs based on WPF (including Visual Studio), applications using the Chromium kernel (Chrome, QQ), multimedia players, games, etc. will modify the system timer accuracy to 1ms within a certain period of time. (See later on when viewing)
So in fact, this side effect has become the norm in the desktop environment. And since Windows 8, this side effect has been weakened.
Under the premise of 1ms system timer accuracy, there are three ways to implement blocking and waiting:
(1)
(2)
(3)
In addition, the multimedia timer timeSetEvent also uses the blocking method.
(1)
Its parameters use millisecond units, so it can only be accurate to 1ms at most. However, in fact, it is very unstable. (1) It will jump between 1ms and 2ms states, which means that it may produce +1ms more errors.
Actual measurements have found that in the absence of task load (pure loop call Sleep(1)), the blocking time is stable at 2ms; when there is task load, it will be blocked at least 1ms. This is different from the other two blocking methods, please see the following article for details.
If you need to correct this error, you can use Sleep(n-1) when blocking n milliseconds and time it through Stopwatch, and the remaining waiting time is supplemented by Sleep(0), or spin.
Sleep(0) will send the remaining CPU time slices to threads with the same priority, but will send the remaining CPU time slices to threads running on the same core. After the time slice of the sale ends, it will be rescheduled. Generally, the entire process can be completed within 1ms.
(0) and very unstable under high CPU load conditions, the actual measurement may block up to 6ms, so more errors may occur. Therefore, error correction is best achieved through spin.
(2)
Similarly, parameters are also units of milliseconds.
The difference is that in the absence of task load (pure loop call WaitOne(1)), the blocking time is stable at 1.5ms; when there is task load, it may only block time of nearly 0 (guessing that it only blocks until the end of the current time slice, and no specific documentation has been found). So the duration of its blocking range is more than 0 to 2ms.
(0) is used to test the waiting handle state, it does not block, so using it to correct errors is similar to spin, but it is not as reliable as using spin directly.
(3)
The parameters of the method are in microseconds. In theory, it uses the network card hardware to timing, and the accuracy is very high. However, since the blocking implementation still depends on threads, it can only achieve 1ms accuracy.
Its advantage is that it is more stable than sum and has smaller errors. It does not require correction, but it occupies a Socket port.
In the absence of task load (pure loop call Poll(1)), the blocking time is stable at 1ms; when there is task load, it is similar to WaitOne, which may only block time of nearly 0. So the duration of its blocking range is more than 0 to 1ms.
(0) is used to test the Socket state, but it will block and may block up to 6ms, so it cannot be used for error correction.
2.3timeSetEvent
timeSetEvent is the multimedia timer function provided by the timeBeginPeriod mentioned earlier. It can be used directly as a timer and also provides 1ms accuracy. Use timeKillEvent to close when not needed.
It also has high stability and precision, and this is the ideal solution if 1ms timing is required and spin cannot be used.
Although MSDN says timeSetEvent is an outdated method, it should be replaced with CreateTimerQueueTimer. However, the CreateTimerQueueTimer is not as accurate and stable as the multimedia timer, so when high precision is required, you can only use timeSetEvent.
3. Timer implementation
It should be noted that whether it is spin or blocking, it is obvious that the timer should run on an independent thread and cannot interfere with the user thread's work. For high-precision timers, the threads that trigger events to execute tasks are generally within the timer thread, rather than using independent task threads.
This is because in high-precision timing scenarios, the time overhead of executing tasks is likely to be greater than the time interval of the timer. If the task is executed on other threads by default, it may occupy a large number of threads. Therefore, the control should be given to the user, so that the user can schedule the threads of task execution by themselves when needed.
3.1 Trigger mode
Since the task is executed on the timer thread, the triggering of the timer produces three modes. Here are their descriptions and main loop pseudocode:
(1) Fixed time frame
For example, if the interval is 10ms and the task is 7-12ms, it will be carried out according to waiting 10ms, task 7ms, waiting 3ms, task 12ms (timeout 2ms lose synchronization), task 7ms, waiting 1ms (return to synchronization), task 7ms, waiting 3ms,... It is to try to execute tasks according to the set time frame. As long as the task does not always time out, you can return to the original time frame.
var Next frame time = 0; while(Timer on) { Next frame time += Interval time; while (Current timing < Next frame time) { wait; } Trigger task; }
(2) The time frame can be postponed:
The above example will be carried out according to waiting 10ms, task 7ms, waiting 3ms, task 12ms (timeout, delayed time frame 2ms), task 7ms, waiting 3ms,... Timeout tasks delay the time frame.
var Next frame time = 0; while(Timer on) { Next frame time += Interval time; if (Next frame time < Current timing) Next frame time = Current timing while (Current timing < Next frame time) { wait; } Trigger task; }
(3) Fixed waiting time
The above example will be carried out as waiting 10ms, task 7ms, waiting 10ms, task 12ms, waiting 10ms, task 7ms... The waiting time remains unchanged.
while(Timer on) { var Wait for the start time = Current timing; while ((Current timing - Wait for the start time) < Interval time) { wait; } Trigger task; } // or:var Next frame time = 0; while(Timer on) { Next frame time += Interval time; while (Current timing < Next frame time) { wait; } Trigger task; Next frame time = Current timing; }
If a multimedia timer (timeSetEvent) is used, it implements the first mode firmly, while other waiting strategies can implement all three modes, which can be selected according to the needs.
Waiting in a while loop can use spin or blocking, or they can be combined to achieve a balance of accuracy, stability and CPU overhead.
In addition, it can be seen from the above pseudo-code that the implementation of these three modes can be unified and can be switched according to the situation.
3.2 Thread priority
It is best to increase the thread priority to ensure that the timer can work stably and reduce the chance of being preempted. However, it should be noted that this can lead to low-priority thread starvation when CPU resources are insufficient. In other words, high-priority threads cannot be allowed to wait for low-priority threads to change their state. It is very likely that low-priority threads do not have a chance to run, resulting in deadlock or deadlock-like states. (See a similar example of hunger)
The final priority of a thread is related to the priority of the process, so sometimes it is necessary to increase the process priority (see the thread priority description of the multithreaded series in C#).
4. Others
There are two more points to note:
(1) Thread safety: The timer runs on an independent thread, and all exposed members should be thread-safe, otherwise the call may cause problems when the timer runs.
(2) Release resources in a timely manner: multimedia timers, waiting handles, threads, etc. are system resources, and they should be released/destroyed in time when they are not needed.
How to check the accuracy of the system timer?
For a simple view, you can use ClockRes in the Sysinternals toolkit, which will display the following information:
Maximum timer interval: 15.625 ms Minimum timer interval: 0.500 ms Current timer interval: 15.625 ms // or Maximum timer interval: 15.625 ms Minimum timer interval: 0.500 ms Current timer interval: 1.000 ms
If you want to see which programs request higher system timer accuracy, run:
powercfg energy -duration 5
It monitors the system's energy consumption for 5 seconds, and then generates an analysis report in the current directory, which can be opened to view.
If you find the warning part inside, there will be information about the platform timer resolution: unfinished timer request (Platform Timer Resolution: Outstanding Timer Request).