DEV Community

mapogolions
mapogolions

Posted on

.NET Timers Internals

The basic abstractions

The primary abstractions in the timer mechanism are two classes within the System.Threading namespace:

  • TimerQueueTimer
  • TimerQueue

Both classes are marked with the internal modifier and are intended for use within the standard library to build higher-level abstractions such as System.Threading.Timer, System.Threading.PeriodicTimer, and System.Timers.Timer.

TimerQueueTimer (internal class)

A timer is a wrapper object around a callable entity that is set to execute after a specified duration (dueTime). It also supports rescheduling with a specified interval (period).

Timers are stored in a timer queue (TimerQueue). When a timer (TimerQueueTimer) is created, it is associated with the timer queue (TimerQueue) via the TimerQueueTimer._associatedTimerQueue property.

Thus, the internal state of a timer can be represented as follows:

TimerQueueTimer
{
    _associatedTimerQueue : TimerQueue
    _prev : TimerQueueTimer?
    _next : TimerQueueTimer?

    _callback : Action<object?>
    _state : object?
    _dueTime : ???
    _period : ???
}
Enter fullscreen mode Exit fullscreen mode

TimerQueue (internal class)

This class represents a timer queue where timers (TimerQueueTimer) are stored.

  • TimerQueue stores timers as two doubly linked lists:
    • TimerQueue._shortTimers : TimerQueueTimer - a queue for timers with short firing time.
    • TimerQueue._longTimers : TimerQueueTimer - a queue for timers with longer firing time.

When adding a new timer, the threshold ShortTimersThresholdMilliseconds, set to 333 milliseconds, is used to choose between _shortTimers and _longTimers.

  • Timers are stored as a doubly linked list, meaning each timer maintains references to the previous (TimerQueueTimer._prev) and next (TimerQueueTimer._next) timers.

  • New timers are always added to the beginning of the list, thus overwriting the previous head (TimerQueue._shortTimers or TimerQueue._longTimers). The addition order doesn’t depend on the scheduled firing time of the new timer compared to existing timers in the queue.

Short long timers

  • At any moment, the timer queue (TimerQueue) holds the state of the timer (TimerQueueTimer) with the nearest firing time. Each time a new timer is added, the queue checks if the state of the soonest-to-fire timer needs updating.

For example, if a timer with a due time of TimeSpan.FromMinutes(1) is created first, followed by a timer with a due time of TimeSpan.FromSeconds(30), both associated with the same timer queue (TimerQueue), the addition of the second timer will update the soonest-to-fire timer's state.

Timer Infrastructure

The timer infrastructure refers to a set of static collections and background threads that enable the operation of timers.

When an application starts:

1) A static array of timer queues, TimerQueue.Instances : TimerQueue[], is created with a size of Environment.ProcessorCount, populated with TimerQueue objects.
2) Two static collections are created for storing timer queues (TimerQueue). Initially, both collections are empty:
- TimerQueue.s_scheduledTimers : List<TimerQueue> - contains scheduled timer queues. A timer queue becomes scheduled as soon as at least one timer (TimerQueueTimer) is added to it.
- TimerQueue.s_scheduledTimersToFire : List<TimerQueue> - holds TimerQueue objects that are ready to execute on the thread pool, based on the nearest firing timer in each queue.

3) A background thread is created and started to monitor timer queues for readiness and schedule their execution on the thread pool. In other words, this background thread monitors the static collection TimerQueue.s_scheduledTimers and, as needed, moves timer queue objects to the other static collection, TimerQueue.s_scheduledTimersToFire. A more detailed explanation of the background thread's operation is provided in the Timer Scheduler section.

The key components of the timer insfrastructure.

The key components


The above illustration is simplified. Developers aim to defer entity creation as long as possible. Only the first step above occurs immediately when the application starts. Entities in steps 2 and 3 are created only when the first timer (TimerQueueTimer) is registered in one of the timer queues (TimerQueue). However, this simplification doesn’t impact understanding of the timer mechanism and eases explanation.

The background thread schedules execution of the timer queue (TimerQueue) on the thread pool, not individual timers (TimerQueueTimer), based on the state of the nearest firing timer within that queue. The timer queue (TimerQueue) implements the IThreadPoolWorkItem contract, allowing it to be executed on the thread pool.

The callback associated with a timer executes on the thread pool rather than in the dedicated background thread mentioned in step 3.

Timer Scheduler

The two static collections:

  • TimerQueue.s_scheduledTimers
  • TimerQueue.s_scheduledTimersToFire

and the background thread can be conceptually grouped into a separate component called TimerScheduler, which is responsible for scheduling the execution of timer queues on the thread pool.

The background thread's entire activity consists of moving timer queues (TimerQueue) from one static list to another. More specifically:

  • The thread repeatedly checks the nearest firing timer (TimerQueueTimer) in each timer queue (TimerQueue) from the scheduled list (TimerQueue.s_scheduledTimers) and transfers those timer queues that are ready to TimerQueue.s_scheduledTimersToFire.
  • After this operation, it checks the TimerQueue.s_scheduledTimersToFire collection, and if it is not empty, it schedules the execution of the timer queues on the thread pool.

Here’s a simplified implementation of the above algorithm:

while (true)
{
    foreach (var timerQueue in TimerQueue.s_scheduledTimers)
    {
        if (timerQueue.IsClosestTimerReadyToFire)
        {
            TimerQueue.s_scheduledTimersToFire.Add(timerQueue);
            // remove `timerQueue` from s_scheduledTimers
        }
    }

    if (TimerQueue.s_scheduledTimersToFire.Count > 0)
    {
        foreach (var timerQueueToFire in TimerQueue.s_scheduledTimersToFire)
        {
            // schedule timerQueue on thread pool
            ThreadPool.UnsafeQueueHighPriorityWorkItemInternal(timerQueueToFire);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It is important to understand that even if a timer queue (TimerQueue) has been scheduled for execution on the thread pool based on the nearest firing timer's time for that queue, this does not mean that other timers (TimerQueueTimer) stored in the same queue are ready to fire.

During execution on the thread pool, the timer queue (TimerQueue) iterates over the two doubly linked lists (_shortTimers and _longTimers) of timers (TimerQueueTimer), checking their readiness to fire. If a timer is ready, the timer itself (TimerQueueTimer) is then scheduled for execution on the thread pool, as it also implements the IThreadPoolWorkItem contract.

Unguaranteed Execution Order

It was previously stated that new timers are always added to the beginning of the list. This means that situations can arise where the callback for a timer with a later firing time is invoked before the callback for a timer with an earlier firing time.

There are various scenarios in which this can happen. For example, if timers created are associated with the same timer queue (TimerQueue), and the last added timer has a slightly later firing time than the first. There is a non-zero probability that by the time the timer queue is executed on the thread pool, both timers will be ready for execution. Since the timer added last is the head of the doubly linked list, it will be attempted to schedule it first.

Creating a timer and adding it to the queue

Before moving forward, we need to introduce two informal concepts that will be used later:

  • association - this refers to the fact of assigning a reference to a selected timer queue. After the association, the timer will hold a reference to one of the timer queues from TimerQueue.Instances through its property TimerQueueTimer._associatedTimerQueue.
  • linking - this refers to the fact of adding the timer to the timer queue with which it is associated. After binding, the timer becomes part of a doubly linked list (TimerQueue._shortTimers or TimerQueue._longTimers) and may (if it is not the only timer in the queue) hold references to the previous and next timers through the properties TimerQueueTimer._prev and TimerQueueTimer._next.

By the time of linking, the timer (TimerQueueTimer) is always associated with one of the timer queues (TimerQueue).

A timer can be associated but not linked to a timer queue.

Creating a timer with infinite firing time

To create a timer with infinite firing time, you need to specify dueTime as Timeout.InfiniteTimeSpan.

A timer with infinite firing time will be associated with one of the timer queues (TimerQueue), but it will not be added to it. This fact has a consequence: if the timer queue (TimerQueue) is empty, creating a timer with an infinite due time will not cause the timer queue to acquire the status of scheduled. In other words, the timer queue will not be placed in the static collection TimerQueue.s_scheduledTimers, and therefore will not be monitored by the background thread.

A timer (TimerQueueTimer) with infinite firing time can be linked to the associated timer queue (TimerQueue), but to do this, the Change method must be called with a finite dueTime.

Creating a timer with finite firing time

  • When a timer (TimerQueueTimer) is created, it is associated with one of the timer queues (TimerQueue) through its property TimerQueueTimer._associatedTimerQueue.
  • The created timer is added to the beginning of the list, thereby overwriting the previous head (TimerQueue._shortTimers or TimerQueue._longTimers).
  • The associated timer queue TimerQueue becomes scheduled if it was not previously.

Top comments (0)