Windows에서 Sleep(1)보다 이벤트 대기를 우선해야 하는 이유
· 업데이트: · Go Komura · Windows 개발, 동기화, 이벤트, 타이머, 설계
수정 이력(7건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635165)
- timer wait의 불확실성과 이벤트 구동으로 고치는 흐름을 그림으로도 따라갈 수 있도록, Mermaid 그림을 11개 추가했습니다(본문 500〜750자당 그림 1개의 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하십시오.
- `WaitOnAddress` 예에서 플래그를 그냥 변수로 둔 채 양쪽 thread에서 읽고 쓰고 있었습니다. C++에서는 데이터 경합(미정의 동작)이고, 최적화된 빌드에서는 값이 레지스터에 남은 채 갱신이 보이지 않아, 깨워져도 계속 멈출 수 있습니다. `WakeByAddressSingle`은 기다리는 thread를 깨울 뿐이고, 직전의 쓰기를 원자적으로도 가시적으로도 만들지 않습니다. 쓰는 쪽을 release, 읽는 쪽을 acquire로 맞춘 원자적 변수로 고치고, 함께 넘기는 데이터의 가시성에 대해서도 보태 적었습니다.
- 이 글을 단독으로 읽을 수 있게 한 것과 이전 글 결론의 요약, 용어표를 앞에 추가했습니다. 자기 환경의 타이머 granularity를 확인하는 방법, `WaitForMultipleObjects` 반환값 처리(동시에 signal된 때는 index가 작은 쪽이 반환되므로 정지용을 앞에 둔다), `WaitOnAddress`의 전제와 조기 복귀 대처를 추가했습니다. 3.1의 그림은 판단표와 중복되어, 대기가 끝나는 방식의 차이를 보이는 그림으로 바꿨습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635164)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「Windows에서 Sleep(1)보다 이벤트 대기를 우선해야 하는 이유」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635164 https://comcomponent.com/ko/blog/windows-timer-vs-event-wait/
- DOI(최신 버전)
- 10.5281/zenodo.21635164
- DOI(이 버전)
- 10.5281/zenodo.22217449
지난 Windows 소프트 리얼타임 실천 가이드에서는 Sleep에 맡기는 주기 루프를 피하는 이야기를 썼습니다.
이번에는 그중에서도 왜 짧은 timer wait보다 event wait를 우선하고 싶은가를 한 점에 집중해 정리합니다.
이 글은 단독으로 읽을 수 있습니다. 이전 글의 결론은 「Sleep에 주기를 맡긴 상태 확인 루프는 대기 시간도 깨어나는 타이밍도 보장되지 않으므로, 주기 처리의 기반으로 쓰지 않는다」는 한 줄입니다. 이것만 기억해 두면, 이후는 이전 글을 읽지 않아도 따라올 수 있습니다.
Windows에서는 Sleep(1)이나 짧은 timeout이 붙은 wait를 써서 「일정 시간마다 상태를 본다」는 설계를 하면, 아무래도 system clock의 granularity와 그 후의 스케줄링 지연의 영향을 받습니다.
일반적인 설정에서는 15.6ms급의 platform timer resolution이 전제가 되는 경우가 많으므로, 「1ms 후에 다시 보자」는 의도여도 실제로는 꽤 거친 대기가 되기 쉽습니다.
한편으로 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼 정말 기다리고 싶은 것이 「시간」이 아니라 「사건」이라면, 일정 간격으로 보러 갈 필요는 없습니다. 이벤트가 일어난 쪽이 signal하고, 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 유리합니다.
flowchart TB
accTitle: 이벤트 구동의 대기 방식
accDescr: 정말 기다리고 싶은 것이 시간이 아니라 사건이라면, 일정 간격으로 보러 가지 않고, 이벤트가 일어난 쪽이 signal하고 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 유리하다는 것을 보이는 그림.
d1["기다리고 싶은 것은 사건"] --> d2["일어난 쪽이 signal한다"]
d2 --> d3["기다리는 쪽은 event를 기다린다"]
d3 -.-> d4["지연에도 CPU에도 전력에도 유리하다"]
그림 1: 사건을 기다린다면, 일정 간격의 상태 확인이 아니라 일어난 쪽에 알려 달라고 한다.
이 글에서 답하고 싶은 질문은 이 네 가지입니다.
Sleep(1)이나 짧은 timer wait가, 왜 생각보다 정확하지 않은가- 왜 event wait는 그 제한을 받기 어려운가
- 어떤 경우에 timer가 아니라 event를 골라야 하는가
- 그래도 timer를 써야 하는 경우는 무엇인가
이 글에 나오는 용어
본문에서 설명 없이 나오는 약어만, 먼저 적어 둡니다.
| 용어 | 의미 |
|---|---|
| platform timer resolution / system clock resolution | OS가 시각을 갱신하는 간격. timed wait의 timeout 판정은 이 granularity에 끌립니다 |
| ISR (Interrupt Service Routine) | 인터럽트가 일어났을 때 최우선으로 달리는 처리. 여기가 달리는 동안, 이쪽 thread는 기다립니다 |
| DPC (Deferred Procedure Call) | ISR이 「나중에 이어서 한다」고 쌓는, 우선순위가 높은 지연 처리. ISR과 함께 인터럽트 처리에 따른 지연 요인으로 읽어 두면, 이 글에서는 충분합니다 |
| IOCP (I/O Completion Port) | 비동기 I/O의 완료 알림을 큐에 모아, 전용 thread 그룹이 받는 Windows의 메커니즘 |
WaitOnAddress |
「어떤 메모리 주소의 값이 바뀔 때까지 기다린다」는 동기 API. 동일 프로세스 전용입니다 (5.3에서 다룹니다) |
| signal 한다 | 기다리는 쪽의 조건을 채워 주는 것. event라면 SetEvent를 호출하는 것입니다 |
1. 먼저 결론
- 작업 도착이나 I/O 완료를 기다린다면, timer가 아니라 event를 기다리는 편이 좋습니다.
- Windows의 timed wait는, 아무래도 system clock granularity의 영향을 받습니다.
Sleep(1)은 「1ms 후에 정확히 깨어난다」는 의미가 아닙니다.- 게다가 timeout이 지나도, thread는 먼저 ready가 될 뿐 즉시 실행은 보장되지 않습니다.
- 그래서 「사실은 사건을 기다리고 있는데, timer로 상태를 보러 간다」는 설계는 지연에도 전력에도 불리합니다.
- timer를 쓰는 것은, 정말로 시간 그 자체가 조건일 때만으로 좁히는 편이 깔끔합니다.
실무에서의 말투로 하면, 거의 이것입니다.
- 「5초마다 metrics를 보낸다」 -> timer의 일
- 「큐에 작업이 들어오면 바로 움직인다」 -> event / semaphore / condition variable /
WaitOnAddress의 일 - 「I/O가 끝나면 이어서 실행한다」 -> completion / event의 일
- 「정지 요청이 오면 멈춘다」 -> stop event / cancellation의 일
flowchart TB
accTitle: timer와 event의 선 긋기
accDescr: 정말로 시간 그 자체가 조건일 때만 timer를 쓰고, 작업 도착이나 I/O 완료, 정지 요청 같은 사건을 기다릴 때는 event를 기다린다는 선 긋기를 보이는 그림.
q1{"기다리고 싶은 것은 시간인가 사건인가"}
q1 -->|"시간 그 자체"| t1["timer의 일"]
q1 -->|"사건"| e1["event를 기다리는 일"]
e1 -.-> rei["작업 도착 / I/O 완료 / 정지 요청"]
그림 2: timer는 시간 그 자체가 조건일 때만으로 좁히고, 사건은 event로 기다린다.
이 글의 지식 맵
Windows의 짧은 timer wait는 system clock resolution의 분해능에 묶이는 데다, timeout이 와도 thread는 ready 상태가 될 뿐 실행 개시는 스케줄러에 달려 있으므로, Sleep(1)로 큐나 정지 요청을 살피는 폴링 설계는 생각보다 부정확합니다. producer가 SetEvent로 알리고 consumer가 WaitForSingleObject나 WaitForMultipleObjects로 기다리는 이벤트 구동으로 바꾸면, 대기가 끝나는 방식이 시간 초과가 아니라 signal이 되어 쓸데없는 헛수고가 사라집니다. I/O 완료는 overlapped I/O의 event나 IOCP, 같은 프로세스 내의 값 변화는 WaitOnAddress, 시각 그 자체가 조건일 때만 waitable timer를 쓰는 것이 도구를 구분해 쓰는 방법이며, timeBeginPeriod에 의한 정밀도 향상은 근본적인 해결이 되지 않습니다.
flowchart LR
accTitle: 타이머 폴링과 이벤트 대기
accDescr: 짧은 timer wait가 system clock resolution과 스케줄링 지연이라는 이중의 불확실성을 가진다는 것, 이벤트 구동 대기가 큐로의 작업 도착·I/O 완료·정지 요청·같은 프로세스 내의 값 변화 각각에 어떻게 대응하는지, waitable timer와 WaitOnAddress를 구분해 쓰는 관계를 보여주는 그림
timer_polling["타이머 폴링(상태 확인 루프)"]
event_driven_wait["이벤트 기반 대기 설계"]
system_clock_resolution["system clock resolution(platform timer resolution)"]
scheduler_latency["스케줄링 지연"]
queue_arrival_wait["큐로의 작업 도착 대기"]
windows_event_object["Windows 이벤트 개체"]
wait_functions["Windows 대기 함수(Wait Functions)"]
overlapped_io["Overlapped I/O"]
io_completion_wait["I/O 완료 대기"]
iocp["I/O 완료 포트(IOCP)"]
stop_request_wait["중지 요청 대기"]
waitonaddress["WaitOnAddress API"]
same_process_value_change_wait["동일 프로세스 내 값 변경 대기"]
data_race["데이터 경합(data race)"]
waitable_timer["대기 가능 타이머(waitable timer)"]
time_based_wait["시각 자체가 조건인 대기"]
timebeginperiod["timeBeginPeriod(타이머 분해능 요청)"]
getsystemtimeadjustment["GetSystemTimeAdjustment"]
interrupt_processing_delay["인터럽트 처리에 따른 지연 요인(ISR/DPC)"]
timer_polling -->|"전제로 한다"| system_clock_resolution
timer_polling -.->|"원인이 될 수 있다"| scheduler_latency
event_driven_wait -.->|"원인이 될 수 있다"| scheduler_latency
timer_polling -->|"사용은 비권장"| queue_arrival_wait
windows_event_object -->|"권장되는 대응"| queue_arrival_wait
wait_functions -->|"이용한다"| windows_event_object
overlapped_io -->|"권장되는 대응"| io_completion_wait
iocp -->|"권장되는 대응"| io_completion_wait
timer_polling -->|"사용은 비권장"| io_completion_wait
windows_event_object -->|"권장되는 대응"| stop_request_wait
timer_polling -->|"사용은 비권장"| stop_request_wait
wait_functions -->|"권장되는 대응"| stop_request_wait
waitonaddress -->|"권장되는 대응"| same_process_value_change_wait
waitonaddress -.->|"원인이 될 수 있다"| data_race
waitable_timer -->|"권장되는 대응"| time_based_wait
timebeginperiod -->|"사용은 비권장"| queue_arrival_wait
system_clock_resolution -->|"에서 확인할 수 있다"| getsystemtimeadjustment
interrupt_processing_delay -.->|"원인이 될 수 있다"| scheduler_latency
iocp -->|"이용한다"| overlapped_io
wait_functions -->|"이용한다"| waitable_timer
timer_polling -->|"사용은 비권장"| same_process_value_change_wait
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 무엇이 문제인가
2.1 timed wait는 system clock의 granularity에 묶인다
Windows의 wait functions의 timeout 정밀도는, system clock resolution에 의존합니다.
Sleep도 같고, 지정한 밀리초가 그대로 「그대로의 길이」로 보장되는 것은 아닙니다.
여기서 중요한 것은, 1ms를 지정했다고 해서 1ms 후에 깨어난다고는 할 수 없다는 점입니다.
자기 환경의 granularity를 확인한다
「15.6ms급」은 일반론이므로, 자기 환경의 값은 직접 보는 편이 빠릅니다. 확인하는 방법은 두 가지입니다.
하나는, GetSystemTimeAdjustment를 호출하는 것입니다. 두 번째 인수 lpTimeIncrement에, system이 time-of-day clock을 갱신하는 간격이 100나노초 단위로 돌아옵니다. 15.6ms급이면 15만 전후의 값이 됩니다.
#include <windows.h>
#include <cstdio>
int main()
{
DWORD adjustment = 0;
DWORD increment = 0;
BOOL adjustmentDisabled = FALSE;
if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
{
std::printf("GetSystemTimeAdjustment failed. GetLastError=%lu\n", GetLastError());
return 1;
}
// increment는 100ns 단위이므로, ms로 바꿔 본다
std::printf("time increment = %lu (100ns) = %.4f ms\n",
increment,
increment / 10000.0);
return 0;
}
또 하나는, Sysinternals의 ClockRes를 실행하는 것입니다. 이것은 안에서 같은 GetSystemTimeAdjustment를 호출해, system clock의 분해능, 즉 앱이 얻을 수 있는 최대 timer resolution을 표시하기만 하는 작은 도구입니다. 코드를 쓰지 않고 확인하고 싶을 때는 이쪽이 빠릅니다.
참고로, 문서상 lpTimeIncrement는 시작 시에 system이 정하는 고정값이며, 가동 중에 바뀌지 않는 것으로 설명되어 있습니다. 즉 이것은 「이 환경의 원래 granularity」를 알기 위한 값이지, timeBeginPeriod를 호출한 결과를 측정하기 위한 것이 아닙니다. 그쪽은 6.3에서 다룹니다.
flowchart TB
accTitle: 원래 granularity를 확인하는 두 가지 방법
accDescr: 자기 환경의 timer granularity는 GetSystemTimeAdjustment를 호출해 100나노초 단위의 갱신 간격을 보거나, 안에서 같은 API를 호출하는 Sysinternals의 ClockRes를 실행해 확인할 수 있음을 보이는 그림.
g1["자기 환경의 granularity를 알고 싶다"] --> g2["GetSystemTimeAdjustment를 호출한다"]
g1 --> g3["ClockRes를 실행한다"]
g2 --> g4["100나노초 단위의 갱신 간격이 돌아온다"]
g3 -.-> g5["안에서 같은 API를 호출하고 있다"]
그림 3: 코드로 호출하거나 ClockRes를 쓰거나, 두 가지로 환경의 원래 granularity를 확인할 수 있다.
2.2 기한이 와도, 바로 실행된다고는 할 수 없다
더 까다로운 것은, timeout이 지난 순간에 thread가 즉시 실행되는 것이 아니라는 점입니다.
Sleep의 설명에도 있는 대로, 대기 시간이 끝난 뒤 thread는 ready는 되지만, 지금 당장 CPU를 받아 달릴 보증은 없습니다.
다른 thread, priority, CPU의 idle state, DPC / ISR, lock 경합 등의 영향을 받습니다.
즉, 짧은 timer wait에는 적어도 2단계의 불확실성이 있습니다.
- 애초에 timeout 판정 자체가 timer granularity에 끌린다
- timeout 후에도, 실행 시작은 scheduler에 달려 있다
flowchart TB
accTitle: 짧은 timer wait의 2단계 불확실성
accDescr: 짧은 timer wait에서는 timeout 판정 자체가 timer granularity에 끌리고, 게다가 timeout 후에도 thread는 ready가 될 뿐 실행 시작은 scheduler에 달려 있다는 2단계 불확실성이 있음을 보이는 그림.
s1["짧은 timer wait를 시작한다"] --> s2["timeout 판정이 timer granularity에 의존한다"]
s2 --> s3["thread는 ready가 될 뿐이다"]
s3 --> s4["실행 시작은 scheduler에 달려 있다"]
s3 -.-> s5["다른 thread나 DPC / ISR의 영향"]
그림 4: timeout 판정과 실행 시작의 두 곳에서, 지정한 대기 시간에서 어긋나 간다.
2.3 Sleep(1)은 1ms 주기의 의미가 되지 않는다
Sleep(1)을 보면, 그만 「1ms마다 도는 loop」처럼 보입니다.
하지만 실제로는, 그렇게 읽어서는 안 됩니다.
while (!g_stop)
{
Step();
Sleep(1);
}
이 loop의 실태는 이렇습니다.
Step()의 실행 시간이 매번 더해진다Sleep(1)의 대기 시간 자체가 granularity에 끌린다- 깨어나도, 바로 달릴 수 있다고는 할 수 없다
flowchart TB
accTitle: Sleep(1) 루프의 실태
accDescr: Sleep(1)의 루프는 Step의 실행 시간이 매번 더해지고, 대기 시간 자체가 granularity에 끌리며, 깨어나도 바로 달릴 수 있다고는 할 수 없으므로 1밀리초 주기의 의미가 되지 않음을 보이는 그림.
p1["Step 실행 시간이 더해진다"] --> p4["1밀리초 주기가 되지 않는다"]
p2["대기가 granularity에 끌린다"] --> p4
p3["깨어난 뒤 바로 달린다고는 할 수 없다"] --> p4
그림 5: 세 가지 어긋남이 겹치므로, Sleep(1)은 1밀리초마다의 주기가 되지 않는다.
3. 왜 이벤트 대기가 유리한가
3.1 대기의 종료 조건이 「시간 초과」가 아니라 「signal」이 된다
event wait가 유리한 것은, 대기의 의미가 바뀌기 때문입니다.
timer wait는, 이렇습니다.
- 아직 아무것도 일어나지 않았어도
- 일정 시간이 오면 깨어난다
- 깨어난 뒤에 「무언가 일어났는지」를 확인한다
event wait는, 이렇습니다.
- 무언가가 일어난 쪽이 signal한다
- signal되면 대기가 충족된다
- 깨어난 시점에, 이미 이유가 있다
그림으로 보면, 대기가 끝나는 방식 자체가 다르다는 것이 보입니다.
flowchart TB
subgraph TimerWait["timer wait: 시간이 왔으니 깨어난다"]
T1["기다린다"] --> T2["timer granularity로 깨어남"]
T2 --> T3{"무언가 일어나 있었나?"}
T3 -- "아니요" --> T1
T3 -- "예" --> T4["처리한다"]
end
subgraph EventWait["event wait: 일어났으니 깨워진다"]
E1["기다린다"] --> E2["일어난 쪽이 signal한다"]
E2 --> E3["깨어난 시점에 이유가 확정되어 있다"]
E3 --> E4["처리한다"]
end
그림 6: timer wait에만 헛도는 루프가 있고, event wait는 깨어난 시점에 이유가 확정되어 있다.
timer wait 쪽에만, 헛돌고 돌아가는 루프가 있습니다. 여기가 latency와 전력 양쪽에 영향을 줍니다.
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 granularity로 깨어날 필요가 없다는 의미에서 유리하지만, signal된 순간에 절대 제로 지연으로 달리는 것은 아닙니다.
event wait에서도, 이런 영향은 받습니다.
- scheduler latency
- thread priority
- CPU의 power state
- lock 경합
- page fault
- DPC / ISR
다만 적어도, 「다음 timer tick까지 자고 있다」는 쓸데없는 대기 방식은 뺄 수 있습니다.
flowchart TB
accTitle: event wait가 뺄 수 있는 대기와 남는 영향
accDescr: event wait에서도 scheduler latency나 thread priority, DPC / ISR 등의 영향은 받지만, 다음 timer tick까지 자고 있는 쓸데없는 대기 방식은 뺄 수 있음을 보이는 그림.
e1["event wait로 기다린다"] --> e2["scheduler 등의 영향은 남는다"]
e1 --> e3["timer tick까지 자는 대기는 뺄 수 있다"]
e2 -.-> e4["signal 즉시 제로 지연은 아니다"]
그림 7: event는 마법이 아니지만, timer granularity로 깨어날 필요만은 확실히 없어진다.
4. 전형적인 안티패턴
4.1 Sleep(1)로 큐를 폴링한다
가장 자주 보는 것은 이것입니다.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
이렇게 쓰면, 얼핏 단순하지만, 문제가 세 가지 있습니다.
- queue가 비어 있어도 주기적으로 깨어난다
- latency가 timer granularity에 끌린다
- power 면에서도 손해
flowchart TB
accTitle: Sleep(1) 폴링의 세 가지 문제
accDescr: Sleep(1)로 큐를 폴링하면, 큐가 비어 있어도 주기적으로 깨어나는 것, latency가 timer granularity에 끌리는 것, 전력 면에서도 손해인 것이라는 세 가지 문제가 있음을 보이는 그림.
a1["Sleep(1)로 큐를 상태 확인"] --> b1["비어 있어도 주기적으로 깨어난다"]
a1 --> b2["latency가 granularity에 끌린다"]
a1 --> b3["power에서도 손해"]
그림 8: 얼핏 단순한 폴링 루프에는, 깨어남, 지연, 전력의 세 가지 손해가 있다.
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한다
sequenceDiagram
accTitle: producer가 signal하는 형태
accDescr: producer가 큐에 item을 넣은 직후에 SetEvent하고, consumer는 WaitForSingleObject 등으로 기다렸다가 깨어나면 큐를 drain하는 흐름을 보이는 그림.
participant P as producer
participant Q as queue
participant C as consumer
P->>Q: item을 넣는다
P->>C: 직후에 SetEvent
C->>C: wait에서 깨어난다
C->>Q: queue를 drain한다
그림 9: 도착을 아는 producer 쪽이 알려 주므로, consumer의 헛돌기가 사라진다.
5.2 WaitForMultipleObjects로 work와 stop을 동시에 기다린다
단순한 worker라면, 이 형태가 이해하기 쉽습니다.
HANDLE waits[2] = { _stopEvent, _workEvent }; // index 0 = stop, index 1 = work
for (;;)
{
// bWaitAll = FALSE 이므로, 반환값은 「먼저 signal된 handle의 index」
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
// 실패는 WAIT_FAILED ((DWORD)0xFFFFFFFF). 이유는 GetLastError로만 알 수 있다
if (rc == WAIT_FAILED)
{
throw std::system_error(
static_cast<int>(GetLastError()),
std::system_category(),
"WaitForMultipleObjects failed.");
}
if (rc == WAIT_OBJECT_0) // stop
{
return;
}
if (rc == WAIT_OBJECT_0 + 1) // work
{
DrainQueue();
continue;
}
// 여기에 오는 것은 INFINITE 대기에서는 예상 밖
// (WAIT_TIMEOUT 이나 WAIT_ABANDONED_0 계열). 무시하지 말고 실패로 처리한다
throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}
이 예의 포인트는 세 가지입니다.
Sleep(1)이 사라져 있다- item 도착 시에 producer가
SetEvent하고 있다 - worker는
stop과work를 동시에 기다리고 있다
반환값 처리만, 실무에서 빠지기 쉬운 점을 보충해 둡니다.
bWaitAll이FALSE일 때, 성공 시 반환값은WAIT_OBJECT_0부터WAIT_OBJECT_0 + nCount - 1범위이고, 거기서WAIT_OBJECT_0을 뺀 값이 배열의 index입니다.== WAIT_OBJECT_0과!= WAIT_OBJECT_0 + 1의 두 분기만으로 쓰면, handle을 3개로 늘린 순간에 깨집니다- 여러 개가 동시에 signal된 경우에는, index가 작은 쪽이 반환됩니다. 위 예에서
stop을 index 0에 둔 것은, 정지 요청을 놓치지 않기 위해서입니다 - 실패는 예외가 아니라
WAIT_FAILED((DWORD)0xFFFFFFFF) 라는 반환값으로 돌아옵니다. 원인은GetLastError를 호출하지 않으면 알 수 없습니다.rc != 기대값을 묶어 「실패」라고 써 버리면, handle이 닫혀 있었다,SYNCHRONIZE권한이 없다, 같은 원인이 사라집니다 - mutex를 대기 대상에 섞으면
WAIT_ABANDONED_0계열도 반환될 수 있습니다. 이 예에서는 event만 기다리고 있으므로 예상 밖으로 다루고 있습니다
5.3 동일 프로세스라면 WaitOnAddress도 후보
같은 프로세스 안에서, 단순히 「어떤 값이 바뀔 때까지 기다리고 싶다」만이면, WaitOnAddress도 꽤 유력합니다.
event를 만들어 초기화하고, 값과 동기가 어긋나지 않게 손을 보는, 그런 수고가 없어집니다.
구분의 감각으로는, 대체로 이렇습니다.
| 관점 | event / semaphore / waitable object | WaitOnAddress |
|---|---|---|
| 대기 대상의 범위 | 프로세스 간도 가능. 이름 붙일 수 있다 | 동일 프로세스 안만 |
| 깨우는 쪽 | SetEvent / ReleaseSemaphore 등 |
WakeByAddressSingle / WakeByAddressAll |
| 사전 준비 | 커널 객체의 생성과 handle 관리가 필요하다 | 기다릴 변수만 있으면 된다 |
| 쓸 수 있는 버전 | 오래전부터 이용 가능 | Windows 8 / Windows Server 2012 이후 |
| 링크 | Kernel32.lib |
Synchronization.lib |
쓸 때 빠지고 싶지 않은 점이 세 가지 있습니다.
- 반드시
WakeByAddressSingle또는WakeByAddressAll과 짝으로 씁니다. 값을 바꾼 쪽이 이것을 호출하지 않으면, 기다리는 thread는 깨어나지 않습니다. 하나만 깨우려면 Single, 전부 깨우려면 All입니다 WaitOnAddress는 signal되지 않아도 돌아올 수 있습니다. 문서에도, 저메모리 상태 등에서 일찍 깨어날 가능성이 명시되어 있습니다. 돌아오면 반드시 값을 한 번 더 읽어, 정말 바뀌었는지를 확인하는 while 루프로 씁니다- 기다릴 수 있는 크기는 1 / 2 / 4 / 8 바이트 중 하나입니다
- 플래그는 원자적으로 다룹니다.
WakeByAddressSingle은 기다리는 thread를 깨울 뿐이고, 직전의 쓰기를 원자적으로도 가시적으로도 만들지 않습니다. 그냥 변수를 양쪽에서 읽고 쓰면 C++에서는 데이터 경합(미정의 동작)이 되고, 최적화된 빌드에서는 갱신이 보이지 않은 채 계속 멈출 수 있습니다. 쓰는 쪽은 release, 읽는 쪽은 acquire로 맞춥니다
// 기다리는 쪽과 깨우는 쪽은 다른 스레드이므로, 플래그는 반드시 원자적으로 다룬다.
// 그냥 ULONG을 양쪽에서 읽고 쓰면 C++에서는 데이터 경합(미정의 동작)이고,
// 최적화된 빌드에서는 값이 레지스터에 남은 채 갱신이 보이지 않아,
// 깨워져도 계속 멈출 수 있다
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
"WaitOnAddress 에 넘기므로, 락 프리여야 한다");
// 「g_ready가 0이 아니게 될 때까지 기다린다」 최소형
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);
while (captured == undesired)
{
// 일찍 돌아올 수 있으므로, 돌아오면 반드시 다시 읽는다
WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
captured = g_ready.load(std::memory_order_acquire);
}
바꾸는 쪽은, 값을 갱신한 뒤에 깨웁니다.
// release로 쓴다. 이렇게 쓰면, 이 줄보다 앞에 준비한 데이터(아래 payload)도,
// acquire로 읽은 쪽에서 반드시 보인다. 순서를 보장하는 것은 이 스토어이지,
// WakeByAddressSingle이 아니다 ── 그것은 기다리는 thread를
// 깨울 뿐이고, 직전의 쓰기를 원자적으로도 가시적으로도 만들지 않는다
g_payload = ...; // 함께 넘기고 싶은 데이터
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);
sequenceDiagram
accTitle: WaitOnAddress의 짝 흐름
accDescr: 바꾸는 쪽은 데이터를 준비한 뒤 release로 플래그를 쓰고 WakeByAddressSingle로 깨우며, 기다리는 쪽은 조기 복귀에 대비해 acquire로 값을 다시 읽는 while 루프로 기다린다는 짝 흐름을 보이는 그림.
participant W as 바꾸는 쪽
participant F as 원자적 플래그
participant S as 기다리는 쪽
S->>F: acquire로 읽는다
S->>S: 바뀔 때까지 WaitOnAddress
W->>F: release로 쓴다
W->>S: WakeByAddressSingle
S->>F: 깨어나면 다시 읽는다
그림 10: 깨우는 것은 WakeByAddress이고, 값의 가시성을 보장하는 것은 release와 acquire 쪽이다.
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)을 더하고 싶어집니다.
하지만, 이것은 일상적인 첫 선택으로 삼지 않는 편이 좋습니다.
이유는 세 가지입니다.
- power / performance 비용이 있다
- 최근 Windows에서는 동작이 조금 복잡하다
- 근본 원인을 고치지 않은 경우가 많다
flowchart TB
accTitle: timeBeginPeriod를 일상적으로 쓰지 않는 이유
accDescr: 짧은 timer wait의 정밀도가 신경 쓰여도 timeBeginPeriod를 일상적인 첫 선택으로 삼지 않는 편이 좋은 것은, power와 performance 비용이 있고, 최근 Windows에서는 동작이 복잡하며, 근본 원인을 고치지 않은 경우가 많기 때문임을 보이는 그림.
t1["정밀도가 신경 쓰인다"] --> t2["timeBeginPeriod를 더하고 싶어진다"]
t2 --> t3["일상적인 첫 선택으로 삼지 않는다"]
t3 -.-> r1["비용이 있다"]
t3 -.-> r2["동작이 조금 복잡하다"]
t3 -.-> r3["근본 원인을 고치지 않았다"]
그림 11: 정밀도를 올리기 전에, 일상적으로 쓰지 않는 편이 좋은 세 가지 이유를 떠올린다.
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 granularity와 scheduler의 영향을 받습니다.
그래서, Sleep(1)이나 짧은 timeout은, 겉보기만큼 정확한 대기가 아닙니다.
한편으로, 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼, 정말 기다리고 싶은 것이 「사건」이라면, event wait 쪽이 자연스럽습니다.
정리하면, 이 한 줄로 끝입니다.
시간을 기다리면 timer, 사건을 기다리면 event.
이 기준이 분명해지는 것만으로,
- latency를 읽기 쉬워진다
- 쓸모없는 periodic wakeup이 줄어든다
- power 면에서도 나아진다
- 코드의 의도가 분명해진다
는 효과가 있습니다.
flowchart TB
accTitle: 시간은 timer, 사건은 event
accDescr: 시간을 기다리면 timer, 사건을 기다리면 event라는 선이 분명해지면, latency를 읽기 쉬워지고, 쓸모없는 periodic wakeup이 줄며, 전력 면에서도 나아지고, 코드의 의도가 분명해짐을 보이는 그림.
m1{"기다리고 싶은 것은 어느 쪽인가"}
m1 -->|"시간"| m2["timer로 기다린다"]
m1 -->|"사건"| m3["event로 기다린다"]
m2 --> m4["선이 분명해진다"]
m3 --> m4
m4 -.-> m5["latency를 읽기 쉬워진다"]
m4 -.-> m6["쓸모없는 wakeup이 줄어든다"]
그림 12: 선 긋기 한 줄을 지키는 것만으로, 지연, 전력, 코드 의도 읽기가 바뀐다.
9. 참고 자료
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- WaitForMultipleObjects function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- WakeByAddressAll function
- GetSystemTimeAdjustment function
- ClockRes - Sysinternals
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
ADR(Architecture Decision Record) 입문 ── 소규모 개발에서 '왜 이런 설계로 했는가'를 남기는 최소한의 방법
코드는 '왜 그렇게 했는지'를 말하지 않습니다. ADR(Architecture Decision Record)로 설계 판단의 이유를 결정 하나당 Markdown 파일 하나로 남기는 방법을, 템플릿과 쓸지 말지의 판단표, 실제 예와 함께 해설합니다.
슬립・최대 절전 모드・Modern Standby와 장시간 가동 앱 ── '한밤중에 멈춰 있었다'를 설계로 막는다
장시간 동작하는 Windows 앱이 '아침에 보니 멈춰 있었다'가 되는 원인을 S3 슬립/최대 절전 모드/Modern Standby의 차이에서 정리합니다. 슬립 중 타이머와 TCP 연결의 동작, SetThreadExecutionState로 억제하...
Windows 앱의 Web화, 하지 않는 편이 나은 경우 ── 판단표와 ‘분할’이라는 현실적 해법
장치 연동·로컬 파일 처리·오프라인 운영이 있는 Windows 앱에서는 Web화가 비용 증가와 기능 저하를 부를 수 있습니다. Web화에 맞는 경우와 맞지 않는 경우의 판단표, 그리고 일부만 Web으로 내보내는 분할 구성이라는 현실적 해법을 정리...
예기치 않은 예외가 났을 때 종료할지 계속할지 판단표
예상하지 못한 예외가 났을 때 앱을 종료할지 계속할지를, 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
대기 설계, 동기 primitives 선택, soft real-time에서의 지연과 전력 트레이드오프를 정리하는 이야기이므로, 기술 상담·설계 리뷰와 잘 맞습니다.
Windows 앱 개발
Windows 앱이나 서비스에서 timer polling을 event-driven으로 바꾸는 설계는 Windows 앱 개발의 구현 품질에 직결되는 주제입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Windows의 Sleep(1)은 왜 1밀리초 후에 정확히 깨어나지 않나요?
- Windows의 timed wait timeout 정밀도는 system clock resolution에 의존하며, 일반적인 설정에서는 15.6밀리초급 platform timer resolution이 전제가 되는 경우가 많기 때문입니다. 게다가 대기 시간이 끝나도 thread는 ready가 될 뿐이고, 즉시 CPU를 받아 달릴 보증은 없습니다. 다른 thread, priority, CPU의 idle state, DPC/ISR, lock 경합 등의 영향을 받습니다. 즉 짧은 timer wait에는 timeout 판정이 timer granularity에 끌리는 점과, timeout 후 실행 시작이 scheduler에 달려 있는 점이라는, 적어도 2단계의 불확실성이 있습니다.
- Sleep(1)이나 Task.Delay(1)로 큐를 폴링하는 설계의 무엇이 문제인가요?
- 문제는 세 가지입니다. 큐가 비어 있어도 주기적으로 깨어나는 것, latency가 timer granularity에 끌리는 것, 전력 면에서도 손해인 것입니다. C#의 await Task.Delay(1) 루프도 겉보기는 부드럽지만 설계의 본질은 polling입니다. 고치는 방법은 producer가 큐에 item을 넣은 직후에 SetEvent하고, consumer가 WaitForSingleObject나 WaitForMultipleObjects로 기다리는 형태로 바꾸는 것입니다. stop 이벤트와 work 이벤트를 한 번의 wait로 함께 기다리면 정지 요청에도 바로 반응할 수 있습니다.
- 타이머 대기와 이벤트 대기는 어떻게 나누면 되나요?
- 시간을 기다리면 timer, 사건을 기다리면 event가 기준입니다. 5초마다 metrics를 보내는 것처럼 시간 자체가 조건인 처리는 waitable timer의 일입니다. 큐에 작업이 도착하는 것은 event나 semaphore, I/O 완료는 overlapped I/O의 event나 IOCP, 정지 요청은 stop event나 cancellation, 같은 프로세스 안의 값 변화는 WaitOnAddress가 맞습니다. 이 기준이 분명해지면 latency를 읽기 쉬워지고, 쓸모없는 periodic wakeup이 줄며, 코드의 의도도 분명해집니다.
- timeBeginPeriod(1)로 타이머 정밀도를 올리면 해결되지 않나요?
- 일상적인 첫 선택으로 삼지 않는 편이 좋습니다. 이유는 세 가지로, power와 performance 비용이 있는 것, 최근 Windows에서는 동작이 조금 복잡해진 것, 그리고 근본 원인을 고치지 않은 경우가 많은 것입니다. 정말 기다리고 싶은 것이 작업 도착이나 I/O 완료 같은 사건이라면, 타이머 정밀도를 올리는 것이 아니라 일어난 쪽이 signal하는 이벤트 구동으로 설계를 바꾸는 편이 지연에도 CPU에도 전력에도 유리합니다.