Под капотом у Stopwatch

в 6:22, , рубрики: .net, api, таймер, метки: ,

Введение

Очень часто, нам разработчикам необходимо измерить время выполнения своего (и не только своего) кода. Когда я только начал программировать, я использовал структуру DateTime для этих целей. Прошло время, и я узнал о классе Stopwatch и начал его активно использовать. Думаю аналогичная ситуация была и у вас. Не то, чтобы я раньше не задавался вопросом о том, как работает Stopwatch, просто на тот момент знаний о том, что он позволяет измерять затраченное время точнее, чем DateTime мне хватало. Пришло время разъяснить себе, а так же читателям то, как на самом деле работает класс Stopwatch, а так же выяснить его преимущества и недостатки по сравнению с использованием DateTime.

Использование DateTime

Использовать структуру DateTime для замера времени выполнения кода достаточно просто:

var before = DateTime.Now;
SomeOperation();
var spendTime = DateTime.Now - before;

Свойство DateTime.Now — возвращает локальную текущую дату и время. Вместо свойства DateTime.Now можно использовать свойство DateTime.UtcNow — возвращающее текущую дату и время, но вместо локального часового пояса оно представляет их как время Utc, то есть как всемирное координированное время.

var before = DateTime.UtcNow;
SomeOperation();
var spendTime = DateTime.UtcNow - before;        

Несколько слов о структуре DateTime

Возможно, немногие задумывались о том, что из себя представляет структура DateTime. Значение структуры DateTime измеряется в 100-наносекундных единицах, называемых тактами, и точная дата представляется числом тактов прошедших с 00:00 1 января 0001 года нашей эры.

Например, число 628539264000000000 представляет собой 6 октября 1992 года 00:00:00.

Структура DateTime содержит единственное поле, которое и содержит количество прошедших тактов:

private UInt64 dateData;

Следует так же сказать, что начиная с .NET 2.0, 2 старших бита данного поля указывают тип DateTime: Unspecfied — не задан, Utc — координированное время, Local — местное время, а остальные 62 бита — количество тактов. Мы можем легко запросить эти два бита с помощью свойства Kind.

Что плохого в использовании DateTime?

Использовать свойство DateTime.Now для измерения временных интервалов не очень хорошая идея, и вот почему:

DateTime.Now

public static DateTime Now
{
   get
       {
          DateTime utc = DateTime.UtcNow;
          Boolean isAmbiguousLocalDst = false;
          Int64 offset = TimeZoneInfo.GetDateTimeNowUtcOffsetFromUtc(utc, out isAmbiguousLocalDst).Ticks;
          long tick = utc.Ticks + offset;
          if (tick > DateTime.MaxTicks)
           {
             return new DateTime(DateTime.MaxTicks, DateTimeKind.Local);
           }
          if (tick < DateTime.MinTicks)
           {
             return new DateTime(DateTime.MinTicks, DateTimeKind.Local);
           }
             return new DateTime(tick, DateTimeKind.Local, isAmbiguousLocalDst);
       }
}

Вычисление свойства DateTime.Now основывается на DateTime.UtcNow, то есть сначала вычисляется координированное время, а потом к нему применяется смещение часового пояса.

Именно поэтому использовать свойство DateTime.UtcNow будет правильнее, оно вычисляется намного быстрее:

DateTime.UtcNow

public static DateTime UtcNow
{
  get
    {
       long ticks = 0;
       ticks = GetSystemTimeAsFileTime();
       return new DateTime(((UInt64)(ticks + FileTimeOffset)) | KindUtc);
    }
}

Проблема использования DateTime.Now или DateTime.UtcNow заключается в том, что их точность фиксирована. Как было сказано выше

1 tick = 100 nanoseconds = 0.1 microseconds = 0.0001 milliseconds = 0.0000001 seconds

соответственно измерить временной интервал длина которого меньше чем длинна одного такта, просто невозможно. Конечно, маловероятно, что вам это потребуется, но знать это надо.

Использование класса Stopwatch

Класс Stopwatch появился в .NET 2.0 и с тех по не претерпел ни одного изменения. Он предоставляет набор методов и средств, которые можно использовать для точного измерения затраченного времени.

Публичный API класса Stopwatch выглядит следующий образом:

Свойства

  1. Elapsed — возвращает общее затраченное время;
  2. ElapsedMilliseconds — возвращает общее затраченное время в миллисекундах;
  3. ElapsedTicks — возвращает общее затраченное время в тактах таймера;
  4. IsRunning — возвращает значение, показывающее, запущен ли таймер Stopwatch.

Методы

  1. Reset — останавливает измерение интервала времени и обнуляет затраченное время;
  2. Restart — останавливает измерение интервала времени, обнуляет затраченное время и начинает измерение затраченного времени;
  3. Start — запускает или продолжает измерение затраченного времени для интервала;
  4. StartNew — инициализирует новый экземпляр Stopwatch, задает свойство затраченного времени равным нулю и запускает измерение затраченного времени;
  5. Stop — останавливает измерение затраченного времени для интервала.

Поля

  1. Frequency — возвращает частоту таймера, как число тактов в секунду;
  2. IsHighResolution — указывает, зависит ли таймер от счетчика производительности высокого разрешения.

Код, использующий класс Stopwatch для измерения времени выполнения метода SomeOperation может выглядеть так:

var sw = new Stopwatch();
sw.Start();
SomeOperation();
sw.Stop();

Первые две строчки можно записать более лаконично:

var sw = Stopwatch.StartNew();
SomeOperation();
sw.Stop();

Реализация Stopwatch

Класс Stopwatch основан на HPET (High Precision Event Timer, таймер событий высокой точности). Данный таймер был введён фирмой Microsoft, чтобы раз и навсегда поставить точку в проблемах измерения времени. Частота этого таймера (минимум 10 МГц) не меняется во время работы системы. Для каждой системы Windows сама определяет, с помощью каких устройств реализовать этот таймер.

Класс Stopwatch содержит следующие поля:

private const long TicksPerMillisecond = 10000;
private const long TicksPerSecond = TicksPerMillisecond * 1000;
        
private bool isRunning;
private long startTimeStamp;
private long elapsed;

private static readonly double tickFrequency; 

TicksPerMillisecond — определяет количество DateTime тактов в 1 миллисекунду;
TicksPerSecond — определяет количество DateTime тактов в 1 секунду;

isRunning — определяет, запущен ли текущий экземпляр (вызван ли был метод Start);
startTimeStamp — число тактов на момент запуска;
elapsed — общее число затраченных тактов;

tickFrequency — упрощает перевод тактов Stopwatch в такты DateTime.

Статический конструктор проверяет наличие таймера HPET и в случае его отсутствия частота Stopwatch устанавливается равной частоте DateTime.

Статический конструктор Stopwatch

static Stopwatch() 
{                       
  bool succeeded = SafeNativeMethods.QueryPerformanceFrequency(out Frequency);            
    if(!succeeded) 
     {
        IsHighResolution = false; 
        Frequency = TicksPerSecond;
        tickFrequency = 1;
      }
     else 
     {
        IsHighResolution = true;
        tickFrequency = TicksPerSecond;
        tickFrequency /= Frequency;
     }   
}

Основной сценарий работы данного класса был показан выше: вызов метода Start, метод время которого необходимо измерить, а затем вызов метода Stop.

Реализация метода Start очень проста — он запоминает начальное число тактов:

Start

public void Start()
{
   if (!isRunning)
     {
       startTimeStamp = GetTimestamp();
       isRunning = true;
     }
}

Следует сказать, что вызов метода Start на уже замеряющем экземпляре ни к чему не приводит.

Аналогично просто устроен метод Stop:

Stop

public void Stop()
{
   if (isRunning)
     {
       long endTimeStamp = GetTimestamp();
       long elapsedThisPeriod = endTimeStamp - startTimeStamp;
       elapsed += elapsedThisPeriod;
       isRunning = false;

       if (elapsed < 0)
        {
          // When measuring small time periods the StopWatch.Elapsed* 
          // properties can return negative values.  This is due to 
          // bugs in the basic input/output system (BIOS) or the hardware
          // abstraction layer (HAL) on machines with variable-speed CPUs
          // (e.g. Intel SpeedStep).

          elapsed = 0;
         }
      }
}

Вызов метода Stop на остановленном экземпляре так же ни к чему не приводит.

Оба метода используют вызов GetTimestamp() — возвращающего количество тактов на момент вызова:

GetTimestamp

public static long GetTimestamp()
{
   if (IsHighResolution)
     {
       long timestamp = 0;
       SafeNativeMethods.QueryPerformanceCounter(out timestamp);
       return timestamp;
     }
     else
      {
         return DateTime.UtcNow.Ticks;
      }
 }

При наличии HPET(таймер событий высокой точности) такты Stopwatch отличаются от тактов DateTime.

Следующий код

Console.WriteLine(Stopwatch.GetTimestamp());
Console.WriteLine(DateTime.UtcNow.Ticks);

на моем компьютере выводит

5201678165559
635382513439102209

Использовать такты Stopwatch для создания DateTime или TimeSpan неверно. Запись

var time = new TimeSpan(sw.ElaspedTicks);

по понятным причинам приведет к неправильным результатам.

Чтобы получить такты DateTime, а не Stopwatch нужно воспользоваться свойствами Elapsed и ElapsedMilliseconds или же сделать преобразование вручную. Для преобразования тактов Stopwatch в такты DateTime в классе используется следующий метод:

GetElapsedDateTimeTicks

private long GetElapsedDateTimeTicks()
 {
   long rawTicks = GetRawElapsedTicks();// get Stopwatch ticks
     if (IsHighResolution)
      {
        // convert high resolution perf counter to DateTime ticks
        double dticks = rawTicks;
        dticks *= tickFrequency;
        return unchecked((long)dticks);
      }
      else
      {
        return rawTicks;
      }
}

Код свойств выглядит, как и ожидалось:

Elapsed, ElapsedMilliseconds

public TimeSpan Elapsed
{
   get { return new TimeSpan(GetElapsedDateTimeTicks()); }
}

public long ElapsedMilliseconds
{
  get { return GetElapsedDateTimeTicks() / TicksPerMillisecond; }
}

Что плохого в использовании Stopwatch?

Примечание к данному классу с MSDN говорит: на многопроцессорном компьютере не имеет значения, на каком из процессоров выполняется поток. Однако, из-за ошибок в BIOS или слое абстрагированного оборудования (HAL), можно получить различные результаты расчета времени на различных процессорах.

Во избежание этого в методе Stop стоит условие if (elapsed < 0).

Я нашел немало статей, авторы которых столкнулись с проблемами из-за некорректной работы HPET.

В случае отсутствия HPET Stopwatch использует такты DateTime, поэтому его преимущество перед явным использованием DateTime теряется. К тому же нужно учитывать время на вызовы методов и проверки осуществляемые Stopwatch, особенно если это происходит в цикле.

Stopwatch in mono

Мне стало интересно, как реализован класс Stopwatch в mono, поскольку рассчитывать на нативные функции Windows по работе с HPET не приходится.

public static readonly long Frequency = 10000000;
public static readonly bool IsHighResolution = true;

Stopwatch в mono использует всегда такты DateTime, а потому преимуществ перед явным использованием DateTime у него нет, разве, что код более читабелен.

Environment.TickCount

Следует так же сказать о свойстве Environment.TickCount, которое возвращает время, истекшее с момента загрузки системы (в миллисекундах).

Значение этого свойства извлекается из таймера системы и хранится как целое 32-разрядное число со знаком. Следовательно, если система работает непрерывно, значение свойства TickCount на протяжении приблизительно 24,9 дней будет возрастать, начиная с нуля и заканчивая значением Int32.MaxValue, после чего оно будет сброшено до значения Int32.MinValue, являющегося отрицательным числом, и снова начнет расти до нуля в течение следующих 24,9 дней.

Использование данного свойства соответствует вызову системной функции GetTickCount(), которая является очень быстрой, так как просто возвращает значение соответствующего счётчика. Однако точность её низка (10 миллисекунд), поскольку для увеличения счётчика используются прерывания, генерируемые часами реального времени компьютера.

Заключение

Операционная система Windows содержит немало таймеров (функций позволяющих измерять интервалы времени). Одни из них точные, но не быстрые (timeGetTime), другие быстрые, но не точные (GetTickCount, GetSystemTime), а третьи как утверждает Microsoft и быстрые и точные. К числу последних относится таймер HPET и функции, позволяющие с ним работать: QueryPerformanceFrequency, QueryPerformanceCounter.

Класс Stopwatch фактически является управляемой обёрткой над HPET. У использования данного класса есть как преимущества (более точное измерение временных интервалов), так и недостатки (ошибки в BIOS, HAL могут приводить к неправильным результатам), а в случае отсутствия HPET его преимущества и вовсе теряются.

Использовать или не использовать класс Stopwatch решать Вам. Однако как мне кажется преимуществ у данного класса, все же больше чем недостатков.

Автор: timyrik20

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js