Pull to refresh

Windows: Sleep(0.5)

Reading time 12 min
Views 32K
Как, наверняка, многие знают, в 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;
}

Вот типичный вывод на 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(&current))
		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<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;
}

Типичный вывод на 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<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;
}

Посмотрим, изменилось что.

Типичный вывод на 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<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;
}


Традиционно, типичные выводы нашей програмы.

На 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 <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")


Типичный вывод с 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, но и на той, что указана основной у заказчика. Надо помнить, что серверные операционки могут сильно отличаться от десктопных
Tags:
Hubs:
+41
Comments 65
Comments Comments 65

Articles