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

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

지난 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단계의 불확실성이 있습니다.

  1. 애초에 timeout의 판정 자체가 timer 입자에 끌려간다
  2. 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가지 있습니다.

  1. queue가 비어 있어도 정기적으로 일어난다
  2. latency가 timer 입자에 끌려간다
  3. 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는 stopwork를 동시에 기다리고 있다

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가지 있습니다.

  1. power / performance의 비용이 있다
  2. 최근의 Windows에서는 동작이 조금 복잡
  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 측면에서도 나아진다
  • 코드의 의도가 알기 쉬워진다

라는 형태로 효과가 나옵니다.

9. 참고 자료

관련 기사

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

관련 토픽

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

이 주제와 연결되는 서비스

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

블로그 목록으로 돌아가기