spurious wakeup ── 조건 변수가 「알림 없이 깨어나는」 이유와 Windows에서 올바르게 기다리는 방법

· 업데이트: · · Windows, 멀티스레드, 조건 변수, 동기화, C++, C#, Win32 API, 불량 조사

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

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

한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22176643)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「spurious wakeup ── 조건 변수가 「알림 없이 깨어나는」 이유와 Windows에서 올바르게 기다리는 방법」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-condition-variable-spurious-wakeup/

DOI(등록된 아카이브)
10.5281/zenodo.22176643
DOI(마지막 등록 버전)
10.5281/zenodo.22176644

「큐에 데이터를 넣었으면, 기다리는 워커 스레드를 깨운다. 반년 동안 잘 돌다가, 어느 날 빈 큐를 읽으려다 죽었다」「알림은 보내고 있을 텐데, 가끔 깨어나지 않는 스레드가 있다」── 멀티스레드의 대기는 돌아가는 것처럼 보이면서, 드물게만 나오는 버그의 온상입니다. 이런 조사에서 자주 도착하는 곳이, 조건 변수(condition variable)의 wait를 if로 감싼 코드입니다. 그리고 그 뒤에 있는 것이 spurious wakeup(가짜 깨어남) ── 알림을 받지 않았는데 wait에서 깨어나는 현상입니다.

「알리지 않았는데 깨어난다」고 들으면 구현의 결함처럼 보이지만, 이는 Win32·C++·POSIX가 모두 문서나 규격에 명시한 사양이며, .NET의 Monitor도 「깨어나면 조건을 다시 확인한다」는 쓰임새를 전제로 설계되어 있습니다. 왜 그런 동작이 허용되는가. Windows에서는 어느 계층에서 일어나는가. 그리고 어떻게 쓰면 절대 밟지 않는가. 이 글에서는 Windows에서 업무 앱이나 장치 제어 소프트웨어를 쓰는 개발자를 대상으로, spurious wakeup의 정체를 1차 정보를 바탕으로 밝히고, Win32(C)·C++·C# 각각의 올바른 대기 방법으로 풀어 씁니다.

1. 먼저 결론

  • 조건 변수의 wait는 알림이 오지 않아도 반환될 수 있습니다. Win32 공식 문서는 조건 변수에 spurious wakeup(명시적 깨어남과 묶이지 않는 깨어남)과 stolen wakeup(깨어난 스레드보다 다른 스레드가 조건을 먼저 소비하는 일)이 있다고 명시합니다.1
  • 따라서 대기는 반드시 「while 루프 + 조건 재확인」으로 씁니다. if로 한 번 검사하고 wait하는 코드는 돌아가는 것처럼 보이면서, 드물게만 재현되는 버그를 품습니다.12
  • 이는 Windows만의 버릇이 아니라 POSIX도 C++ 규격도 같습니다. 「가짜로 절대 깨어나지 않는」 구현은 조건 변수의 모든 연산을 느리게 하므로, 대기 쪽의 재확인을 전제로 허용되어 있습니다.34
  • C++에서는 predicate를 받는 wait(lock, pred)를 쓰면 루프는 라이브러리가 대신합니다. 이 형태는 사실상 while (!pred()) wait(lock);을 실행합니다. 새 코드의 기본값은 이쪽입니다.5
  • C#의 Monitor.Wait에도 같은 규율이 필요합니다. 깨어난 뒤 락을 다시 잡을 때까지 조건이 소비될 수 있으므로, while로 조건을 확인한 다음 Wait로 돌아갑니다.6
  • 조건의 갱신과 확인은 같은 락 안에서 합니다. 락 밖에서 조건을 보고 wait에 들어가면, 그 틈에 알림이 지나가는 lost wakeup이 일어납니다.1
  • 조건 변수의 「지금 기다리는 사람을 깨운다」는 일시적 알림을 이벤트의 pulse로 재현하지 마십시오. 특히 PulseEvent는 커널 모드 APC로 대기가 잠깐 풀린 순간에 알림이 그냥 지나가므로, Microsoft 자신이 「신뢰할 수 없으니 쓰지 말고, 대신 조건 변수를 쓰라」고 분명히 말합니다.7

이하는 이 결론을 뒷받침하는 구조를 차례로 설명합니다.

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 17건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. spurious wakeup이란 무엇인가 ── 「깨어났다 = 조건 성립」이 아니다

조건 변수는 「어떤 조건이 성립할 때까지 스레드를 재워 두고, 성립하면 깨워 주는」 동기 프리미티브입니다. Win32에서는 CONDITION_VARIABLE 구조체와 SleepConditionVariableCS / SleepConditionVariableSRW(대기), WakeConditionVariable / WakeAllConditionVariable(알림)이 이에 해당합니다. 대기 API는 쥐고 있는 락(크리티컬 섹션 또는 SRW 락)을 풀고 잠드는 일을 원자적으로 수행하고, 깨어날 때는 락을 다시 잡은 뒤에 반환합니다.1

문제는 「wait에서 반환했다」는 사실이 무엇을 의미하는가입니다. 소박하게는 「알림이 왔다 = 조건이 성립했다」고 생각하고 싶지만, 실제로는 wait에서 반환하는 경우는 세 가지입니다.

경우 알림 반환 시점의 조건
정상적인 깨어남 있음 성립해 있는 경우가 많지만, 보장은 없다
spurious wakeup 자기 앞으로 온 것 없음 불성립인 채
stolen wakeup 있음 다른 스레드가 먼저 소비해 불성립
wait에서 반환하는 세 가지 경우조건 변수의 wait에서는 정규 알림에 의한 깨어남 외에, 알림 없는 spurious wakeup과, 알림은 있었으나 조건을 먼저 소비당한 stolen wakeup으로도 반환하므로, 어느 경우에도 조건의 재확인이 필요해진다wait에서 반환했다정규 알림spurious wakeup(알림 없음)stolen wakeup(조건은 이미 소비됨)조건을 다시 확인한 뒤에 진행한다

그림 1: wait에서 반환하는 경로는 세 가지이며, 어느 경로로 돌아왔는지를 호출 쪽은 구별할 수 없으므로, 반드시 조건을 다시 확인한다.

spurious wakeup은 이 두 번째 경우 ── 자기를 깨우는 명시적 알림과 묶이지 않은 채 대기 API가 반환하는 현상입니다. 시스템 전체에서 WakeConditionVariable이 한 번도 호출되지 않은 상황에 한정되지 않습니다. 예를 들어 알림이 짧은 시간에 연속하는 고부하 상황에서, 구현 사정으로 대기 중인 스레드가 추가로 한꺼번에 깨어나는 일도 있으며, 대응하는 알림이 없는 쪽에서 보면 그것도 spurious wakeup입니다. Microsoft Learn의 조건 변수 페이지는 이 점을 분명히 적습니다. 「조건 변수는 spurious wakeup(명시적 깨어남과 묶이지 않는 것)과 stolen wakeup(깨어난 스레드보다 다른 스레드가 먼저 실행되는 것)의 대상이 된다. 따라서 대기 조작에서 반환한 뒤에는 predicate를(보통은 while 루프로) 다시 확인해야 한다」.1

중요한 것은, 세 가지 경우 중 어느 쪽으로 돌아왔는지를 호출 쪽이 구별할 수 없다는 점입니다. 구별할 수 없는 이상, 취할 전략은 하나뿐입니다. 반환할 때마다, 기다리던 조건 그 자체를 확인하고, 성립하지 않았으면 다시 잠든다. 이것이 「wait는 while로 감싼다」는 철칙의 정체입니다. 반대로 말하면, 이 철칙만 지키면 세 가지 경우 중 어느 쪽으로 깨어나든 코드는 올바르게 동작합니다.

3. 사양으로 허용되는 이유 ── 정확한 알림은 비싸다

「알리지 않았는데 깨어나는 것은 구현을 대충 한 것 아닌가」라는 의문은 타당합니다. 실제로 spurious wakeup을 일으키지 않는 구현을 만드는 것은 이론상 가능합니다. 그런데도 POSIX도 Windows도 C++ 규격도, 모두 「일어날 수 있다」 쪽으로 기울였습니다. 이유는 POSIX(The Open Group Base Specifications)의 pthread_cond_wait Rationale(근거)에 솔직히 적혀 있습니다.3

첫 번째 이유는 성능입니다. 「딱 스레드 하나만 확실히 깨우는」 알림을 엄격히 구현하려고 하면, 특히 멀티프로세서 환경에서, 조건 변수의 모든 연산에 추가 동기화 비용이 붙습니다. 알림과 깨어남 사이에는 스케줄러가 개입하고, 인터럽트나 선점 타이밍에 따라 「깨웠을 스레드보다 다른 스레드가 먼저 돈다」는 일을 피할 수 없습니다. 이를 완전히 봉쇄하는 비용을 모두에게 치르게 하는 것보다, 「드물게 추가로 깨어날 수 있다」고 받아들이는 편이 조건 변수를 빠르게 유지할 수 있습니다.

두 번째 이유는, 그 판단이 애플리케이션을 깨뜨리지 않는다 ── 오히려 튼튼하게 만든다는 관찰입니다. spurious wakeup이 허용되어 있는 이상, 올바른 코드는 반드시 predicate(기다리던 조건)를 확인하는 루프를 쓰게 됩니다. POSIX Rationale은, 이 루프의 강제가 코드가 스스로 의도를 드러내게 하고 더 튼튼하게 만든다고 말합니다.3 루프만 있으면 알림의 의미는 「조건이 성립했다는 보장」에서 「조건이 바뀌었을지도 모른다는 힌트」로 내려가고, 알리는 쪽의 약간의 설계 변경(너무 많이 깨우기, 한꺼번에 깨우기 등)에도 견디게 됩니다.

stolen wakeup 쪽은 더 구조적인 이야기입니다. 알리는 쪽이 WakeConditionVariable을 호출한 뒤, 깨어난 스레드가 락을 다시 잡고 wait에서 반환하기까지는 반드시 시간 차가 있습니다. 그 사이에 락을 잡을 수 있는 세 번째 스레드가 있으면, 조건(큐의 내용 등)을 먼저 소비할 수 있습니다. 이는 구현을 아무리 다듬어도 지울 수 없는, 조건 변수라는 도구의 형태 자체에서 나오는 틈입니다.

stolen wakeup이 일어나는 시계열생산자가 큐에 1건을 넣고 대기 중인 소비자 A를 깨우지만, A가 락을 다시 잡기 전에 소비자 B가 락을 잡아 1건을 꺼내 버려, A가 깨어났을 때는 큐가 비어 있다소비자 B생산자소비자 A(대기 중)소비자 B생산자소비자 A(대기 중)깨어났으나 락 재획득 대기큐는 비어 있다(stolen wakeup)큐에 1건 추가WakeConditionVariable락을 잡아 1건을 꺼낸다락을 다시 잡고 wait에서 복귀while로 다시 확인하고 다시 wait

그림 2: 알림부터 깨어나기까지의 시간 차 사이에, 세 번째 스레드가 조건을 소비하는 stolen wakeup은 어떤 구현에서도 일어날 수 있다.

즉, 설령 OS가 spurious wakeup을 완전히 없앤다 해도, stolen wakeup이 있는 한 「깨어났다 = 조건 성립」이라고 쓸 수 없습니다. 대기 쪽의 재확인 루프는 어차피 필수이며, 그렇다면 spurious wakeup을 허용해 구현을 빠르게 유지하는 편이 이득이다 ── 이것이 수십 년 동안 쓰여 온 조건 변수의 설계 판단입니다.

4. Windows에서는 어느 계층에서 나타나는가

이 성질은 Windows 동기 프리미티브의 어느 계층을 쓰더라도 모습을 드러냅니다. 자신이 어느 계층의 API로 쓰더라도 피할 수 없다는 감각을 갖기 위해, 대표적인 계층을 확인해 둡니다.

Win32의 조건 변수(CONDITION_VARIABLE)는 앞에서 말한 대로, SleepConditionVariableCS / SleepConditionVariableSRW 문서에 spurious wakeup과 stolen wakeup 양쪽이 명시되어 있고, while 루프로 predicate를 다시 확인할 것이 요구됩니다.2 공식 사용 예(생산자·소비자 큐)도 대기를 while 루프로 씁니다.8

더 낮은 계층의 WaitOnAddress는 「지정 주소의 값이 바뀌기를 기다린다」는, 조건 변수보다 원시적인 대기 API입니다(Windows 8 이후). 이 최하층에 가까운 API조차 문서는 「주소가 시그널되었을 때 반환하는 것은 보장되지만, 그 밖의 이유로 반환하는 것도 허용된다」고 명시하고, 일찍 깨어나는 예로 저메모리 상태·같은 주소에 대한 과거 wake의 포기·checked 빌드에서의 실행을 듭니다. 그래서 문서의 사용 예 자체가 「값을 다시 비교하는 while 루프」 형태입니다.9

C++의 std::condition_variable도 같습니다. MSVC 문서는 predicate 없는 wait에 대해 「notify_one / notify_all 호출로 시그널될 때까지 블록한다. spurious하게 깨어날 수도 있다」고 쓰고, predicate를 받는 wait(lock, pred)가 사실상 다음 코드를 실행한다고 설명합니다.5

while (!Pred())
    wait(Lck);

즉 C++에서 권장하는 predicate 있는 wait란, 이 글에서 설명하는 「while로 감싼다」를 라이브러리가 대신해 주는 형태에 다름 아닙니다. cppreference도 마찬가지로, predicate 없는 wait는 spurious하게 해제될 수 있다고 명시합니다.4

.NET의 Monitor.Wait / Pulse는 대기 큐와 레디 큐라는 독자 큐 구조를 갖지만, 규율은 바뀌지 않습니다. Pulse / PulseAll로 깨어난 스레드는 레디 큐로 옮겨지고, 락을 다시 잡은 순서대로 Wait에서 반환합니다. 락을 다시 잡을 때까지 다른 스레드가 조건을 소비할 수 있는 점은 Win32와 같고, 문서도 「깨어난 스레드는 대기에 들어간 원인이 된 조건을 다시 평가하고, 필요하면 한 번 더 Wait를 호출한다」는 쓰임새를 전제로 적혀 있습니다.610

어느 계층에서도 predicate 재확인이 요구된다C++의 std::condition_variable, .NET의 Monitor, Win32의 CONDITION_VARIABLE, 저수준 WaitOnAddress의 어느 계층에서도, 공식 문서가 깨어난 뒤의 조건 재확인을 요구한다C++ std::condition_variable깨어나면 조건을 다시 확인한다(while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

그림 3: 언어나 프레임워크를 바꿔도, 대기 프리미티브의 계층에서는 어디서나 「깨어난 뒤의 재확인」이 공식으로 요구된다.

5. 올바른 대기 방법 ── while과 predicate로 쓴다

여기서부터는 구현입니다. 원칙은 세 가지뿐입니다.

  1. 기다리는 대상을 「알림」이 아니라 「상태(predicate)」로 둡니다. 「깨어났는가」가 아니라 「큐가 비어 있지 않은가」「플래그가 설정되었는가」처럼, 락으로 보호된 공유 상태를 조건으로 삼습니다.
  2. wait는 반드시 조건의 while 루프 안에 둡니다. 깨어날 때마다 조건을 확인하고, 불성립이면 다시 잠듭니다.
  3. 조건의 갱신과 확인은 같은 락 안에서 합니다. 알리는 쪽은 상태를 갱신한 뒤에 알립니다.
올바른 대기 루프의 흐름락을 잡아 조건을 확인하고, 불성립이면 락을 놓고 잠들며, 깨어나면 락을 다시 잡고 다시 조건 확인으로 돌아간다. 성립했을 때만 락을 쥔 채로 처리로 진행한다아니오예락을 잡는다조건은 성립하는가?wait(락을 놓고 잠든다)깨어난다(락을 다시 잡는다)락을 쥔 채로 처리한다

그림 4: 올바른 대기는 루프이며, 조건 확인과 처리 사이에 틈이 없다(둘 다 락을 쥔 채로 이루어진다).

이 형태에는 놓치기 쉬운 이점이 있습니다. while 루프를 빠져나온 시점에, 락을 쥔 채로 「조건이 성립해 있다」는 것이 확정되어 있다는 점입니다. spurious wakeup 대책의 루프가, 그대로 「조건 확인과 처리 사이에 경합의 틈이 없다」는 보장이 됩니다.

Win32(C)에서의 기본형

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // cs로 보호되는 공유 상태

// 시작할 때 한 번만 초기화한다(정적 초기화라면 cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// 대기 쪽(소비자)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // if가 아니라 반드시 while
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// 여기에서는 락을 쥐고 있으며, queueCount > 0이 보장된다
--queueCount;
LeaveCriticalSection(&cs);

// 알리는 쪽(생산자)
EnterCriticalSection(&cs);
++queueCount;                                    // 상태 갱신은 락 안에서
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // 알림은 락 해제 후여도 된다

알림(WakeConditionVariable)은 락 안에서든 밖에서든 호출할 수 있지만, 문서는 컨텍스트 스위치를 줄이려면 락을 해제한 뒤에 깨우는 편이 보통은 낫다고 합니다.1 한편 상태 갱신 그 자체(++queueCount)는 반드시 락 안에서 합니다. 이 둘을 혼동하지 마십시오.

C++에서의 기본형 ── predicate를 받는 wait를 기본값으로

std::mutex m;
std::condition_variable cv;
std::queue<Item> q;

// 대기 쪽
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // 내부에서 while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();

// 알리는 쪽
{
    std::lock_guard<std::mutex> lk(m);
    q.push(std::move(item));
}
cv.notify_one();

predicate를 받는 wait가 루프를 대신하므로, 손으로 쓴 while은 필요 없습니다. 손으로 쓴 루프가 남아 있는 기존 코드를 고칠 때도, while (q.empty()) cv.wait(lk);는 올바른 형태이므로, 서둘러 바꿀 필요는 없습니다. 잘못된 것은 if (q.empty()) cv.wait(lk);뿐입니다.

C#에서의 기본형

private readonly object _gate = new();
private readonly Queue<Item> _queue = new();

// 대기 쪽
lock (_gate)
{
    while (_queue.Count == 0)          // if가 아니라 반드시 while
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// 알리는 쪽
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // Monitor의 Pulse는 락 안에서만 호출할 수 있다
}

Monitor.Wait / Pulse / PulseAll은 락(lock 블록) 안에서만 호출할 수 있다는 점이 Win32와 다릅니다. 락 밖에서 호출하면 SynchronizationLockException이 됩니다.10

타임아웃이 있는 대기 ── 남은 시간은 마감에서 계산한다

타임아웃을 두고 기다릴 때, 루프마다 「같은 타임아웃 값」을 넘기면, spurious wakeup이 날 때마다 대기 시간이 늘어납니다. 마감 시각을 먼저 정하고, 남은 시간을 다시 계산하는 것이 올바른 형태입니다.

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // 타임아웃(조건은 불성립인 채)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // 타임아웃 이외의 실패는 대기를 그만두고 빠져나간다
    }
    // ERROR_TIMEOUT은 while의 조건 판정과 마감 검사로 최종 확인한다
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
타임아웃이 있는 대기의 올바른 흐름먼저 마감 시각을 정하고, 깨어날 때마다 조건과 마감을 확인하며, 아직이면 남은 시간을 다시 계산해 대기로 돌아간다예아니오예아니오마감 시각을 정한다조건은 성립하는가?처리로 진행한다마감을 지났는가?타임아웃 처리남은 시간을 계산해 wait

그림 5: 타임아웃이 있는 대기는 「같은 대기 시간」을 다시 넘기는 것이 아니라, 마감 시각을 기준으로 남은 시간을 다시 계산한다.

C++라면 이 계산까지 wait_until(절대 시각)+predicate 오버로드에 맡길 수 있습니다. 타임아웃으로 반환한 경우에도 predicate의 최종 값을 돌려주므로, 「시간이 끝났는지, 제때 왔는지」의 판정도 predicate 기준으로 할 수 있습니다.5

6. 하지 말아야 할 패턴 모음

if로 한 번만 확인한다. 이 글의 주역입니다. spurious wakeup이나 stolen wakeup이 일어나는 순간, 조건 불성립인 채 처리가 진행됩니다. 빈 큐에서 꺼내기, 초기화되지 않은 데이터 참조, 이중 해제 ── 증상은 「가끔만 나오는 크래시·데이터 손상」이 됩니다.

조건의 확인이나 갱신을 락 밖에서 한다. 대기 쪽이 락 밖에서 조건을 보고 「아직이다」라고 판단한 뒤, wait에 들어가려는 그 틈에, 알리는 쪽이 상태를 갱신하고 알림을 보내면, 알림은 대기자가 없는 조건 변수를 향해 발사되어 사라집니다. 대기 쪽은 그다음 wait에 들어가, 다시는 오지 않을 알림을 기다립니다. 이것이 lost wakeup(놓친 깨어남)이며, spurious wakeup의 대칭입니다. 조건 변수의 대기 API가 「락을 풀고 잠드는 일을 원자적으로 수행」하도록 설계된 것은, 바로 이 틈을 닫기 위해서입니다.1 락 규율을 지키는 한 일어나지 않습니다.

lost wakeup이 일어나는 시계열대기 쪽이 락 밖에서 조건을 확인한 뒤 wait에 들어가기까지의 틈에, 알리는 쪽이 상태를 갱신하고 알리면, 알림은 대기자가 없는 조건 변수로 보내져 사라지고, 대기 쪽은 다시는 오지 않을 알림을 기다린다알리는 쪽대기 쪽알리는 쪽대기 쪽이때 대기자는 없다알림은 이미 사라져 깨어나지 않는다락 밖에서 조건을 확인한다(불성립)상태를 갱신하고 알린다wait에 들어간다

그림 6: 락 밖에서 조건을 확인하면, 확인과 wait의 틈으로 알림이 그냥 지나가는 lost wakeup이 일어난다.

조건 변수의 「일시적 알림」을 이벤트의 pulse로 재현한다. 이벤트(CreateEvent + SetEvent) 그 자체는 안티패턴이 아닙니다. 소비자 하나가 큐가 빌 때까지 처리하는 구성의 깨어남 신호, 한 번 세우면 내리지 않는 정지 지시(수동 리셋 이벤트)는 이벤트의 올바른 쓰임새이며, WaitForMultipleObjects로 다른 대기 대상과 합류하고 싶을 때나 프로세스 경계를 넘을 때는, 프로세스 간에 공유할 수 없는 사용자 모드 객체인 조건 변수 쪽이 쓸 수 없습니다.1 위험한 것은, 조건 변수의 「그 순간에 기다리는 스레드만 깨우고, 상태를 남기지 않는다」는 일시적 알림을 이벤트 조작으로 재현하려는 일입니다. 그 발상은 거의 반드시 다음의 PulseEvent에 도달합니다.

PulseEvent를 쓴다. 수동 리셋 이벤트에서 「지금 기다리는 모두를 깨우고, 바로 non-signaled로 되돌린다」는 API이지만, Microsoft 자신이 문서에서 「이 함수는 신뢰할 수 없으므로 써서는 안 된다. 주로 하위 호환을 위해 존재한다. 대신 조건 변수를 쓰라」고 분명히 말합니다. 이유는, 대기 중인 스레드가 커널 모드 APC에 의해 일시적으로 대기 상태에서 빠졌다가, APC 완료 후 대기로 돌아올 수 있기 때문입니다. 그 한순간에 PulseEvent가 호출되면, 그 스레드는 「호출된 순간에 대기하고 있던 자」에 포함되지 않아 깨어나지 않습니다.7 커널 APC는 OS가 내부적으로 쓰는 것이며, 앱 쪽에서 제어할 수 없습니다.11 이 문제는 정적 분석 경고(C28648)이기도 합니다.12 spurious wakeup이 「추가로 깨어나는」 문제라면, 이쪽은 「깨어나야 하는데 잠든 채로 있는」 문제이며, while 루프로도 막을 수 없습니다 ── 알림 자체가 사라져 있기 때문입니다.

상태를 갱신하기 전에, 락을 쥐지 않고 알림만 먼저 보낸다. 상태가 아직 낡은 시점에 WakeConditionVariable을 호출하고, 그다음 락을 잡아 상태를 갱신하는 ── 그 순서라면, 깨어난 스레드가 조건을 확인한 시점에는 아직 불성립이라 다시 잠듭니다. 다음 알림이 오지 않으면 그대로입니다. 같은 락을 쥔 채로 「알림→갱신→해제」 순으로 쓴 경우에는, 대기 쪽은 락을 다시 잡을 때까지 조건을 확인할 수 없으므로 실제 피해는 없습니다. 그렇다고 해도, 읽는 이가 매번 이 안전 조건을 검증하지 않아도 되도록, 「락 안에서 상태를 갱신하고, 그다음에 알린다」는 순서로 통일해 두는 편이 안전합니다.

7. 맞닥뜨렸을 때의 조사 방법

spurious wakeup이 얽힌 버그는 「드물게만 나온다」는 것이 특징입니다. 증상에서 거꾸로 따라가면, 다음 두 유형으로 나뉩니다.

유형 1: 조건 불성립인 채 처리가 진행된다. 빈 큐에서 꺼내 예외·크래시, 처리 결과 누락 등. 의심할 것은 predicate 없는 대기입니다. 코드 리뷰에서는 기계적으로 훑을 수 있습니다 ── cv.wait(의 인수가 하나뿐인 곳, SleepConditionVariableCS / Monitor.Wait를 감싼 것이 while이 아니라 if인 곳을 검색합니다. 이 검사는 재현을 기다리지 않아도 되는, 비용 대비 효과가 가장 큰 조치입니다.

유형 2: 깨어나야 할 스레드가 깨어나지 않는다(행). 의심할 것은 lost wakeup(락 밖에서의 조건 확인, 상태 갱신 전의 락 밖 알림)과 PulseEvent입니다. 행 중인 프로세스에서 덤프를 떠, 각 스레드의 스택을 보면, 어느 스레드가 어느 대기 API에서 멈춰 있는지는 특정할 수 있습니다. 그다음 「그 알림은 누가·어느 순서로 보내기로 되어 있었는가」를 코드에서 따라갑니다.

증상에서 가르는 흐름조건 불성립인 채 처리가 진행되는 증상이면 predicate 없는 대기를 코드 검색으로 훑고, 스레드가 깨어나지 않는 증상이면 덤프로 대기 지점을 특정한 뒤 lost wakeup과 PulseEvent를 의심한다드물게만 나오는 버그조건 불성립인 채 처리가 진행된다깨어나야 할 스레드가 깨어나지 않는다predicate 없는 대기를 코드 검색덤프로 대기 중인 스레드를 특정if를 while·predicate wait로 고친다lost wakeup과 PulseEvent를 의심한다

그림 7: 증상이 「너무 진행됨」인지 「깨어나지 않음」인지에 따라, 의심할 지점과 조사 수단이 갈린다.

재현하고 싶을 때는 경합의 창을 넓히는 것이 정석입니다. 스레드 수를 물리 코어 수보다 많게 하고, 대기와 알림 사이에 의도적인 Sleep을 끼워 넣고, 디버그 빌드와 릴리스 빌드 양쪽에서 돌리는 식으로 타이밍 편차를 늘립니다. 「predicate 없는 wait를 고쳤더니 재현되지 않게 되었다」를 확인할 때도, 같은 스트레스를 가한 상태에서 비교하십시오.

8. 정리 ── 체크리스트

  • wait에서 반환하는 경로는 「정규 알림·spurious wakeup·stolen wakeup」 세 가지이며, 호출 쪽은 구별할 수 없습니다. 그래서 대기는 반드시 조건의 while 루프로 씁니다.
  • spurious wakeup은 Win32·C++·POSIX가 성능과의 트레이드오프로 의도적으로 허용한 사양이며, OS 수정이나 라이브러리 교체로는 사라지지 않습니다. .NET의 Monitor.Wait에는 이유 없는 깨어남은 상정되어 있지 않지만, stolen wakeup과 타임아웃이 있는 이상, 같은 while 규율이 필요해집니다.
  • C++는 predicate를 받는 wait(lock, pred)를 기본값으로 합니다. 루프는 라이브러리가 대신합니다.
  • 조건의 갱신·확인은 같은 락 안에서 합니다. 알림은 「상태를 갱신한 뒤에」 보냅니다. Win32/C++의 알림은 락 해제 후여도 되지만, C#의 Pulse는 락 안에서만입니다.
  • 타임아웃이 있는 대기는 마감 시각을 정해 남은 시간을 다시 계산합니다. C++라면 wait_until + predicate입니다.
  • 조건 변수의 일시적 알림을 이벤트의 pulse로 재현하지 않습니다. 특히 PulseEvent는 공식이 「쓰지 말고, 대신 조건 변수를」라고 분명히 말합니다. 이벤트 자체는 정지 지시·WaitForMultipleObjects와의 합류·프로세스 간 동기에서는 지금도 올바른 도구입니다.
  • 리뷰에서는 「predicate 없는 wait」「if + wait」를 기계적으로 검색합니다. 드물게만 나오는 버그를, 재현을 기다리지 않고 없앨 수 있습니다.

spurious wakeup은, 이름의 이상함과 달리, 대처는 한 줄의 키워드 ── if를 while로 바꾼다 ── 로 모아집니다. 그리고 그 한 줄 뒤에는 「정확한 알림은 비싸므로, 확인은 기다리는 쪽의 책임으로 둔다」는, 조건 변수라는 도구의 설계 사상이 놓여 있습니다. 구조까지 이해해 두면, 언어나 프레임워크가 바뀌어도 같은 규율을 망설이지 않고 적용할 수 있을 것입니다.

관련 기사

관련 상담 영역

합동회사 코무라소프트에서는 멀티스레드 코드의 설계 리뷰, 「가끔만 재현되는」 크래시·행의 원인 조사(덤프 분석), 레거시 동기 코드(이벤트·PulseEvent 의존 등)를 조건 변수 기반으로 고치는 일을 다루고 있습니다. 현상의 원인 파악부터여도 괜찮으니, 부담 없이 상담해 주십시오.

참고 링크

  1. Microsoft Learn, Condition Variables. 조건 변수가 락을 풀고 대기로 들어가는 일을 원자적으로 수행하는 사용자 모드 객체라는 점, spurious wakeup(명시적 깨어남과 묶이지 않는 깨어남)과 stolen wakeup(깨어난 스레드보다 다른 스레드가 먼저 실행되는 일)이 있으므로 대기에서 반환하면 predicate를 while 루프로 다시 확인해야 한다는 점, 알림은 락 안에서든 밖에서든 할 수 있지만 컨텍스트 스위치를 줄이려면 락 해제 후 깨우는 편이 낫다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 지정한 크리티컬 섹션의 해제와 조건 변수에서의 대기를 원자적으로 수행한다는 점, 깨어난 스레드는 크리티컬 섹션을 다시 잡은 뒤에 반환한다는 점, 타임아웃 시 ERROR_TIMEOUT이 반환된다는 점, spurious wakeup과 stolen wakeup이 있으므로 대기에서 반환하면 predicate를(보통은 while 루프로) 다시 확인해야 한다는 점에 대해. ↩ ↩2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. pthread_cond_wait / pthread_cond_timedwait에서의 spurious wakeup이 일어날 수 있다는 점, wait에서 반환했다는 것은 predicate 값에 대해 아무것도 의미하지 않으므로 predicate를 다시 평가해야 한다는 점, Rationale에서 「딱 하나만 깨우는」 구현이 특히 멀티프로세서에서 조건 변수 연산을 느리게 할 수 있다는 점, spurious wakeup의 허용이 predicate 확인 루프를 강제해 애플리케이션을 튼튼하게 만든다고 적혀 있다는 점에 대해. ↩ ↩2 ↩3

  4. cppreference.com, std::condition_variable::wait. predicate 없는 wait가 spurious wakeup으로 해제될 수 있다는 점, predicate 오버로드가 while (!pred()) wait(lock);과 동등하며, 알림이나 spurious wakeup마다 락을 다시 잡고 predicate를 확인하는 루프로 정의되어 있다는 점에 대해. ↩ ↩2

  5. Microsoft Learn, condition_variable Class. predicate 없는 wait가 notify_one / notify_all로 해제되는 외에 spurious하게 깨어날 수도 있다고 명시되어 있다는 점, predicate를 받는 wait(lock, pred)가 사실상 while (!Pred()) wait(Lck);를 실행한다는 점, wait_for / wait_until에도 같은 성질과 predicate 오버로드가 있다는 점에 대해. ↩ ↩2 ↩3

  6. Microsoft Learn, Monitor.Wait Method. Wait가 락을 해제하고 대기 큐에 들어간다는 점, Pulse / PulseAll로 깨어난 뒤에도 락을 다시 잡을 때까지 반환하지 않는다는 점, 깨어난 스레드는 대기에 들어간 원인이 된 조건을 다시 평가하고 필요하면 한 번 더 Wait를 호출한다는 쓰임새가 상정되어 있다는 점에 대해. ↩ ↩2

  7. Microsoft Learn, PulseEvent function (winbase.h). 대기 중인 스레드가 커널 모드 APC로 일시적으로 대기 상태에서 빠졌다가 APC 완료 후 돌아올 수 있으며, 그 사이에 PulseEvent가 호출되면 그 스레드는 해제되지 않는다는 점, 이 때문에 PulseEvent는 신뢰할 수 없어 새 애플리케이션에서 써서는 안 되며 대신 조건 변수를 써야 한다는 점에 대해. ↩ ↩2

  8. Microsoft Learn, Using Condition Variables. 크리티컬 섹션 하나와 조건 변수 둘(BufferNotEmpty·BufferNotFull)로 생산자·소비자 큐를 구현하는 공식 샘플에 대해. 대기는 predicate를 확인하는 루프 안에서 이루어진다. ↩

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). 주소의 값이 바뀌기를 기다리는 함수가 시그널 시 반환하는 것은 보장되지만 다른 이유로 반환하는 것도 허용된다는 점, 일찍 깨어나는 예로 저메모리 상태·같은 주소에 대한 이전 wake의 포기·checked 빌드에서의 실행이 나온다는 점, 그래서 반환하면 값을 다시 비교해야 하며 공식 샘플 자체가 while 루프라는 점에 대해. ↩

  10. Microsoft Learn, Monitor.PulseAll Method. PulseAll이 대기 큐의 스레드를 레디 큐로 옮기고, 락이 해제되면 레디 큐의 다음 스레드가 락을 잡는다는 점, Pulse / PulseAll / Wait는 동기 블록 안에서만 호출할 수 있다는 점에 대해. ↩ ↩2

  11. Microsoft Learn, Waits and APCs. 커널 APC가 선점적으로 실행되어, 대기 API를 반환시키지 않은 채 시스템이 내부적으로 대기를 중단·재개한다는 점, 그 사이에 KePulseEvent 같은 일시적 시그널을 놓칠 수 있다는 점에 대해. ↩

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. 정적 분석이 PulseEvent 사용에 경고를 낸다는 점, APC로 대기에서 빠져 있던 스레드는 해제되지 않고 영원히 행할 수 있다는 점, SetEvent나 다른 동기 객체로의 교체 지침에 대해. ↩

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

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

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

자주 묻는 질문

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

spurious wakeup은 OS나 라이브러리의 버그입니까?
버그가 아니라 사양으로 명시한 동작입니다. Win32의 SleepConditionVariableCS, C++의 std::condition_variable, POSIX의 pthread_cond_wait는 모두 공식 문서나 규격이 「알림과 묶이지 않은 깨어남이 일어날 수 있다」고 적습니다. 이를 금지하는 구현도 이론상 가능하지만, 조건 변수의 모든 연산(특히 멀티프로세서에서의 알림)이 느려지므로, 「대기 쪽이 조건을 다시 확인하면 올바르게 동작한다」는 판단으로 허용되어 있습니다. 따라서 대처는 OS 수정을 기다리는 일이 아니라, wait를 반드시 while 루프(또는 predicate를 받는 wait)로 쓰는 것입니다.
wait를 while로 감싸면 성능이 떨어집니까?
실무에서는 무시할 수 있습니다. while로 감쌀 때 늘어나는 일은 깨어날 때마다 하는 조건 검사 한 번뿐이며, 락을 쥔 상태의 가벼운 비교입니다. spurious wakeup 자체도 드물게만 일어나므로, 추가 루프가 도는 것은 예외적인 경우에 한정됩니다. 반면 if로 남겨 둔 대가는 조건이 성립하지 않은 채 처리가 진행되는 「드물게만 재현되는 버그」이며, 비교가 되지 않습니다. 조건 변수의 대기 비용에서 진짜로 영향을 주는 것은 락 경합과 알림 빈도이며, while의 유무가 아닙니다.
C++의 predicate를 받는 wait를 쓰면 spurious wakeup을 의식하지 않아도 됩니까?
대기 루프에 관해서는 그렇습니다. cv.wait(lock, pred)는 사실상 while (!pred()) wait(lock);을 실행하므로, spurious wakeup도 stolen wakeup도 자동으로 흡수됩니다. 새 C++ 코드에서는 predicate 오버로드를 기본값으로 해야 합니다. 다만 predicate가 참조하는 공유 상태의 갱신을 같은 뮤텍스로 보호하는 것, 알리는 쪽이 상태를 갱신한 뒤에 notify를 호출하는 것은 여전히 직접 지켜야 합니다. predicate를 받는 wait는 루프를 대신할 뿐, 락 규율까지 대신해 주지는 않습니다.
C#의 Monitor.Wait에서도 같은 문제가 일어납니까?
일어납니다. Monitor.Wait에서 기다리던 스레드는 Pulse/PulseAll로 깨어난 뒤, 락을 다시 잡고 나서 Wait를 빠져나오지만, 그 사이에 다른 스레드가 먼저 락을 잡아 조건을 소비했을 수 있습니다(stolen wakeup). Microsoft 문서도, 깨어난 스레드는 대기에 들어간 원인이 된 조건을 다시 평가하고, 필요하면 한 번 더 Wait를 호출한다는 쓰임새를 전제로 적혀 있습니다. 따라서 C#에서도 while (!조건) Monitor.Wait(gate);가 기본입니다. lock 문 안에서만 Wait/Pulse를 호출할 수 있다는 점은 Win32와 다른 제약입니다.
WaitForSingleObject로 이벤트를 기다릴 때도 spurious wakeup이 있습니까?
보통의(alertable이 아닌) 대기에서는 WAIT_OBJECT_0이 반환되는 것은 객체가 실제로 signaled 상태가 되었을 때이며, 조건 변수와 같은 「이유 없는 깨어남」은 일어나지 않습니다. 다만 「이벤트가 signaled가 되었다」는 것과 「자기 앱의 조건이 성립해 있다」는 것은 별개입니다. 여러 소비자가 같은 이벤트로 깨어나는 설계라면, 먼저 락을 잡은 스레드가 조건을 소비하므로, 결국 깨어난 뒤의 조건 확인이 필요합니다. 또한 조건 변수처럼 「그 순간의 대기자만 깨우는」 일시적 알림을 이벤트로 재현하려는 설계는 PulseEvent의 신뢰성 문제로 가기 쉬우므로, 프로세스 안에서 상태 성립을 기다리는 용도에는 조건 변수를 쓰는 편이 안전합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기