Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 분해능의 폴링을 피한다

· 업데이트: · · Windows 개발, 동기화, 이벤트, 타이머, 설계

수정 이력(1건, 최종 수정 2026년 09월 01일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635165)
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635164)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

小村 豪 (2026). 「Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 분해능의 폴링을 피한다」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635164 https://comcomponent.com/ko/blog/2026/03/16/006-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의 분해능그 후의 스케줄링 지연의 영향을 받습니다. 일반적인 설정에서는 15.6ms급의 platform timer resolution이 전제가 되는 경우가 많으므로, 「1ms 후에 다시 한 번 보자」는 의도여도 실제로는 꽤 거친 대기가 되기 쉽습니다.

한편 작업 도착, I/O 완료, 정지 요청, 상태 변화처럼 정말로 기다리고 싶은 것이 「시간」이 아니라 「사건」이라면, 일정 간격으로 보러 갈 필요는 없습니다. 이벤트가 일어난 쪽이 signal하고, 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 유리합니다.

이벤트 구동 대기 방식정말로 기다리고 싶은 것이 시간이 아니라 사건이라면, 일정 간격으로 보러 가지 않고 이벤트가 일어난 쪽이 signal하며 기다리는 쪽은 event를 기다리는 편이 지연에도 CPU에도 전력에도 유리하다는 것을 보여주는 그림.기다리고 싶은 것은 사건일어난 쪽이 signal한다기다리는 쪽은 event를 기다린다지연에도 CPU에도 전력에도 유리

그림 1: 사건을 기다린다면, 일정 간격의 상태 확인이 아니라 일어난 쪽이 알려주게 한다.

이 글에서 답하고 싶은 질문은 다음 4가지입니다.

  • Sleep(1)이나 짧은 timer wait가 왜 생각보다 정확하지 않은가
  • 왜 event wait는 그 제약을 받기 어려운가
  • 어떤 장면에서 timer가 아니라 event를 선택해야 하는가
  • 그래도 timer를 써야 하는 장면은 무엇인가

이 글에 나오는 용어

본문에서 설명 없이 나오는 약어만 먼저 정리해 둡니다.

용어 의미
platform timer resolution / system clock resolution OS가 시각을 갱신하는 간격. timed wait의 timeout 판정은 이 분해능에 끌려갑니다
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의 분해능의 영향을 받습니다.
  • 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의 분해능에 묶인다

Windows의 wait functions의 timeout 정밀도는 system clock resolution에 의존합니다. Sleep도 마찬가지로, 지정한 밀리초가 그대로 「그 길이 그대로」로 보증되는 것은 아닙니다.

여기서 중요한 것은 1ms를 지정했다고 해서 1ms 후에 일어난다고는 할 수 없다는 점입니다.

자신의 환경의 분해능을 확인한다

「15.6ms급」은 일반론이므로, 실제 값은 직접 보는 편이 빠릅니다. 확인하는 방법은 2가지입니다.

하나는 GetSystemTimeAdjustment를 호출하는 것입니다. 두 번째 인수인 lpTimeIncrementsystem이 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이 정하는 고정값으로, 가동 중에는 바뀌지 않는 것으로 설명되어 있습니다. 즉 이것은 「이 환경의 본래 분해능」을 알기 위한 값이며, timeBeginPeriod를 호출한 결과를 측정하기 위한 것이 아닙니다. 그쪽에 대해서는 6.3에서 다룹니다.

본래 분해능을 확인하는 2가지 방법자신의 환경의 timer 분해능은 GetSystemTimeAdjustment를 호출해 100나노초 단위의 갱신 간격을 보거나, 내부에서 같은 API를 호출하는 Sysinternals의 ClockRes를 실행해 확인할 수 있음을 보여주는 그림.직접 환경의 분해능을 알고 싶다GetSystemTimeAdjustment를 호출한다ClockRes를 실행한다100나노초 단위의 갱신 간격이 반환된다내부에서 같은 API를 호출하고 있다

그림 3: 코드로 호출하거나 ClockRes를 쓰는 2가지 방법으로 환경의 본래 분해능을 확인할 수 있다.

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

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

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

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

  1. 애초에 timeout의 판정 자체가 timer 분해능에 끌려간다
  2. timeout 후에도 실행 개시는 scheduler에 달려 있다
짧은 timer wait의 2단계 불확실성짧은 timer wait에서는 timeout의 판정 자체가 timer 분해능에 끌려가고, 게다가 timeout 후에도 thread는 ready가 될 뿐 실행 개시는 scheduler에 달려 있다는 2단계의 불확실성이 있음을 보여주는 그림.짧은 timer wait를 시작timeout 판정이 timer 분해능에 의존thread는 ready가 될 뿐실행 개시는 scheduler에 달려 있다다른 thread나 DPC / ISR의 영향

그림 4: timeout 판정과 실행 개시의 2군데에서 지정한 대기 시간과 어긋나 간다.

2.3 Sleep(1)은 1ms 주기의 의미가 되지 않는다

Sleep(1)을 보면 무심코 「1ms마다 도는 loop」처럼 보입니다. 하지만 실제로는 그렇게 읽어서는 안 됩니다.

while (!g_stop)
{
    Step();
    Sleep(1);
}

이 loop의 실체는 다음과 같습니다.

  • Step()의 실행 시간이 매번 더해진다
  • Sleep(1)의 대기 시간 자체가 분해능에 끌려간다
  • 눈을 떠도 바로 달릴 수 있다고는 할 수 없다
Sleep(1) 루프의 실체Sleep(1) 루프는 Step의 실행 시간이 매번 더해지고, 대기 시간 자체가 분해능에 끌려가며, 눈을 떠도 바로 달릴 수 있다고는 할 수 없기 때문에 1밀리초 주기의 의미가 되지 않음을 보여주는 그림.Step 실행 시간이 더해진다1밀리초 주기가 되지 않는다대기가 분해능에 끌려간다기상 후 바로 달릴 수 있다고는 할 수 없다

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

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

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

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

timer wait는 다음과 같습니다.

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

event wait는 다음과 같습니다.

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

그림으로 그리면 대기가 끝나는 방식 자체가 다르다는 것을 알 수 있습니다.

event wait: 일어났으니까 깨워진다일어난 쪽이 signal한다기다린다기상 시점에 이유가 확정되어 있다처리한다timer wait: 시간이 됐으니까 일어난다아니오timer 분해능으로 기상기다린다무언가 일어났는가?처리한다

그림 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 분해능으로 일어날 필요가 없다는 의미에서 유리하지만, 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 분해능으로 일어날 필요만은 확실히 없어진다.

4. 전형적인 안티패턴

4.1 Sleep(1)로 큐를 폴링한다

가장 자주 보이는 것이 이것입니다.

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

이 작성법은 얼핏 단순해 보이지만 문제가 3가지 있습니다.

  1. queue가 비어 있어도 정기적으로 일어난다
  2. latency가 timer 분해능에 끌려간다
  3. power 면에서도 손해
Sleep(1) 폴링의 3가지 문제Sleep(1)로 큐를 폴링하면 큐가 비어 있어도 정기적으로 일어난다는 것, 레이턴시가 timer 분해능에 끌려간다는 것, 전력 면에서도 손해라는 것이라는 3가지 문제가 있음을 보여주는 그림.Sleep(1)로 큐를 상태 확인비어 있어도 정기적으로 일어난다latency가 분해능에 끌려간다power에서도 손해

그림 8: 얼핏 단순한 폴링 루프에는 기상, 지연, 전력이라는 3가지 손해가 있다.

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.");
}

이 예의 포인트는 3가지입니다.

  • 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이라는 2가지 분기만으로 작성하면 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

쓸 때 놓치고 싶지 않은 점이 3가지 있습니다.

  1. 반드시 WakeByAddressSingle이나 WakeByAddressAll과 짝을 지어 씁니다. 값을 다시 쓴 쪽이 이것을 호출하지 않으면 기다리는 thread는 일어나지 않습니다. 하나만 깨운다면 Single, 전부 깨운다면 All입니다
  2. WaitOnAddress는 signal되지 않아도 돌아올 수 있습니다. 문서에도 저메모리 상태 등에서 일찍 일어날 가능성이 명시되어 있습니다. 돌아왔다면 반드시 값을 다시 한 번 읽어, 정말로 바뀌었는지 확인하는 while 루프로 작성합니다
  3. 기다릴 수 있는 크기는 1 / 2 / 4 / 8바이트 중 하나입니다
  4. 플래그는 atomic으로 합니다. WakeByAddressSingle은 기다리는 thread를 깨울 뿐, 직전의 쓰기를 atomic하게도 가시적으로도 만들어 주지 않습니다. 순수한 변수를 양쪽에서 읽고 쓰면 C++에서는 데이터 경합(정의되지 않은 동작)이 되고, 최적화된 빌드에서는 갱신이 보이지 않은 채 계속 멈춰 있을 수 있습니다. 쓰는 쪽은 release, 읽는 쪽은 acquire로 맞춥니다
// 기다리는 쪽과 깨우는 쪽은 서로 다른 스레드이므로 플래그는 반드시 atomic으로 한다.
// 순수한 ULONG을 양쪽에서 읽고 쓰면 C++에서는 데이터 경합(정의되지 않은 동작)이며,
// 최적화된 빌드에서는 값이 레지스터에 올라간 채 갱신이 보이지 않아,
// 깨워져도 계속 멈춰 있을 수 있다
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
              "WaitOnAddress에 넘기므로 lock-free여야 한다");

// 「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로 읽은 쪽에서 반드시 보인다. 순서를 보증하는 것은 이 store이지
// WakeByAddressSingle이 아니다 ── 그것은 기다리는 thread를
// 깨울 뿐, 직전의 쓰기를 atomic하게도 가시적으로도 만들어 주지 않는다
g_payload = ...;                                  // 함께 넘기고 싶은 데이터
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);
WaitOnAddress의 짝을 이루는 흐름다시 쓰는 쪽은 데이터를 준비하고 release로 플래그를 쓴 다음 WakeByAddressSingle로 깨우고, 기다리는 쪽은 조기 복귀에 대비해 acquire로 값을 다시 읽는 while 루프로 기다리는 짝을 이루는 흐름을 보여주는 그림.기다리는 쪽atomic 플래그다시 쓰는 쪽기다리는 쪽atomic 플래그다시 쓰는 쪽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)을 더하고 싶어집니다. 하지만 이것은 상용의 첫 선택으로 삼지 않는 편이 좋습니다.

이유는 3가지 있습니다.

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

그림 11: 정밀도를 올리기 전에, 상용하지 않는 편이 좋은 3가지 이유를 떠올린다.

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 분해능과 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 분해능에 끌려간다는 점과, timeout 후의 실행 개시가 scheduler에 달려 있다는 점, 적어도 2단계의 불확실성이 있습니다.
Sleep(1)이나 Task.Delay(1)로 큐를 폴링하는 설계는 무엇이 문제인가요?
문제는 3가지입니다. 큐가 비어 있어도 정기적으로 일어난다는 것, 레이턴시가 timer 분해능에 끌려간다는 것, 전력 면에서도 손해라는 것입니다. 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가 적합합니다. 이 선긋기가 분명해지면 레이턴시를 읽기 쉬워지고, 쓸데없는 periodic wakeup이 줄어들며, 코드의 의도도 알기 쉬워집니다.
timeBeginPeriod(1)로 타이머 정밀도를 올리면 해결되지 않나요?
상용의 첫 선택으로 삼지 않는 편이 좋습니다. 이유는 3가지로, power와 performance의 비용이 있다는 것, 최근 Windows에서는 동작이 조금 복잡해졌다는 것, 그리고 근본 원인을 고치지 않은 경우가 많다는 것입니다. 정말로 기다리고 싶은 것이 작업 도착이나 I/O 완료 같은 사건이라면, 타이머 정밀도를 올리는 것이 아니라 일어난 쪽이 signal하는 이벤트 구동으로 설계를 바꾸는 편이 지연에도 CPU에도 전력에도 유리합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기