윈도우에서 시간 측정하기

이 글은 월간 마이크로소프트웨어(일명 마소) 2010년 4월 21일자 뉴스캐스트에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.

게임 개발자에게 있어 시간 측정은 꽤나 중요한 문제이다. 온라인 게임이라면 특히 그러하다. 게임을 즐기는 사용자들이 각기 다른 시간대에 살고 있을 지 모르고(워싱턴의 버락 오바마와 서울 모 PC방의 초등학생 사용자가 메이플 스토리를 한다고 생각해보자), 기가급 초 광랜을 쓰는 모 EU 연구소의 직원과 삐삑거리며 추억의 음향을 내는 모뎀을 쓰는 어느 산골짜기의 누군가가 게임을 함께 즐길지도 모른다. 이러한 열악한 경우라도 최대한 자연스럽게 게임을 즐기려면 각 사용자 간의 시간을 잘 조정할 필요가 있다.

게임 개발자가 아니라도 정밀한 시간 측정이 필요할 때는 있다. 역시 응용프로그램의 성능을 측정할 때가 대표적인 경우라 하겠다. 사람이 손수 시계의 초침을 바라보는 방법도 가능하지만 응용프로그램이 결과를 1초 만에 내뱉을지 3일이 걸릴지 모를 땐 간단하게나마 타이머 기능을 구현해야 할지 모른다.

그러나 의외로 정밀한 시간 측정을 주제로 잘 정리해 놓은 글은 찾기 힘들다. 잘못된 내용을 기술한 문서가 있는가 하면 최신 정보를 업데이트하지 않고 놔둔 문서도 있다. 그런 까닭에 시간을 들여 시간 측정과 관련된 정보를 취합해보았다.

기본적인 시간 함수

_time32, _time64, _ftime_s, _ftime32_s, ftime64_s 등

아주 기본적인 시간 함수들로써 현재 시각을 구할 때 사용한다. 여기서 먼저 시간과 시각의 차이를 분명히 인지할 필요가 있다. 기억이 정확하다면 초등학교 교육과정에 이 내용이 포함되었지만 확인 차 점검해보자면, 시각은 어느 순간을 나타내며 시간은 소위 말해 Interval이다. 이 둘을 구분하지 않고 시간을 나타내는 정수 값과 시각을 나타내는 정수 값을 혼재해서 연산하는 모습이 이 코드 저 코드에서 자주 보이는데 이는 가독성을 떨어뜨릴뿐더러 버그가 나기 쉽다. 가능하면 시간과 시각은 잘 정리된 라이브러리를 이용해 표현하는 게 좋다(예를 들어, boost 라이브러리의 datetime 같이).

제목에 길게 나열했듯이 비슷한 함수가 여럿 있는데 구분하기는 매우 쉽다. _s가 들어간 건 소위 말하는 안전한 함수로써 최대한 _s가 없는 구 버전보단 이 새 버전을 쓰는 편이 좋다. 32와 64는 반환 값의 크기를 바이트 수로 나타낸다. 물론 클수록 표현 가능한 범위가 넓다.

이 함수들은 시간이 아닌 시각과 관련된 함수로써 우리가 앞으로 알아볼 시간 함수와는 차이가 있다. 물론 시간 측정용으로 쓸 수야 있지만 해상도, 그러니까 시간 측정의 세밀함에 있어선 그리 만족스럽지 않다. 사실 정확히 말하자면 MSDN 라이브러리 등의 문서에선 이 함수의 정밀도는 아예 언급조차 안 한다. 그러므로 얼마나 정밀한지 모른다고 해야 옳다. 이 함수들이 시각을 밀리초까지 표현하기는 하지만 1밀리초까지 정확히 표현하는지 아니면 2나 3 밀리초까지 끊어서 표현하는지는 모른다. 혹자는 실험해보면 알지 않느냐고 할지 모르지만 문서화되지 않은 내용은 항상 조심해야 한다. 당장은 실험 결과가 옳을지 모르지만 추후 서비스 팩이 나오거나 새 버전의 운영체제가 나오면 어찌될지 모르기 때문이다.

GetTickCount, GetTickCount64

시스템이 시작된 후, 그러니까 커널이 부팅된 후로 몇 밀리 초가 지났는지를 알려준다.

GetTickCount는 부호 없는 32비트 정수 값을 반환하므로 뒤에서 언급할 timeGetTime와 마찬가지로 49.71일이면 오버플로우가 일어난다. 그러므로 부호 없는 64비트 정수 값을 반환하는 GetTickCount64를 쓰는 게 좋다. 그러나 GetTickCount64는 Windows Vista 또는 Windows Server 2008 이후 버전에서만 사용 가능하다.

해상도에 있어선 앞으로 다룰 멀티미디어 타이머나 고해상도 타이머에 뒤진다. GetTickCount 등은 시스템 타이머의 해상도에 전적으로 의존하는데 이 주제는 하드웨어와 연관이 깊은 데다 굳이 알아서 도움될 것은 없으므로 이 정도로 설명을 마치고 본 주제에만 집중하겠다.

멀티미디어 타이머

SetTimer

SetTimer는 일정 시간이 지난 후 지정한 콜백 함수를 호출한다. 메시지 루프를 사용해 WM_TIMER 메시지를 전달한다. 따라서 뒤이어 다룰 timeGetTime에 비해 정확도가 떨어지며 콘솔 응용프로그램이나 Win32 서비스 응용프로그램에는 쓰지 못한다.

timeGetTime

윈도우 시작 후에 흐른 시간을 밀리초 단위로 반환한다. timeGetSystemTime과는 반환 형만 차이가 있을 뿐이며 timeGetTime이 오버헤드가 적다.

정밀도
  • 기본: 윈도우 NT/2000에선 5 밀리초 이상이며 머신에 따라 기본 값이 다르다. 윈도우 98에선 기본 정밀도 값이 1밀리초이다.

  • 최대: 1밀리초이다.

정밀도를 조정할 때는 timeBeginPeriod, timeEndPeriod 함수를 이용한다. 정밀도가 이보다 좋아야 한다면 고해상도 타이머(QueryPerformanceCounter, QueryPerformanceFrequency)를 이용한다.

단점

32비트 정수 변수를 이용해 밀리초를 나타내기 때문에 49.71일이면 INT_MAX 범위를 넘어간다. 장시간 운영해야 하는 서버 응용프로그램에 쓰기엔 부적합하다.

고해상도 타이머

RDTSC

RDTSC는 x86 P5의 명령 집합으로 도입된 이래로 정밀하게 시간을 측정할 때 널리 쓰였다. CPU 내부에 Time Stamp Counter(이하 TSC)란 64비트 변수 값을 두고 클락 사이클마다 값을 1씩 증가시킨다.

과거에는 RDTSC 값에 접근하려고 인라인 어셈블리를 쓰곤 했다. 그러나 이제는 Visual C++ 컴파일러가 __rdtsc란 기본 함수(intrinsic)가 있으므로 더 이상 인라인 어셈블리에 의존하지 않아도 된다. x64 컴파일러는 인라인 어셈블리를 허용하지 않으므로 의존하지 않아도 된다기 보단 말아야 한다고 해야 옳을지 모르겠다.

// processor: x86, x64
#include <stdio.h>
#include <intrin.h>

#pragma intrinsic(__rdtsc)

int main()
{
	unsigned __int64 i;
	i = __rdtsc();
	printf_s("%I64d ticks\n", i);
}
단점
  1. RDTSC는 스레드가 한 프로세스에서만 돈다고 가정한다. 멀티프로세서, 멀티코어가 보급된 오늘날 이러한 가정은 더 이상 유효하지 않다. 여기에 더해 절전 기능의 보급과 발전은 이 문제를 더 골치 아프게 만든다. 절전 기술은 보통 코어를 각기 다른 시점에 재우고 복구시키기 때문에 코어 간의 동기화가 깨지기 때문이다.

  2. 몇 년 전까진 RDTSC가 가장 훌륭한 시간 측정 기술이었다. 하지만 요즘은 메인보드에 더 나은 고해상도 타이머가 탑재되어 나오곤 한다.

  3. RDTSC는 클락 사이클 횟수를 반환한다. 그러므로 이 값을 시간으로 환산하려면 한 클락 사이클에 몇 밀리 초 또는 몇 나노 초가 걸리는지 알아야 한다. 또한 이 시간이 항상 일정하다는 가정이 필요하다. 그러나 절전 기술 때문에 한 클락 사이클에 걸리는 시간도 가변적이게 됐다.

QueryPerformanceCounter / QueryPerformanceFrequency

RDTSC에 주목할만한 문제가 있기 때문에 윈도우는 이를 대체할 API를 제공한다.

  1. QueryPerformanceCounter 등을 RDTSC 대신 써야 한다. QueryPerformanceCounter 내부적으로 RDTSC를 쓴다고 아는 사람도 있지만 정확히 말하자면 RDTSC를 쓸 수도 있고 그렇지 않을 수도 있다가 맞다. 앞서 언급했듯 메인보드에 더 나은 시간 측정 기술이 적용된 경우에는 이를 활용할 가능성이 높다. 새삼스런 이야기지만 운영체제 API가 어떻게 구현되었는지 섣부르게 가정하고 소프트웨어를 개발했다간 낭패보기 쉽다. 특히 MSDN 라이브러리 같은 문서에 나와 있지 않은 내용은 함부로 믿지 않기를 바란다. 이 글을 쓰느라 웹 상에서 여러 자료를 찾아 읽었는데 매우 잘못된 기술이 많았다.

  2. QueryPerformanceCounter 등은 운영체제 호출(시스템 콜)이기 때문에 RDTSC보단 분명 느리다. 일반적으로 그 차이가 미미하기 때문에 문제되지 않겠지만 그래도 API 호출을 가급적 적게 하는 편이 좋다.

  3. 시간 계산은 한 스레드에서만 한다. 여러 스레드에서 시간을 계산하면 멀티 코어 시스템의 성능이 크게 저하된다.

  4. 원칙적으론 QueryPerformanceCounter가 어느 때라도 올바로 작동해야 한다. 그러나 일부 하드웨어 제조사의 실수(BIOS나 드라이버 문제 등으로)로 그렇지 않을 때가 있다. 스레드 하나가 이 프로세서에서 실행되다 저 프로세서로 옮겨 실행될 때 값을 잘못 계산하는 문제가 일부 보고됐다. 하드웨어를 구미에 맞게 골라 잡을 수 있는 서버 개발자라면 이런 문제를 신경 쓰지 않겠지만 그렇지 않은 경우엔 주의를 기울여야 한다. 해결책은 크게 두 가지이다.

    1. 최선의 방책은 스레드를 한 프로세서에 고정시키는 것이다. SetThreadAffinityMask 같은 API를 쓰면 된다. 하지만 상당수의 써드파티 라이브러리의 스레드 풀을 보면 프로세서 별로 스레드를 고정하는 상황을 고려하지 않았기 때문에 고민은 끊이지 않는다. 최악의 경우엔 문제가 되는 프로그램의 프로세스 전체를 프로세서 하나에 고정시켜버리는 방법이 있긴 하다. SingleProcAffinity란 API가 그런 일을 해준다. 물론 어디까지나 최악의 경우라면 말이다.

    2. 시간 계산은 주 스레드에서만 하고 나머지 스레드는 주 스레드가 계산한 값을 읽어서 사용하는 방법도 있다. 작업 스레드에서 시간을 계산하면 병목 지점이 될 가능성이 있으니 일반적인 경우라면 주 스레드를 이용한다. 이는 세 번째(3.) 지적 사항과도 일맥상통한다.

  5. QueryPerformanceFrequency 는 한번만 호출한다. 어차피 변치 않는 값이므로 여러 번 호출해서 아까운 CPU 자원을 낭비할 이유가 없다.

참고 문헌

최 재훈

블로그, 페이스북, 트위터 고성능 서버 엔진, 데이터베이스, 지속적인 통합 등 다양한 주제에 관심이 많다.
Close Menu