Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 입자의 폴링을 피한다
지난 Windows 소프트 리얼타임 실천 가이드에서는 Sleep에 맡기는 주기 루프를 피하는 이야기를 썼습니다.
이번에는 그중에서도 왜 짧은 timer wait보다 event wait를 우선하고 싶은가를 한 점에 집중해 정리합니다.
Windows에서는 Sleep(1)이나 짧은 timeout 붙은 wait를 써서 「일정 시간마다 상태를 본다」라는 설계를 하면, 아무래도 system clock의 입자와 그 후의 스케줄링 지연의 영향을 받습니다.
일반적인 설정에서는 15.6ms급의 platform timer resolution이 전제가 되는 경우가 많으므로, 「1ms 후에 다시 보자」는 의도여도 실제로는 꽤 거친 대기가 되기 쉽습니다.
한편으로 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼 정말 기다리고 싶은 것이 「시간」이 아니라 「사건」이라면, 일정 간격으로 보러 갈 필요는 없습니다.
이벤트가 일어난 쪽이 signal하고, 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 솔직합니다.
이 글에서는 다음 물음에 답하는 형태로 정리합니다.
Sleep(1)이나 짧은 timer wait는 왜 생각보다 정확하지 않은가- 왜 event wait는 그 제한을 받기 어려운가
- 어떤 장면에서 timer가 아니라 event를 선택해야 하는가
- 그래도 timer를 써야 하는 장면은 무엇인가
1. 먼저 결론
- 작업 도착이나 I/O 완료를 기다린다면 timer가 아니라 event를 기다리는 편이 좋습니다.
- Windows의 timed wait는 아무래도 system clock의 입자의 영향을 받습니다.
Sleep(1)은 「1ms 후에 정확히 일어난다」는 의미가 아닙니다.- 더구나 timeout이 지났다고 해서 thread는 먼저 ready가 될 뿐 즉시 실행은 보장되지 않습니다.
- 그래서 「사실은 사건을 기다리고 있는데 timer로 상태를 보러 간다」는 설계는 지연에도 전력에도 불리합니다.
- timer를 쓰는 것은 정말로 시간 그 자체가 조건일 때만으로 좁히는 편이 깨끗합니다.
실무에서의 말투로 하면 거의 이것입니다.
- 「5초마다 metrics를 보낸다」 -> timer의 일
- 「큐에 작업이 들어오면 바로 움직인다」 -> event / semaphore / condition variable /
WaitOnAddress의 일 - 「I/O가 끝나면 계속을 실행한다」 -> completion / event의 일
- 「정지 요청이 오면 멈춘다」 -> stop event / cancellation의 일
2. 무엇이 문제인가
2.1 timed wait는 system clock의 입자에 묶인다
Windows의 wait functions의 timeout 정밀도는 system clock resolution에 의존합니다.
Sleep도 같고, 지정한 밀리초가 그대로 「그대로의 길이」로 보장되는 것은 아닙니다.
여기서 중요한 것은 1ms를 지정했다고 해서 1ms 후에 일어난다고는 할 수 없다는 점입니다.
2.2 기한이 와도 바로 실행된다고는 할 수 없다
더 까다로운 것은 timeout이 지난 순간에 thread가 즉시 실행되는 것이 아니라는 점입니다.
Sleep의 설명에도 있는 대로, 대기 시간이 끝난 뒤 thread는 ready는 되지만 지금 당장 CPU를 받고 달릴 보증은 없습니다.
다른 thread, priority, CPU의 idle state, DPC / ISR, lock 경합 등의 영향을 받습니다.
즉, 짧은 timer wait에는 적어도 2단계의 불확실성이 있습니다.
- 애초에 timeout의 판정 자체가 timer 입자에 끌려간다
- timeout 후에도 실행 개시는 scheduler에 달려 있다
2.3 Sleep(1)은 1ms 주기의 의미가 되지 않는다
Sleep(1)을 보면 무심코 「1ms마다 도는 loop」처럼 보입니다.
하지만 실제로는 그렇게 읽어서는 안 됩니다.
while (!g_stop)
{
Step();
Sleep(1);
}
이 loop는 실제로는 다음 같은 것입니다.
Step()의 실행 시간이 매번 더해진다Sleep(1)의 대기 시간 자체가 입자에 끌려간다- 눈을 떠도 바로 달릴 수 있다고는 할 수 없다
3. 왜 이벤트 대기가 유리한가
3.1 대기의 종료 조건이 「시간 초과」가 아니라 「signal」이 된다
event wait가 유리한 것은 대기의 의미가 바뀌기 때문입니다.
timer wait는 이렇습니다.
- 아직 아무것도 일어나지 않았어도
- 일정 시간이 되면 일어난다
- 일어나고 나서 「뭔가 일어났는가」를 확인한다
event wait는 이렇습니다.
- 무언가가 일어난 쪽이 signal한다
- signal되면 대기가 충족된다
- 일어난 시점에 이미 이유가 있다
flowchart LR
start["기다리는 thread"] --> q{"정말로 기다리는 것은?"}
q -- "시간이 오는 것" --> timer["timer / waitable timer"]
q -- "작업의 도착" --> event["event / semaphore / condition variable"]
q -- "값의 변화" --> addr["WaitOnAddress"]
q -- "I/O 완료" --> io["completion / event"]
q -- "정지 요청" --> stop["stop event / cancellation"]
3.2 무엇을 기다리고 싶은가로 도구를 나눈다
첫 판단은 대체로 다음 표로 충분합니다.
| 기다리고 싶은 것 | 좋지 않은 예 | 우선의 선택 |
|---|---|---|
| 큐에 작업이 들어오는 것 | Sleep(1)으로 TryPop한다 |
event / semaphore |
| I/O가 완료되는 것 | timer로 상태를 보러 간다 | overlapped I/O의 event / IOCP |
| 정지 요청이 오는 것 | 100ms마다 stop flag를 본다 | stop event / cancellation |
| 같은 프로세스 내의 값 변화 | while (flag == 0) Sleep(1) |
WaitOnAddress |
| 시각이 오는 것 | event로 억지로 치우친다 | timer / waitable timer |
3.3 event도 마법은 아니다
event wait는 timer 입자로 일어날 필요가 없다는 의미에서 유리하지만, signal된 순간에 절대 제로 지연으로 달리는 것은 아닙니다.
event wait에서도 실제로는 다음 영향은 받습니다.
- scheduler latency
- thread priority
- CPU의 power state
- lock 경합
- page fault
- DPC / ISR
다만 적어도 「다음 timer tick까지 자고 있다」는 쓸데없는 대기 방식은 제거할 수 있습니다.
4. 전형적인 안티패턴
4.1 Sleep(1)로 큐를 폴링한다
가장 자주 보는 것이 이것입니다.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
이 작성법은 얼핏 단순하지만 문제가 3가지 있습니다.
- queue가 비어 있어도 정기적으로 일어난다
- latency가 timer 입자에 끌려간다
- power 측면에서도 손해
4.2 Thread.Sleep(1) / Task.Delay(1)로 상태를 감시한다
C# / .NET에서도 같은 냄새가 납니다.
while (!stoppingToken.IsCancellationRequested)
{
if (_queue.TryDequeue(out WorkItem? item))
{
await ProcessAsync(item, stoppingToken);
continue;
}
await Task.Delay(1, stoppingToken);
}
겉보기는 async로 온순해도 설계의 본질은 polling입니다.
5. 이렇게 고친다
5.1 producer가 도착 시에 signal한다
queue 도착 대기라면 polling이 아니라 producer가 signal하는 형태로 바꿉니다.
- producer가 queue에 item을 넣는다
- item을 넣은 직후에
SetEvent한다 - consumer는
WaitForSingleObject또는WaitForMultipleObjects로 기다린다 - 일어나면 queue를 drain한다
5.2 WaitForMultipleObjects로 work와 stop을 동시에 기다린다
단순한 worker라면 다음 형태가 이해하기 쉽습니다.
HANDLE waits[2] = { _stopEvent, _workEvent };
for (;;)
{
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (rc == WAIT_OBJECT_0)
{
return;
}
if (rc != WAIT_OBJECT_0 + 1)
{
throw std::runtime_error("WaitForMultipleObjects failed.");
}
DrainQueue();
}
이 예의 포인트는 3가지입니다.
Sleep(1)이 사라졌다- item 도착 시에 producer가
SetEvent하고 있다 - worker는
stop과work를 동시에 기다리고 있다
5.3 같은 프로세스라면 WaitOnAddress도 후보
같은 프로세스 안에서 단순히 「어떤 값이 바뀔 때까지 기다리고 싶을」 뿐이라면 WaitOnAddress도 꽤 유력합니다.
구분 사용의 감각으로는 대체로 이렇습니다.
- 프로세스 간이나 일반적인 대기 대상 -> event / semaphore / waitable object
- 같은 프로세스의 가벼운 값 변화 ->
WaitOnAddress
6. 그래도 timer를 쓰는 장면
6.1 시간 그 자체가 조건일 때
물론 timer를 쓰는 장면은 제대로 있습니다.
- 5초마다 metrics를 보낸다
- 200ms 후에 retry한다
- 1분마다 캐시를 청소한다
- 기한 시각까지 기다려 timeout으로 한다
여기에서 기다리고 싶은 것은 정말로 시간입니다.
6.2 waitable timer를 쓴다
Windows에서 「시간 그 자체」를 기다린다면 Sleep을 대충 쌓는 것보다 waitable timer를 쓰는 편이 의미가 분명합니다.
6.3 timeBeginPeriod를 상용하지 않는다
짧은 timer wait의 정밀도가 신경 쓰이면 무심코 timeBeginPeriod(1)을 더하고 싶어집니다.
하지만 이것은 상용의 첫 선택으로 삼지 않는 편이 좋습니다.
이유는 3가지 있습니다.
- power / performance의 비용이 있다
- 최근의 Windows에서는 동작이 조금 복잡
- 근본 원인을 고치지 않은 경우가 많다
7. 리뷰 시의 체크리스트
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)로 상태 보기 loop를 만들지 않았는가- 사실은 queue 도착, I/O 완료, 정지 요청을 기다리고 있는데 timer poll하고 있지 않은가
- producer / completion 측에서 signal할 수 있는 설계로 되어 있는가
stop과work를 한 번의 wait로 한꺼번에 기다릴 수 없는가- 같은 프로세스의 값 변화라면
WaitOnAddress로 쓸 수 없는가 - timer를 쓰고 있는 곳에서 정말로 기다리고 싶은 것이 「시간」인가
8. 정리
Windows에서 짧은 timer wait를 써서 「일정 시간마다 상태를 본다」라는 설계는 아무래도 timer 입자와 scheduler의 영향을 받습니다.
그래서 Sleep(1)이나 짧은 timeout은 겉보기만큼 정확한 대기가 아닙니다.
한편으로 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼 정말로 기다리고 싶은 것이 「사건」이라면, event wait 쪽이 자연스럽습니다.
정리하면 다음 한 줄입니다.
시간을 기다리면 timer, 사건을 기다리면 event.
이 선긋기가 분명해지는 것만으로,
- latency가 읽기 쉬워진다
- 쓸데없는 periodic wakeup이 줄어든다
- power 측면에서도 나아진다
- 코드의 의도가 알기 쉬워진다
라는 형태로 효과가 나옵니다.
9. 참고 자료
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
상정하지 않은 예외가 발생했을 때의 체크리스트 - 앱을 종료시킬지 계속할지, 먼저 보는 판단표
상정 외 예외 시 앱을 종료할지 계속할지를 실패 단위 격리·공유 상태 회복·외부 부작용 설명·네이티브 경계 건전성의 네 축으로 판단하는 흐름을 표와 플로차트로 정리한 글입니다. 독자는 catch 가능 여부가 아니라 불변 조건 회복 가능성으로 가르...
Windows 앱 개발에서 최저한의 보안을 지키기 위한 체크리스트
WPF・WinForms・WinUI・Win32 등 Windows 앱 개발에서 권한, 서명, 비밀 정보, HTTPS, 입력 검증, DLL 읽기, 로그까지 릴리스 전에 빠뜨리면 사고로 이어지는 최저한의 보안 항목을 체크리스트 형태로 정리합니다.
PeriodicTimer / System.Threading.Timer / DispatcherTimer의 사용 구분 - .NET의 정기 실행을 먼저 정리
PeriodicTimer는 async 루프, System.Threading.Timer는 ThreadPool callback, DispatcherTimer는 WPF UI 스레드라는 책무 차이를 정리하고, .NET 6 이후의 worker・WPF에서 ...
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
Windows .NET 파일 감시에서 FileSystemWatcher의 이벤트를 완료 알림으로 오인하기 쉬운 함정과, 재스캔 요청·원자적 claim·idempotency를 축으로 누락과 중복을 견디는 안전한 설계 패턴을 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.