Как, наверняка, многие знают, в WinAPI'шную функцию Sleep передаётся число миллисекунд, на сколько мы хотим уснуть. Поэтому минимум, что мы можем запросить — это уснуть на 1 миллисекунду. Но что если мы хотим спать ещё меньше? Для интересующихся, как это сделать в картинках, добро пожаловать, под кат.
Сперва напомню, что виндоус (как любая не система реального времени) не гарантирует, что поток (некоторые называют его нить, thread) будет спать именно запрошенное время. Начиная с Висты логика ОС простая. Есть некий квант времени, выделяемый потоку на выполнение (да, да, те самые 20 мс, про которые все слышали во времена 2000/XP и до сих пор слышат про это на серверных осях). И виндоус перепланирует потоки (останавливает одни потоки, запускает другие) только по истечению этого кванта. Т.е. если квант в ОС стоит в 20 мс (по умолчанию в XP было именно такое значение, например), то даже если мы запросили Sleep(1) то в худшем случае управление нам вернётся через те же самые 20 мс. Для управления этим квантом временем есть мультимедийные функции, в частности timeBeginPeriod/timeEndPeriod.
Во вторых, сделаю краткое отступление, зачем может потребоваться такая точность. Майкрософт говорит, что такая точность нужна только мультимедийным приложениям. Например, делаете вы новый WinAMP с блекджетом, и здесь очень важно, чтобы мы новый кусок аудио-данных отправляли в систему вовремя. У меня нужда была в другой области. Был у нас декомпрессор H264 потока. И был он на ffmpeg'е. И обладал он синхронным интерфейсом (Frame* decompressor.Decompress(Frame* compressedFrame)). И всё было хорошо, пока не прикрутили декомпрессию на интеловских чипах в процессорах. В силу уже не помню каких причин работать с ним пришлось не через родное интеловское Media SDK, а через DXVA2 интерфейс. А оно асинхронное. Так что пришлось работать так:
- Копируем данные в видеопамять
- Делаем Sleep, чтобы кадр успел расжаться
- Опрашиваем, завершилась ли декомпрессия, и если да, то забираем расжатый кадр из видеопамяти
Проблема оказалась во втором пункте. Если верить GPUView, то кадры успевали расжиматься за 50-200 микросекунд. Если поставить Sleep(1) то на core i5 можно расжать максимум 1000*4*(ядра) = 4000 кадров в секунду. Если считать обычный fps равным 25, то это выходит всего 40 * 4 = 160 видеопотоков одновременно декомпрессировать. А цель стояла вытянуть 200. Собственно было 2 варианта: либо переделывать всё на асинхронную работу с аппаратным декомпрессором, либо уменьшать время Sleep'а.
Первые замеры
Чтобы грубо оценить текущий квант времени выполнения потока, напишем простую программу:
void test()
{
std::cout << "Starting test" << std::endl;
std::int64_t total = 0;
for (unsigned i = 0; i < 5; ++i)
{
auto t1 = std::chrono::high_resolution_clock::now();
::Sleep(1);
auto t2 = std::chrono::high_resolution_clock::now();
auto elapsedMicrosec = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
total += elapsedMicrosec;
std::cout << i << ": Elapsed " << elapsedMicrosec << std::endl;
}
std::cout << "Finished. average time:" << (total / 5) << std::endl;
}
int main()
{
test();
return 0;
}
0: Elapsed 1977
1: Elapsed 1377
2: Elapsed 1409
3: Elapsed 1396
4: Elapsed 1432
Finished. average time:1518
Сразу, хочу предупредить, что если у вас например MSVS 2012, то std::chrono::high_resolution_clock вы ничего не намеряете. Да и вообще, вспоминаем, что самый верный способ измерить длительность чего либо — это Performance Counter'ы. Перепишем немного наш код, чтобы быть уверенными, что меряем времена мы правильно. Для начала напишем классец-хелпер. Я тесты сейчас делал на MSVS2015, там реализация high_resolution_clock уже правильная, через performance counter'ы. Делаю этот шаг, вдруг кто захочет повторить тесты на более старом компиляторе
#pragma once
class PreciseTimer
{
public:
PreciseTimer();
std::int64_t Microsec() const;
private:
LARGE_INTEGER m_freq; // системная частота таймера.
};
inline PreciseTimer::PreciseTimer()
{
if (!QueryPerformanceFrequency(&m_freq))
m_freq.QuadPart = 0;
}
inline int64_t PreciseTimer::Microsec() const
{
LARGE_INTEGER current;
if (m_freq.QuadPart == 0 || !QueryPerformanceCounter(¤t))
return 0;
// Пересчитываем количество системных тиков в микросекунды.
return current.QuadPart * 1000'000 / m_freq.QuadPart;
}
void test()
{
PreciseTimer timer;
std::cout << "Starting test" << std::endl;
std::int64_t total = 0;
for (unsigned i = 0; i < 5; ++i)
{
auto t1 = timer.Microsec();
::Sleep(1);
auto t2 = timer.Microsec();
auto elapsedMicrosec = t2 - t1;
total += elapsedMicrosec;
std::cout << i << ": Elapsed " << elapsedMicrosec << std::endl;
}
std::cout << "Finished. average time:" << (total / 5) << std::endl;
}
0: Elapsed 10578
1: Elapsed 14519
2: Elapsed 14592
3: Elapsed 14625
4: Elapsed 14354
Finished. average time:13733
Пытаемся решить проблему в лоб
Перепишем немного нашу программу. И попытаемся использовать очевидное:
void test(const std::string& description, const std::function<void(void)>& f)
{
PreciseTimer timer;
std::cout << "Starting test: " << description << std::endl;
std::int64_t total = 0;
for (unsigned i = 0; i < 5; ++i)
{
auto t1 = timer.Microsec();
f();
auto t2 = timer.Microsec();
auto elapsedMicrosec = t2 - t1;
total += elapsedMicrosec;
std::cout << i << ": Elapsed " << elapsedMicrosec << std::endl;
}
std::cout << "Finished. average time:" << (total / 5) << std::endl;
}
int main()
{
test("Sleep(1)", [] { ::Sleep(1); });
test("sleep_for(microseconds(500))", [] { std::this_thread::sleep_for(std::chrono::microseconds(500)); });
return 0;
}
0: Elapsed 1187
1: Elapsed 1315
2: Elapsed 1427
3: Elapsed 1432
4: Elapsed 1449
Finished. average time:1362
Starting test: sleep_for(microseconds(500))
0: Elapsed 1297
1: Elapsed 1434
2: Elapsed 1280
3: Elapsed 1451
4: Elapsed 1459
Finished. average time:1384
Т.е. как мы видим, с ходу никакого выигрыша нету. Посмотрим внимательнее на this_thread::sleep_for. И замечаем, что он вообще реализован через this_thread::sleep_until, т.е. в отличие от Sleep он даже не иммунен к переводу часов, например. Попробуем найти лучшую альтернативу.
Слип, который может
Поиск по MSDN и stackoverflow направляет нас в сторону Waitable Timers, как на единственную альтернативу. Что же, напишем ещё один хелперный классец.
#pragma once
class WaitableTimer
{
public:
WaitableTimer()
{
m_timer = ::CreateWaitableTimer(NULL, FALSE, NULL);
if (!m_timer)
throw std::runtime_error("Failed to create waitable time (CreateWaitableTimer), error:" + std::to_string(::GetLastError()));
}
~WaitableTimer()
{
::CloseHandle(m_timer);
m_timer = NULL;
}
void SetAndWait(unsigned relativeTime100Ns)
{
LARGE_INTEGER dueTime = { 0 };
dueTime.QuadPart = static_cast<LONGLONG>(relativeTime100Ns) * -1;
BOOL res = ::SetWaitableTimer(m_timer, &dueTime, 0, NULL, NULL, FALSE);
if (!res)
throw std::runtime_error("SetAndWait: failed set waitable time (SetWaitableTimer), error:" + std::to_string(::GetLastError()));
DWORD waitRes = ::WaitForSingleObject(m_timer, INFINITE);
if (waitRes == WAIT_FAILED)
throw std::runtime_error("SetAndWait: failed wait for waitable time (WaitForSingleObject)" + std::to_string(::GetLastError()));
}
private:
HANDLE m_timer;
};
И дополним наши тесты новым:
int main()
{
test("Sleep(1)", [] { ::Sleep(1); });
test("sleep_for(microseconds(500))", [] { std::this_thread::sleep_for(std::chrono::microseconds(500)); });
WaitableTimer timer;
test("WaitableTimer", [&timer] { timer.SetAndWait(5000); });
return 0;
}
Посмотрим, изменилось что.
0: Elapsed 10413
1: Elapsed 8467
2: Elapsed 14365
3: Elapsed 14563
4: Elapsed 14389
Finished. average time:12439
Starting test: sleep_for(microseconds(500))
0: Elapsed 11771
1: Elapsed 14247
2: Elapsed 14323
3: Elapsed 14426
4: Elapsed 14757
Finished. average time:13904
Starting test: WaitableTimer
0: Elapsed 12654
1: Elapsed 14700
2: Elapsed 14259
3: Elapsed 14505
4: Elapsed 14493
Finished. average time:14122
Как мы видим, на сервеных операционах с ходу, ничего не поменялось. Так как по умолчанию квант времени выполнения потока на ней обычно огромный. Не буду искать виртуалки с XP и с Windows 7, но скажу, что скорее всего на XP будет полностью аналогичная ситуация, а вот на Windows 7 вроде как квант времени по умолчанию 1мс. Т.е. Новый тест должен дать те же показатели, что давали предыдущие тесты на Windows 8.1.
0: Elapsed 1699
1: Elapsed 1444
2: Elapsed 1493
3: Elapsed 1482
4: Elapsed 1403
Finished. average time:1504
Starting test: sleep_for(microseconds(500))
0: Elapsed 1259
1: Elapsed 1088
2: Elapsed 1497
3: Elapsed 1497
4: Elapsed 1528
Finished. average time:1373
Starting test: WaitableTimer
0: Elapsed 643
1: Elapsed 481
2: Elapsed 424
3: Elapsed 330
4: Elapsed 468
Finished. average time:469
Что мы видим? Правильно, что наш новый слип смог! Т.е. на Windows 8.1 мы свою задачу уже решили. Из-за чего так получилось? Это произошло из-за того, что в windows 8.1 квант времени сделали как раз 500 микросекунд. Да, да, потоки выполняются по 500 микросекунд (на моей системе по умолчанию разрешение установлено в 500,8 микросекунд и меньше не выставляется, в отличие от XP/Win7 где можно было ровно в 500 микросекунд выставить), потом заново перепланируются согласно их приоритетам и запускаются на новое выполнение.
Вывод 1: Чтобы сделать Sleep(0.5) необходим, но не достаточен, правильный слип. Всегда используйте Waitable timers для этого.
Вывод 2: Если вы пишите только под Win 8.1/Win 10 и гарантированно не будете запускаться на других операционках, то на использовании Waitable Timers можно остановиться.
Убираем зависимость от обстоятельств или как поднять точность системного таймера
Я уже упоминал мультимедийную функцию timeBeginPeriod. В документации Заявляется, что с помощью этой функции можно устанавливать желаемую точностью таймера. Давайте проверим. Ещё раз модифицируем нашу программу.
#include "stdafx.h"
#include "PreciseTimer.h"
#include "WaitableTimer.h"
#pragma comment (lib, "Winmm.lib")
void test(const std::string& description, const std::function<void(void)>& f)
{
PreciseTimer timer;
std::cout << "Starting test: " << description << std::endl;
std::int64_t total = 0;
for (unsigned i = 0; i < 5; ++i)
{
auto t1 = timer.Microsec();
f();
auto t2 = timer.Microsec();
auto elapsedMicrosec = t2 - t1;
total += elapsedMicrosec;
std::cout << i << ": Elapsed " << elapsedMicrosec << std::endl;
}
std::cout << "Finished. average time:" << (total / 5) << std::endl;
}
void runTestPack()
{
test("Sleep(1)", [] { ::Sleep(1); });
test("sleep_for(microseconds(500))", [] { std::this_thread::sleep_for(std::chrono::microseconds(500)); });
WaitableTimer timer;
test("WaitableTimer", [&timer] { timer.SetAndWait(5000); });
}
int main()
{
runTestPack();
std::cout << "Timer resolution is set to 1 ms" << std::endl;
// здесь надо бы сперва timeGetDevCaps вызывать и смотреть, что она возвращяет, но так как этот вариант
// мы в итоге выкинем, на написание правильного кода заморачиваться не будем
timeBeginPeriod(1);
::Sleep(1); // чтобы предыдущие таймеры гарантированно отработали
::Sleep(1); // чтобы предыдущие таймеры гарантированно отработали
runTestPack();
timeEndPeriod(1);
return 0;
}
Традиционно, типичные выводы нашей програмы.
0: Elapsed 2006
1: Elapsed 1398
2: Elapsed 1390
3: Elapsed 1424
4: Elapsed 1424
Finished. average time:1528
Starting test: sleep_for(microseconds(500))
0: Elapsed 1348
1: Elapsed 1418
2: Elapsed 1459
3: Elapsed 1475
4: Elapsed 1503
Finished. average time:1440
Starting test: WaitableTimer
0: Elapsed 200
1: Elapsed 469
2: Elapsed 442
3: Elapsed 456
4: Elapsed 462
Finished. average time:405
Timer resolution is set to 1 ms
Starting test: Sleep(1)
0: Elapsed 1705
1: Elapsed 1412
2: Elapsed 1411
3: Elapsed 1441
4: Elapsed 1408
Finished. average time:1475
Starting test: sleep_for(microseconds(500))
0: Elapsed 1916
1: Elapsed 1451
2: Elapsed 1415
3: Elapsed 1429
4: Elapsed 1223
Finished. average time:1486
Starting test: WaitableTimer
0: Elapsed 602
1: Elapsed 445
2: Elapsed 994
3: Elapsed 347
4: Elapsed 345
Finished. average time:546
0: Elapsed 10306
1: Elapsed 13799
2: Elapsed 13867
3: Elapsed 13877
4: Elapsed 13869
Finished. average time:13143
Starting test: sleep_for(microseconds(500))
0: Elapsed 10847
1: Elapsed 13986
2: Elapsed 14000
3: Elapsed 13898
4: Elapsed 13834
Finished. average time:13313
Starting test: WaitableTimer
0: Elapsed 11454
1: Elapsed 13821
2: Elapsed 14014
3: Elapsed 13852
4: Elapsed 13837
Finished. average time:13395
Timer resolution is set to 1 ms
Starting test: Sleep(1)
0: Elapsed 940
1: Elapsed 218
2: Elapsed 276
3: Elapsed 352
4: Elapsed 384
Finished. average time:434
Starting test: sleep_for(microseconds(500))
0: Elapsed 797
1: Elapsed 386
2: Elapsed 371
3: Elapsed 389
4: Elapsed 371
Finished. average time:462
Starting test: WaitableTimer
0: Elapsed 323
1: Elapsed 338
2: Elapsed 309
3: Elapsed 359
4: Elapsed 391
Finished. average time:344
Давай те разберём интересные факты, которые видны из результатов:
- На windows 8.1 ничего не поменялось. Делаем вывод, что timeBeginPeriod достаточно умный, т.е. если N приложений запросили разрешение системного таймера в разные значения, то понижаться это разрешение не будет. На Windows 7 мы бы тоже не заметили никаких изменений, так как там разрешение таймера уже стоит в 1 мс.
- На серверной операционке, timeBeginPeriod(1) отработал неожиданным образом: он установил разрешение системного таймера в наибольшее возможное значение. Т.е. на таких операционках где-то явно зашит воркараунт вида:
void timeBeginPerion(UINT uPeriod) { if (uPeriod == 1) { setMaxTimerResolution(); return; } ... }
Замечу, что на Windows Server 2003 R2 такого ещё не было. Это нововведение в 2008м сервере.
- На серверной операционке, Sleep(1) отработал также неожиданным образом. Т.е. Sleep(1) трактуется на серверных операционках, начиная с 2008го сервера не как "сделай паузу в 1 миллисекунду", а как "сделай минимально возможную паузу". Дальше будет случай, что это утверждение не верно.
Продолжим наши выводы:
Вывод 3: Если вы пишите только под Win Server 2008/2012/2016 и гарантированно не будете запускаться на других операционках, то можно вообще не заморачиваться, timeBeginPeriod(1) и последующие Sleep(1) будут делать всё, что вам нужно.
Вывод 4: timeBeginPeriod для наших целей хорош только под серверные оси. но совместное его использование с Waitable timer'ами, покрывает нашу задачу на Win Server 2008/2012/2016 и на Windows 8.1/Windows 10
Что если мы хотим всё и сразу?
Давай те подумаем, что же нам делать, если нам надо, чтобы Sleep(0.5) работал и под Win XP/Win Vista/Win 7/Win Server 2003.
На помощь нам придёт только native api — то недокументированное api, что нам доступно из user space через ntdll.dll. Там есть интересные функции NtQueryTimerResolution/NtSetTimerResolution.
ULONG AdjustSystemTimerResolutionTo500mcs()
{
static const ULONG resolution = 5000; // 0.5 мс в 100-наносекундных интервалах.
ULONG sysTimerOrigResolution = 10000;
ULONG minRes;
ULONG maxRes;
NTSTATUS ntRes = NtQueryTimerResolution(&maxRes, &minRes, &sysTimerOrigResolution);
if (NT_ERROR(ntRes))
{
std::cerr << "Failed query system timer resolution: " << ntRes;
}
ULONG curRes;
ntRes = NtSetTimerResolution(resolution, TRUE, &curRes);
if (NT_ERROR(ntRes))
{
std::cerr << "Failed set system timer resolution: " << ntRes;
}
else if (curRes != resolution)
{
// здесь по идее надо проверять не равенство curRes и resolution, а их отношение. Т.е. возможны случаи, например,
// что запрашиваем 5000, а выставляется в 5008
std::cerr << "Failed set system timer resolution: req=" << resolution << ", set=" << curRes;
}
return sysTimerOrigResolution;
}
#include <winnt.h>
#ifndef NT_ERROR
#define NT_ERROR(Status) ((((ULONG)(Status)) >> 30) == 3)
#endif
extern "C"
{
NTSYSAPI
NTSTATUS
NTAPI
NtSetTimerResolution(
_In_ ULONG DesiredResolution,
_In_ BOOLEAN SetResolution,
_Out_ PULONG CurrentResolution);
NTSYSAPI
NTSTATUS
NTAPI
NtQueryTimerResolution(
_Out_ PULONG MaximumResolution,
_Out_ PULONG MinimumResolution,
_Out_ PULONG CurrentResolution);
}
#pragma comment (lib, "ntdll.lib")
0: Elapsed 13916
1: Elapsed 14995
2: Elapsed 3041
3: Elapsed 2247
4: Elapsed 15141
Finished. average time:9868
Starting test: sleep_for(microseconds(500))
0: Elapsed 12359
1: Elapsed 14607
2: Elapsed 15019
3: Elapsed 14957
4: Elapsed 14888
Finished. average time:14366
Starting test: WaitableTimer
0: Elapsed 12783
1: Elapsed 14848
2: Elapsed 14647
3: Elapsed 14550
4: Elapsed 14888
Finished. average time:14343
Timer resolution is set to 1 ms
Starting test: Sleep(1)
0: Elapsed 1175
1: Elapsed 1501
2: Elapsed 1473
3: Elapsed 1147
4: Elapsed 1462
Finished. average time:1351
Starting test: sleep_for(microseconds(500))
0: Elapsed 1030
1: Elapsed 1376
2: Elapsed 1452
3: Elapsed 1335
4: Elapsed 1467
Finished. average time:1332
Starting test: WaitableTimer
0: Elapsed 105
1: Elapsed 394
2: Elapsed 429
3: Elapsed 927
4: Elapsed 505
Finished. average time:472
0: Elapsed 7364
1: Elapsed 14056
2: Elapsed 14188
3: Elapsed 13910
4: Elapsed 14178
Finished. average time:12739
Starting test: sleep_for(microseconds(500))
0: Elapsed 11404
1: Elapsed 13745
2: Elapsed 13975
3: Elapsed 14006
4: Elapsed 14037
Finished. average time:13433
Starting test: WaitableTimer
0: Elapsed 11697
1: Elapsed 14174
2: Elapsed 13808
3: Elapsed 14010
4: Elapsed 14054
Finished. average time:13548
Timer resolution is set to 1 ms
Starting test: Sleep(1)
0: Elapsed 10690
1: Elapsed 14308
2: Elapsed 768
3: Elapsed 823
4: Elapsed 803
Finished. average time:5478
Starting test: sleep_for(microseconds(500))
0: Elapsed 983
1: Elapsed 955
2: Elapsed 946
3: Elapsed 937
4: Elapsed 946
Finished. average time:953
Starting test: WaitableTimer
0: Elapsed 259
1: Elapsed 456
2: Elapsed 453
3: Elapsed 456
4: Elapsed 460
Finished. average time:416
Осталось сделать наблюдения и выводы.
Наблюдения:
- На Win8 после первого запуска программы разрешение системного таймера сбросилось в большое значение. Т.е. вывод 2 был нами сделан неправильно.
- После ручной установки разброс реальных слипов для случая WaitableTimer вырос, хоть в среднем слип и держится около 500 микросекунд.
- На серверной операционке очень неожиданно перестал работать Sleep(1) (как и this_thread::sleep_for) по сравнению со случаем timeBeginPeriod. Т.е. Sleep(1) стал работать как он должен, в значении "сделай паузу в 1 миллисекунду".
Финальные выводы
- Вывод 1 остался без изменения: Чтобы сделать Sleep(0.5) необходим, но не достаточен, правильный слип. Всегда используйте Waitable timers для этого.
- Вывод 2: Разрешение системного таймера на винде зависит от типа виндоус, от версии виндоус, от запущенных в текущий момент процессов, от того, какие процессы могли выполнять до этого. Т.е. что-либо утверждать или гарантировать нельзя! Если нужны какие гарантии, то надо самому всегда запрашивать/выставлять нужную точность. Для значений меньше 1 миллисекунды нужно использовать native api. Для больших значений лучше использовать timeBeginPeriod.
- Вывод 3: По возможности лучше тестировать код не только на своей рабочей Win 10, но и на той, что указана основной у заказчика. Надо помнить, что серверные операционки могут сильно отличаться от десктопных
Автор: nikolaynnov