Windows에서 Sleep(1)보다 이벤트 대기를 우선해야 하는 이유

· 업데이트: · · 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에도 전력에도 유리합니다.

이벤트 구동의 대기 방식정말 기다리고 싶은 것이 시간이 아니라 사건이라면, 일정 간격으로 보러 가지 않고, 이벤트가 일어난 쪽이 signal하고 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 유리하다는 것을 보이는 그림.기다리고 싶은 것은 사건일어난 쪽이 signal한다기다리는 쪽은 event를 기다린다지연에도 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의 일
timer와 event의 선 긋기정말로 시간 그 자체가 조건일 때만 timer를 쓰고, 작업 도착이나 I/O 완료, 정지 요청 같은 사건을 기다릴 때는 event를 기다린다는 선 긋기를 보이는 그림.시간 그 자체사건기다리고 싶은 것은 시간인가 사건인가timer의 일event를 기다리는 일작업 도착 / 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에 의한 정밀도 향상은 근본적인 해결이 되지 않습니다.

타이머 폴링과 이벤트 대기짧은 timer wait가 system clock resolution과 스케줄링 지연이라는 이중의 불확실성을 가진다는 것, 이벤트 구동 대기가 큐로의 작업 도착·I/O 완료·정지 요청·같은 프로세스 내의 값 변화 각각에 어떻게 대응하는지, waitable timer와 WaitOnAddress를 구분해 쓰는 관계를 보여주는 그림전제로 한다원인이 될 수 있다원인이 될 수 있다사용은 비권장권장되는 대응이용한다권장되는 대응권장되는 대응사용은 비권장권장되는 대응사용은 비권장권장되는 대응권장되는 대응원인이 될 수 있다권장되는 대응사용은 비권장에서 확인할 수 있다원인이 될 수 있다이용한다이용한다사용은 비권장타이머 폴링(상태 확인 루프)이벤트 기반 대기 설계system clock resolution(platform timer resolution)스케줄링 지연큐로의 작업 도착 대기Windows 이벤트 개체Windows 대기 함수(Wait Functions)Overlapped I/OI/O 완료 대기I/O 완료 포트(IOCP)중지 요청 대기WaitOnAddress API동일 프로세스 내 값 변경 대기데이터 경합(data race)대기 가능 타이머(waitable timer)시각 자체가 조건인 대기timeBeginPeriod(타이머 분해능 요청)GetSystemTimeAdjustment인터럽트 처리에 따른 지연 요인(ISR/DPC)

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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에서 다룹니다.

원래 granularity를 확인하는 두 가지 방법자기 환경의 timer granularity는 GetSystemTimeAdjustment를 호출해 100나노초 단위의 갱신 간격을 보거나, 안에서 같은 API를 호출하는 Sysinternals의 ClockRes를 실행해 확인할 수 있음을 보이는 그림.자기 환경의 granularity를 알고 싶다GetSystemTimeAdjustment를 호출한다ClockRes를 실행한다100나노초 단위의 갱신 간격이 돌아온다안에서 같은 API를 호출하고 있다

그림 3: 코드로 호출하거나 ClockRes를 쓰거나, 두 가지로 환경의 원래 granularity를 확인할 수 있다.

2.2 기한이 와도, 바로 실행된다고는 할 수 없다

더 까다로운 것은, timeout이 지난 순간에 thread가 즉시 실행되는 것이 아니라는 점입니다.

Sleep의 설명에도 있는 대로, 대기 시간이 끝난 뒤 thread는 ready는 되지만, 지금 당장 CPU를 받아 달릴 보증은 없습니다. 다른 thread, priority, CPU의 idle state, DPC / ISR, lock 경합 등의 영향을 받습니다.

즉, 짧은 timer wait에는 적어도 2단계의 불확실성이 있습니다.

  1. 애초에 timeout 판정 자체가 timer granularity에 끌린다
  2. timeout 후에도, 실행 시작은 scheduler에 달려 있다
짧은 timer wait의 2단계 불확실성짧은 timer wait에서는 timeout 판정 자체가 timer granularity에 끌리고, 게다가 timeout 후에도 thread는 ready가 될 뿐 실행 시작은 scheduler에 달려 있다는 2단계 불확실성이 있음을 보이는 그림.짧은 timer wait를 시작한다timeout 판정이 timer granularity에 의존한다thread는 ready가 될 뿐이다실행 시작은 scheduler에 달려 있다다른 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에 끌린다
  • 깨어나도, 바로 달릴 수 있다고는 할 수 없다
Sleep(1) 루프의 실태Sleep(1)의 루프는 Step의 실행 시간이 매번 더해지고, 대기 시간 자체가 granularity에 끌리며, 깨어나도 바로 달릴 수 있다고는 할 수 없으므로 1밀리초 주기의 의미가 되지 않음을 보이는 그림.Step 실행 시간이 더해진다1밀리초 주기가 되지 않는다대기가 granularity에 끌린다깨어난 뒤 바로 달린다고는 할 수 없다

그림 5: 세 가지 어긋남이 겹치므로, Sleep(1)은 1밀리초마다의 주기가 되지 않는다.

3. 왜 이벤트 대기가 유리한가

3.1 대기의 종료 조건이 「시간 초과」가 아니라 「signal」이 된다

event wait가 유리한 것은, 대기의 의미가 바뀌기 때문입니다.

timer wait는, 이렇습니다.

  • 아직 아무것도 일어나지 않았어도
  • 일정 시간이 오면 깨어난다
  • 깨어난 뒤에 「무언가 일어났는지」를 확인한다

event wait는, 이렇습니다.

  • 무언가가 일어난 쪽이 signal한다
  • signal되면 대기가 충족된다
  • 깨어난 시점에, 이미 이유가 있다

그림으로 보면, 대기가 끝나는 방식 자체가 다르다는 것이 보입니다.

event wait: 일어났으니 깨워진다일어난 쪽이 signal한다기다린다깨어난 시점에 이유가 확정되어 있다처리한다timer wait: 시간이 왔으니 깨어난다아니요timer granularity로 깨어남기다린다무언가 일어나 있었나?처리한다

그림 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까지 자고 있다」는 쓸데없는 대기 방식은 뺄 수 있습니다.

event wait가 뺄 수 있는 대기와 남는 영향event wait에서도 scheduler latency나 thread priority, DPC / ISR 등의 영향은 받지만, 다음 timer tick까지 자고 있는 쓸데없는 대기 방식은 뺄 수 있음을 보이는 그림.event wait로 기다린다scheduler 등의 영향은 남는다timer tick까지 자는 대기는 뺄 수 있다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);
}

이렇게 쓰면, 얼핏 단순하지만, 문제가 세 가지 있습니다.

  1. queue가 비어 있어도 주기적으로 깨어난다
  2. latency가 timer granularity에 끌린다
  3. power 면에서도 손해
Sleep(1) 폴링의 세 가지 문제Sleep(1)로 큐를 폴링하면, 큐가 비어 있어도 주기적으로 깨어나는 것, latency가 timer granularity에 끌리는 것, 전력 면에서도 손해인 것이라는 세 가지 문제가 있음을 보이는 그림.Sleep(1)로 큐를 상태 확인비어 있어도 주기적으로 깨어난다latency가 granularity에 끌린다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한다
producer가 signal하는 형태producer가 큐에 item을 넣은 직후에 SetEvent하고, consumer는 WaitForSingleObject 등으로 기다렸다가 깨어나면 큐를 drain하는 흐름을 보이는 그림.consumerqueueproducerconsumerqueueproduceritem을 넣는다직후에 SetEventwait에서 깨어난다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는 stopwork를 동시에 기다리고 있다

반환값 처리만, 실무에서 빠지기 쉬운 점을 보충해 둡니다.

  • bWaitAllFALSE일 때, 성공 시 반환값은 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

쓸 때 빠지고 싶지 않은 점이 세 가지 있습니다.

  1. 반드시 WakeByAddressSingle 또는 WakeByAddressAll과 짝으로 씁니다. 값을 바꾼 쪽이 이것을 호출하지 않으면, 기다리는 thread는 깨어나지 않습니다. 하나만 깨우려면 Single, 전부 깨우려면 All입니다
  2. WaitOnAddress는 signal되지 않아도 돌아올 수 있습니다. 문서에도, 저메모리 상태 등에서 일찍 깨어날 가능성이 명시되어 있습니다. 돌아오면 반드시 값을 한 번 더 읽어, 정말 바뀌었는지를 확인하는 while 루프로 씁니다
  3. 기다릴 수 있는 크기는 1 / 2 / 4 / 8 바이트 중 하나입니다
  4. 플래그는 원자적으로 다룹니다. 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);
WaitOnAddress의 짝 흐름바꾸는 쪽은 데이터를 준비한 뒤 release로 플래그를 쓰고 WakeByAddressSingle로 깨우며, 기다리는 쪽은 조기 복귀에 대비해 acquire로 값을 다시 읽는 while 루프로 기다린다는 짝 흐름을 보이는 그림.기다리는 쪽원자적 플래그바꾸는 쪽기다리는 쪽원자적 플래그바꾸는 쪽acquire로 읽는다바뀔 때까지 WaitOnAddressrelease로 쓴다WakeByAddressSingle깨어나면 다시 읽는다

그림 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)을 더하고 싶어집니다. 하지만, 이것은 일상적인 첫 선택으로 삼지 않는 편이 좋습니다.

이유는 세 가지입니다.

  1. power / performance 비용이 있다
  2. 최근 Windows에서는 동작이 조금 복잡하다
  3. 근본 원인을 고치지 않은 경우가 많다
timeBeginPeriod를 일상적으로 쓰지 않는 이유짧은 timer wait의 정밀도가 신경 쓰여도 timeBeginPeriod를 일상적인 첫 선택으로 삼지 않는 편이 좋은 것은, power와 performance 비용이 있고, 최근 Windows에서는 동작이 복잡하며, 근본 원인을 고치지 않은 경우가 많기 때문임을 보이는 그림.정밀도가 신경 쓰인다timeBeginPeriod를 더하고 싶어진다일상적인 첫 선택으로 삼지 않는다비용이 있다동작이 조금 복잡하다근본 원인을 고치지 않았다

그림 11: 정밀도를 올리기 전에, 일상적으로 쓰지 않는 편이 좋은 세 가지 이유를 떠올린다.

7. 리뷰 때의 체크리스트

  • Sleep(1) / Thread.Sleep(1) / Task.Delay(1)로 상태 확인 loop를 만들지 않았는가
  • 사실은 queue 도착, I/O 완료, 정지 요청을 기다리면서 timer poll하지 않았는가
  • producer / completion 쪽에서 signal할 수 있는 설계가 되어 있는가
  • stopwork를 한 번의 wait로 함께 기다릴 수 없는가
  • 동일 프로세스의 값 변화라면 WaitOnAddress로 쓸 수 없는가
  • timer를 쓰는 곳에서, 정말로 기다리고 싶은 것이 「시간」인가

8. 정리

Windows에서 짧은 timer wait를 써서 「일정 시간마다 상태를 본다」는 설계는, 아무래도 timer granularity와 scheduler의 영향을 받습니다. 그래서, Sleep(1)이나 짧은 timeout은, 겉보기만큼 정확한 대기가 아닙니다.

한편으로, 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼, 정말 기다리고 싶은 것이 「사건」이라면, event wait 쪽이 자연스럽습니다.

정리하면, 이 한 줄로 끝입니다.

시간을 기다리면 timer, 사건을 기다리면 event.

이 기준이 분명해지는 것만으로,

  • latency를 읽기 쉬워진다
  • 쓸모없는 periodic wakeup이 줄어든다
  • power 면에서도 나아진다
  • 코드의 의도가 분명해진다

는 효과가 있습니다.

시간은 timer, 사건은 event시간을 기다리면 timer, 사건을 기다리면 event라는 선이 분명해지면, latency를 읽기 쉬워지고, 쓸모없는 periodic wakeup이 줄며, 전력 면에서도 나아지고, 코드의 의도가 분명해짐을 보이는 그림.시간사건기다리고 싶은 것은 어느 쪽인가timer로 기다린다event로 기다린다선이 분명해진다latency를 읽기 쉬워진다쓸모없는 wakeup이 줄어든다

그림 12: 선 긋기 한 줄을 지키는 것만으로, 지연, 전력, 코드 의도 읽기가 바뀐다.

9. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

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에도 전력에도 유리합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기