[Из песочницы] Windows: Sleep(0.5)

Как, наверняка, многие знают, в 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(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;
}

Вот типичный вывод на Win 8.1
Starting test
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’ы. Делаю этот шаг, вдруг кто захочет повторить тесты на более старом компиляторе
PreciseTimer.h
#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;
}


Изменённая функция test
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;
}

Ну и типичный вывод нашей программы на Windows Server 2008 R2
Starting test
0: Elapsed 10578
1: Elapsed 14519
2: Elapsed 14592
3: Elapsed 14625
4: Elapsed 14354
Finished. average time:13733

Пытаемся решить проблему в лоб


Перепишем немного нашу программу. И попытаемся использовать очевидное:
std: this_thread: sleep_for (std: chrono: microseconds (500))
void test(const std::string& description, const std::function& 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;
}

Типичный вывод на Windows 8.1
Starting test: Sleep (1)
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, как на единственную альтернативу. Что же, напишем ещё один хелперный классец.
WaitableTimer.h
#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(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;
}

Посмотрим, изменилось что.
Типичный вывод на Windows Server 2008 R2
Starting test: Sleep (1)
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.
А теперь поглядим на вывод нашей программы на Windows 8.1
Starting test: Sleep (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. В документации Заявляется, что с помощью этой функции можно устанавливать желаемую точностью таймера. Давайте проверим. Ещё раз модифицируем нашу программу.
программа v3
#include "stdafx.h"

#include "PreciseTimer.h"
#include "WaitableTimer.h"

#pragma comment (lib, "Winmm.lib")

void test(const std::string& description, const std::function& 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;
}


Традиционно, типичные выводы нашей програмы.
На Windows 8.1
Starting test: Sleep (1)
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

И на Windows Server 2008 R2
Starting test: Sleep (1)
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

Давай те разберём интересные факты, которые видны из результатов:
  1. На windows 8.1 ничего не поменялось. Делаем вывод, что timeBeginPeriod достаточно умный, т.е. если N приложений запросили разрешение системного таймера в разные значения, то понижаться это разрешение не будет. На Windows 7 мы бы тоже не заметили никаких изменений, так как там разрешение таймера уже стоит в 1 мс.
  2. На серверной операционке, timeBeginPeriod (1) отработал неожиданным образом: он установил разрешение системного таймера в наибольшее возможное значение. Т.е. на таких операционках где-то явно зашит воркараунт вида:
    void timeBeginPerion(UINT uPeriod)
    {
    	if (uPeriod == 1)
    	{
    		setMaxTimerResolution();
    		return;
    	}
    	...
    }

    Замечу, что на Windows Server 2003 R2 такого ещё не было. Это нововведение в 2008 м сервере.
  3. На серверной операционке, 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.

Напишем функцию AdjustSystemTimerResolutionTo500mcs.
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 

#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")


Типичный вывод с Windows 8.1
Starting test: Sleep (1)
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

Типичный вывод с Windows Server 2008 R2
Starting test: Sleep (1)
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

Осталось сделать наблюдения и выводы.

Наблюдения:

  1. На Win8 после первого запуска программы разрешение системного таймера сбросилось в большое значение. Т.е. вывод 2 был нами сделан неправильно.
  2. После ручной установки разброс реальных слипов для случая WaitableTimer вырос, хоть в среднем слип и держится около 500 микросекунд.
  3. На серверной операционке очень неожиданно перестал работать Sleep (1) (как и this_thread: sleep_for) по сравнению со случаем timeBeginPeriod. Т.е. Sleep (1) стал работать как он должен, в значении »сделай паузу в 1 миллисекунду».

Финальные выводы


  • Вывод 1 остался без изменения: Чтобы сделать Sleep (0.5) необходим, но не достаточен, правильный слип. Всегда используйте Waitable timers для этого.
  • Вывод 2: Разрешение системного таймера на винде зависит от типа виндоус, от версии виндоус, от запущенных в текущий момент процессов, от того, какие процессы могли выполнять до этого. Т.е. что-либо утверждать или гарантировать нельзя! Если нужны какие гарантии, то надо самому всегда запрашивать/выставлять нужную точность. Для значений меньше 1 миллисекунды нужно использовать native api. Для больших значений лучше использовать timeBeginPeriod.
  • Вывод 3: По возможности лучше тестировать код не только на своей рабочей Win 10, но и на той, что указана основной у заказчика. Надо помнить, что серверные операционки могут сильно отличаться от десктопных

Комментарии (0)

© Habrahabr.ru