「큐에 데이터를 넣고 기다리는 워커 스레드를 깨운다. 여섯 달 동안 잘 돌다가, 어느 날 빈 큐를 읽으려다 크래시했다」. 「알림은 보내고 있는데, 가끔 스레드가 영영 깨어나지 않는다」── 멀티스레드의 랑데부는 돌아가는 것처럼 보이고, 드물게만 나타나는 버그의 온상입니다. 이런 조사는 종종 조건 변수의 wait를 if로 감싼 코드에 도착합니다. 그리고 그 뒤에는 스퓨리어스 웨이크업── 알림을 받지 않고 wait에서 돌아오는 현상이 있습니다.
「아무도 알리지 않았는데 깨어난다」는 구현의 결함처럼 들리지만, Win32, C++, POSIX가 모두 문서나 표준에 명시한 동작이며, .NET의 Monitor도 「한 번 깨어나면 조건을 다시 확인한다」는 가정 위에 설계되어 있습니다. 왜 그 동작이 허용되는가. Windows에서는 어느 계층에서 일어나는가. 그리고 맞지 않게 대기를 어떻게 쓰는가. Windows에서 업무 앱과 설비 제어 소프트웨어를 쓰는 개발자를 대상으로, 이 글은 스퓨리어스 웨이크업의 정체를 1차 정보에서 풀고, 올바른 대기를 Win32(C), C++, C#으로 압축합니다.
1. 먼저 결론
- 조건 변수의
wait는 알림이 오지 않아도 돌아올 수 있습니다. Win32 공식 문서는 조건 변수가 스퓨리어스 웨이크업(명시적 기상에 묶이지 않은 기상)과 가로채인 기상(깨어난 스레드보다 다른 스레드가 조건을 먼저 소비)의 대상이라고 말합니다.1 - 따라서 대기는 항상 「while 루프 + 조건의 재확인」으로 써야 합니다.
if로 한 번 확인하고wait하는 코드는 돌아가는 것처럼 보이고, 드물게만 재현되는 버그를 품습니다.12 - 이것은 Windows만의 괴짜가 아니며, POSIX와 C++ 표준도 같은 말을 합니다. 「절대 가짜로 깨지 않는」 구현은 모든 조건 변수 연산을 느리게 하므로, 대기 측이 다시 확인한다는 가정 위에서 기상이 허용됩니다.34
- C++에서는 술어 형태
wait(lock, pred)가 라이브러리가 루프를 대신 수행합니다. 그 형태는 사실상while (!pred()) wait(lock);을 돕니다. 새 코드의 기본입니다.5 - C#의
Monitor.Wait에도 같은 규율이 필요합니다. 깨어난 뒤 락을 다시 획득하기까지의 간격에서 조건이 소비될 수 있으므로,while에서 조건을 다시 확인하고Wait로 돌아갑니다.6 - 조건의 갱신과 확인은 같은 락 아래에서 합니다. 락 밖에서 조건을 보고
wait에 들어가면, 그 틈으로 알림이 지나가 버려── 잃어버린 기상(lost wakeup)이 됩니다.1 - 조건 변수의 「지금 기다리는 사람을 깨운다」는 일시적 알림을 이벤트의 pulse로 재현하지 마십시오. 특히
PulseEvent는 커널 모드 APC가 대기를 잠깐 들어 올리는 순간에 알림을 놓칠 수 있고, Microsoft 자신도 「신뢰할 수 없으니 쓰지 말고, 대신 조건 변수를 쓰라」고 분명히 말합니다.7
이하는 이 결론을 받치는 메커니즘을 순서대로 따라갑니다.
2. 스퓨리어스 웨이크업이란 무엇인가 ── 깨어났다고 조건이 성립한 것은 아니다
조건 변수는 「어떤 조건이 성립할 때까지 스레드를 잠들게 하고, 성립하면 깨운다」는 동기화 원시입니다. Win32에서는 CONDITION_VARIABLE 구조와 SleepConditionVariableCS / SleepConditionVariableSRW(대기), WakeConditionVariable / WakeAllConditionVariable(알림)입니다. 대기 API는 쥐고 있는 락(크리티컬 섹션 또는 SRW 락)을 원자적으로 풀고 잠들며, 기상 때는 락을 다시 획득한 뒤에 돌아옵니다.1
질문은 「wait에서 돌아왔다」는 사실이 실제로 무엇을 뜻하는가입니다. 순진하게는 「알림이 왔다 = 조건이 성립한다」고 생각하고 싶지만, 현실에서 wait가 돌아오는 경우는 세 가지입니다.
| 경우 | 알림 | 반환 때의 조건 |
|---|---|---|
| 진짜 기상 | 있음 | 종종 만족되지만, 보장되지 않음 |
| 스퓨리어스 웨이크업 | 당신에게 향한 것 없음 | 여전히 불만족 |
| 가로채인 기상 | 있음 | 다른 스레드가 먼저 소비, 불만족 |
flowchart TB
accTitle: wait가 돌아오는 세 경우
accDescr: 조건 변수 대기는 진짜 알림뿐 아니라 알림 없는 스퓨리어스 웨이크업과 알림은 왔지만 조건이 먼저 소비된 가로채인 기상에서도 돌아올 수 있으므로, 모든 경우 조건을 다시 확인해야 한다
w["wait에서 돌아왔다"] --> a["진짜 알림"]
w --> b["스퓨리어스 웨이크업(알림 없음)"]
w --> c["가로채인 기상(조건이 이미 소비됨)"]
a --> r["조건을 다시 확인한 뒤 진행한다"]
b --> r
c --> r
그림 1: wait에서 돌아오는 경로는 세 가지이고, 호출자는 어느 것을 탔는지 알 수 없으므로, 항상 조건을 다시 확인해야 한다.
스퓨리어스 웨이크업은 이 두 번째 경우── 대기 API가 당신을 깨우기 위한 명시적 알림에 묶이지 않고 돌아오는 현상입니다. 시스템 어디에서도 WakeConditionVariable이 한 번도 호출되지 않은 상황에만 한정되지 않습니다. 예를 들어 알림이 짧은 폭주로 오는 고부하에서는 구현이 여분의 대기 스레드를 한꺼번에 깨울 수 있고, 대응하는 알림이 없는 쪽에서 보면 그것도 스퓨리어스 웨이크업입니다. Microsoft Learn의 조건 변수 페이지는 이것을 분명히 말합니다. “Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.”1
중요한 점은 호출자가 세 경우 중 어느 것으로 돌아왔는지 알 수 없다는 것입니다. 알 수 없으면 전략은 하나뿐입니다. 돌아올 때마다 기다리던 조건 자체를 확인하고, 성립하지 않으면 다시 잠든다. 그것이 「wait를 while로 감싼다」는 철칙의 진짜 내용입니다. 반대로 말하면, 그 규칙을 지키는 한, 세 경우 중 어느 것이 깨웠든 코드는 올바릅니다.
3. 사양이 허용하는 이유 ── 정확한 알림은 비싸다
「알림 없이 깨어나는 것은 그냥 엉성한 구현 아닌가?」는 타당한 질문입니다. 실제로 가짜로 깨지 않는 구현을 만드는 것은 이론적으로 가능합니다. 그런데도 POSIX, Windows, C++ 표준은 모두 「일어날 수 있다」 쪽에 섰습니다. 이유는 POSIX(The Open Group Base Specifications)의 pthread_cond_wait Rationale에 솔직히 적혀 있습니다.3
첫 번째 이유는 성능입니다. 「정확히 스레드 하나를 확실히 깨운다」는 알림을 엄격히 구현하려 하면, 특히 멀티프로세서에서 모든 조건 변수 연산에 추가 동기화 비용이 붙습니다. 알림과 기상 사이에는 스케줄러가 있고, 인터럽트와 선점의 타이밍에 따라 「깨우려던 스레드보다 다른 스레드가 먼저 도는」 일을 피할 수 없습니다. 그것을 완전히 봉인하는 비용을 모두에게 물리는 것은, 「가끔 여분으로 깨워도 된다」를 받아들이는 것보다, 조건 변수를 빠르게 유지하는 데 더 나쁩니다.
두 번째 이유는 이 트레이드오프가 앱을 깨뜨리지 않고── 오히려 더 튼튼하게 만든다는 관찰입니다. 스퓨리어스 웨이크업이 허용되므로, 올바른 코드는 항상 술어(기다리던 조건)를 확인하는 루프를 씁니다. POSIX Rationale은 이 루프를 강제하면 코드가 자기 문서화되고 더 튼튼해진다고 말합니다.3 루프가 있으면 알림의 의미는 「조건이 성립한다는 보장」에서 「조건이 바뀌었을 수 있다는 힌트」로 격하되고, 대기 측은 알리는 쪽의 소박한 설계 변경(너무 많이 깨우기, 한꺼번에 깨우기 등)에 관대해집니다.
가로채인 기상은 더 구조적인 문제입니다. 알리는 쪽이 WakeConditionVariable을 호출한 뒤, 깨어난 스레드가 락을 다시 획득하고 wait에서 돌아오기까지는 항상 시간 틈이 있습니다. 그 간격에서 세 번째 스레드가 락을 잡으면 조건(큐의 내용 등)을 먼저 소비할 수 있습니다. 그것은 구현을 아무리 다듬어도 지울 수 없는 틈이며, 조건 변수라는 도구의 형태 자체에서 나옵니다.
sequenceDiagram
accTitle: 가로채인 기상의 타임라인
accDescr: 생산자가 큐에 항목 하나를 넣고 기다리는 소비자 A를 깨우지만, A가 락을 다시 획득하기 전에 소비자 B가 락을 얻어 그 하나를 가져가, A가 깰 때 큐는 비어 있다
participant A as 소비자 A(대기 중)
participant P as 생산자
participant B as 소비자 B
P->>P: 큐에 항목 하나를 넣는다
P->>A: WakeConditionVariable
Note over A: 깨어나 락을 다시 획득하기를 기다린다
B->>B: 락을 얻어 항목 하나를 가져간다
A->>A: 락을 다시 획득하고 wait에서 돌아온다
Note over A: 큐가 비어 있다(가로채임)
A->>A: while 루프에서 다시 확인하고 다시 기다린다
그림 2: 알림과 기상 사이의 시간 틈에서 세 번째 스레드가 조건을 소비하는 「가로채인 기상」은 어떤 구현에서도 일어날 수 있다.
즉 OS가 스퓨리어스 웨이크업을 완전히 없애도, 가로채인 기상이 있는 한 「깨어났다 = 조건이 성립한다」고 쓸 수는 없습니다. 대기 측의 재확인 루프는 어차피 필요하고, 그렇다면 스퓨리어스 웨이크업을 허용하고 구현을 빠르게 유지하는 편이 싸다── 그것이 조건 변수가 수십 년 동안 가져온 설계 판단입니다.
4. Windows에서는 어느 계층에서 나타나는가
이 성질은 Windows 동기화 원시의 어느 계층을 쓰든 얼굴을 냅니다. 어느 계층의 API를 쓰든 도망칠 수 없다는 감을 잡기 위해, 대표 계층을 봅니다.
Win32 조건 변수(CONDITION_VARIABLE)는 이미 말한 대로 SleepConditionVariableCS / SleepConditionVariableSRW에서 스퓨리어스 웨이크업과 가로채인 기상 모두의 대상이라고 문서화되어 있고, while 루프에서 술어를 다시 확인할 것이 요구됩니다.2 공식 사용 샘플(생산자–소비자 큐)도 대기를 while 루프 안에 씁니다.8
더 낮은 계층의 WaitOnAddress는 조건 변수보다 더 원시적인 대기 API입니다. 「주어진 주소의 값이 바뀔 때까지 기다린다」(Windows 8 이후). 이 거의 바닥 계층의 API조차 문서가 「주소가 신호되면 돌아오는 것은 보장되지만, 다른 이유로도 돌아올 수 있다」고 말하고, 일찍 깨는 예로 저메모리 상태, 같은 주소에 대한 이전 기상의 포기, checked 빌드 실행을 듭니다. 그래서 문서 자신의 사용 샘플도 「값을 다시 비교하는 while 루프」 형태입니다.9
C++의 std::condition_variable도 같습니다. MSVC 문서는 술어 없는 wait에 대해 「notify_one / notify_all 호출로 신호될 때까지 블록한다. 가짜로도 깰 수 있다」고 말하고, 술어 형태 wait(lock, pred)는 사실상 다음 코드를 돈다고 설명합니다.5
while (!Pred())
wait(Lck);
즉 C++에서 권장하는 술어 형태 wait는, 이 글이 말하는 「while로 감싼다」를 라이브러리가 대신해 주는 것 외에 아무것도 아닙니다. cppreference도 술어 없는 wait가 가짜로 언블록될 수 있다고 말합니다.4
.NET의 Monitor.Wait / Pulse는 대기 큐와 준비 큐라는 자체 큐 구조가 있지만, 규율은 바뀌지 않습니다. Pulse / PulseAll로 깨어난 스레드는 준비 큐로 옮겨지고, 락을 다시 획득할 수 있는 순서로 Wait에서 돌아옵니다. 락을 다시 획득하기 전의 간격에서 다른 스레드가 조건을 소비할 수 있는 점은 Win32와 같고, 문서도 「깨어난 스레드가 대기에 들어가게 한 조건을 다시 평가하고, 필요하면 다시 Wait를 호출한다」는 가정 위에 쓰여 있습니다.610
flowchart TB
accTitle: 모든 계층에서 술어를 다시 확인해야 한다
accDescr: C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE, 저수준 WaitOnAddress의 모든 계층에서 공식 문서가 기상 후 조건 재확인을 요구한다
cpp["C++ std::condition_variable"] --> rule["기상 때 조건을 다시 확인한다(while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
그림 3: 언어나 프레임워크를 바꿔도, 공식 요구는 모든 대기 원시 계층에서 같다. 깬 뒤에 다시 확인한다.
5. 올바르게 대기하는 방법 ── while과 술어로 쓴다
여기서부터는 구현입니다. 원칙은 셋뿐입니다.
- 기다리는 것을 「알림」이 아니라 상태(술어)로 둡니다. 조건은 락으로 보호되는 공유 상태입니다── 「큐가 비어 있지 않은가?」, 「플래그가 섰는가?」── 「내가 깨어났는가?」가 아닙니다.
wait는 항상 조건에 대한 while 루프 안에 둡니다. 깰 때마다 조건을 확인하고, 성립하지 않으면 다시 잠듭니다.- 조건의 갱신과 확인은 같은 락 아래에서 합니다. 알리는 쪽은 상태를 갱신한 뒤 알립니다.
flowchart TB
accTitle: 올바른 대기 루프의 흐름
accDescr: 락을 획득하고 조건을 확인한다. 성립하지 않으면 락을 풀고 잠든다. 기상하면 락을 다시 획득하고 조건 확인으로 돌아온다. 조건이 성립할 때만 락을 쥔 채로 진행한다
l["락을 획득한다"] --> c{"조건이 성립하는가?"}
c -->|"아니오"| s["wait(락을 풀고 잠든다)"]
s --> wk["기상(락을 다시 획득)"]
wk --> c
c -->|"예"| go["락을 쥔 채로 진행한다"]
그림 4: 올바른 대기는 루프이며, 조건 확인과 처리 사이에 틈이 없다(둘 다 락을 쥔 채로 일어난다).
이 형태에는 놓치기 쉬운 이점이 있습니다. while 루프를 나온 순간, 락을 쥔 채로 「조건이 성립한다」가 확립됩니다. 스퓨리어스 웨이크업을 막는 루프는, 그대로, 조건 확인과 처리 사이에 경쟁 조건의 틈이 없다는 보장입니다.
Win32(C)의 기본 형태
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // shared state protected by cs
// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) { // always while, never if
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);
// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount; // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // notifying after releasing the lock is fine
알림(WakeConditionVariable)은 락 안에서도 밖에서도 호출할 수 있지만, 문서는 컨텍스트 스위치를 줄이려면 락을 푼 뒤에 깨우는 편이 보통 더 낫다고 말합니다.1 한편 상태 갱신 자체(++queueCount)는 항상 락 아래에서 해야 합니다. 둘을 혼동하지 마십시오.
C++의 기본 형태 ── 술어 wait를 기본으로
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// Notifier
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
술어 형태 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();
// Waiter
lock (_gate)
{
while (_queue.Count == 0) // always while, never if
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// Notifier
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor.Pulse can only be called inside the lock
}
Monitor.Wait / Pulse / PulseAll은 락(lock 블록) 안에서만 호출할 수 있으며, 이는 Win32와 다릅니다. 락 밖에서 호출하면 SynchronizationLockException이 납니다.10
타임아웃이 있는 대기 ── 기한에서 남은 시간을 계산한다
타임아웃을 두고 기다릴 때, 루프 반복마다 「같은 타임아웃 값」을 넘기면 스퓨리어스 웨이크업이 날 때마다 대기가 늘어납니다. 올바른 형태는 먼저 기한을 고정하고 남은 시간을 다시 계산하는 것입니다.
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // timeout (condition still unsatisfied)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // on failure other than timeout, stop waiting and leave
}
// Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: 타임아웃이 있는 대기의 올바른 흐름
accDescr: 먼저 기한을 고정하고, 깰 때마다 조건과 기한을 확인한다. 아직 시간이 있으면 남은 시간을 다시 계산하고 대기로 돌아간다
d["기한을 고정한다"] --> c{"조건이 성립하는가?"}
c -->|"예"| go["처리로 진행한다"]
c -->|"아니오"| t{"기한이 지났는가?"}
t -->|"예"| to["타임아웃을 처리한다"]
t -->|"아니오"| w["남은 시간을 계산하고 기다린다"]
w --> c
그림 5: 타임아웃이 있는 대기는 「같은 대기 시간」을 다시 넘기지 않고, 기한에서 남은 시간을 다시 계산한다.
C++에서는 이 계산을, 기한까지 포함해, wait_until(절대 시각)과 술어 오버로드에 맡길 수 있습니다. 타임아웃으로 돌아와도 술어의 최종 값을 주므로, 「타임아웃인가, 제때 왔는가?」도 술어로 판단할 수 있습니다.5
6. 피해야 할 패턴 목록
if로 한 번만 확인하기. 이 글의 주역입니다. 스퓨리어스 웨이크업이나 가로채인 기상이 일어나는 순간, 조건이 만족되지 않은 채 처리가 진행됩니다. 빈 큐에서 꺼내기, 초기화되지 않은 데이터 건드리기, 이중 해제── 증상은 「가끔만 나타나는 크래시나 데이터 파손」이 됩니다.
락 밖에서 조건을 확인하거나 갱신하기. 대기 측이 락 밖에서 조건을 보고 「아직」이라고 정한 뒤, wait에 들어가기 전의 틈에서 알리는 쪽이 상태를 갱신하고 알림을 보내면, 알림은 대기자가 없는 조건 변수에 발사되어 사라집니다. 대기 측은 그다음 wait에 들어가 다시는 오지 않을 알림을 계속 기다립니다. 그것이 잃어버린 기상(lost wakeup)이며, 스퓨리어스 웨이크업의 거울상입니다. 조건 변수의 대기 API가 「락을 원자적으로 풀고 잠든다」고 설계된 이유는 바로 이 틈을 닫기 위해서입니다.1 락 규율을 지키는 한 일어나지 않습니다.
sequenceDiagram
accTitle: 잃어버린 기상의 타임라인
accDescr: 대기 측이 락 밖에서 조건을 확인하고 wait에 들어가기 전의 틈에서 알리는 쪽이 상태를 갱신하고 알리면, 알림은 대기자가 없는 조건 변수로 보내져 사라지고, 대기 측은 다시는 오지 않을 알림을 계속 기다린다
participant W as 대기 측
participant N as 알리는 쪽
W->>W: 락 밖에서 조건을 확인한다(불만족)
N->>N: 상태를 갱신하고 알린다
Note over N: 이 순간 대기자가 없다
W->>W: wait에 들어간다
Note over W: 알림은 이미 사라졌고 영영 깨어나지 않는다
그림 6: 락 밖에서 조건을 확인하면, 확인과 wait 사이의 틈으로 알림이 빠져나간다 ── 「잃어버린 기상」.
조건 변수의 「일시적 알림」을 이벤트의 pulse로 재현하기. 이벤트 자체(CreateEvent + SetEvent)는 안티패턴이 아닙니다. 소비자 하나가 큐가 빌 때까지 처리하는 구성의 기상 신호, 한 번 올리면 내리지 않는 정지 지시(수동 리셋 이벤트)는 이벤트의 올바른 사용이며, WaitForMultipleObjects로 다른 대기 대상과 합치거나 프로세스 경계를 넘고 싶을 때는── 프로세스 간에 공유할 수 없는 사용자 모드 객체인 조건 변수는 쓸 수 없습니다.1 위험한 것은 「바로 그 순간 기다리는 스레드만 깨우고 상태를 남기지 않는」 조건 변수의 일시적 알림을 이벤트 조작으로 재현하려는 일입니다. 그 발상은 거의 항상 다음 항목, PulseEvent로 이어집니다.
PulseEvent를 쓰기. 수동 리셋 이벤트에서 「지금 기다리는 모두를 깨우고 즉시 이벤트를 비신호 상태로 되돌리는」 API이지만, Microsoft 자신은 문서에서 「이 함수는 신뢰할 수 없으며 써서는 안 된다. 주로 하위 호환을 위해 존재한다. 대신 조건 변수를 쓰라」고 말합니다. 이유는 대기 스레드가 커널 모드 APC에 의해 대기 상태에서 일시적으로 빠졌다가 APC가 끝난 뒤 대기로 돌아올 수 있기 때문입니다. 그 짧은 간격에 PulseEvent가 호출되면, 그 스레드는 「호출 순간에 기다리고 있던 사람」에 포함되지 않아 깨어나지 않습니다.7 커널 APC는 OS가 내부에서 쓰는 것이며, 앱이 제어할 수 없습니다.11 이 문제는 정적 분석 경고(C28648)이기도 합니다.12 스퓨리어스 웨이크업이 「여분으로 깨는」 문제라면, 이것은 「깨워야 하는데 늦잠을 자는」 문제이며, while 루프는 구할 수 없습니다── 알림 자체가 사라졌기 때문입니다.
상태를 갱신하기 전에, 락을 쥐지 않고 알림만 먼저 보내기. 상태가 아직 낡은 채로 WakeConditionVariable을 호출하고, 그다음에야 락을 잡아 상태를 갱신하는── 그 순서에서는 깨어난 스레드가 확인할 때 조건이 여전히 불만족이어서 다시 잠듭니다. 더 이상의 알림이 없으면 그대로 있습니다. 같은 락을 쥔 채로 「알림 → 갱신 → 해제」로 쓰면 실제 해는 없는데, 대기 측이 락을 다시 획득할 때까지 조건을 확인할 수 없기 때문입니다. 그래도 읽는 이가 이 안전 조건을 매번 검증하지 않게, 「락 아래에서 상태를 갱신하고, 그다음에 알린다」는 순서로 통일하는 편이 안전합니다.
7. 맞닥뜨렸을 때의 조사 방법
스퓨리어스 웨이크업이 끼는 버그는 「드물게만 나타난다」는 특징이 있습니다. 증상에서 거꾸로 가면 다음 두 계열로 나뉩니다.
계열 1: 조건이 만족되지 않은 채 처리가 진행된다. 빈 큐에서 꺼내 예외나 크래시, 결과 누락 등. 술어 없는 대기를 의심합니다. 코드 검토에서 기계적으로 훑을 수 있습니다── cv.wait(가 인수가 하나뿐인 곳, SleepConditionVariableCS / Monitor.Wait가 while이 아니라 if로 감싸인 곳을 검색합니다. 이 확인은 재현을 기다릴 필요가 없고, 지렛대가 가장 큰 수입니다.
계열 2: 깨어야 할 스레드가 깨지 않는다(행). 잃어버린 기상(락 밖에서 조건 확인, 또는 상태 갱신 전에 락 밖에서 알림)과 PulseEvent를 의심합니다. 멈춘 프로세스에서 덤프를 떠 각 스레드의 스택을 보면, 어느 스레드가 어느 대기 API에서 멈췄는지 식별할 수 있습니다. 거기서 「누가 어떤 순서로 그 알림을 보내기로 되어 있었는지」를 코드로 쫓습니다.
flowchart TB
accTitle: 증상에서 나누는 흐름
accDescr: 조건이 만족되지 않은 채 처리가 진행되면 코드 검색으로 술어 없는 대기를 훑고, 스레드가 깨지 않으면 덤프에서 대기 지점을 식별한 뒤 잃어버린 기상이나 PulseEvent를 의심한다
s["드물게만 나타나는 버그"] --> a["조건이 만족되지 않은 채 처리가 진행된다"]
s --> b["깨어야 할 스레드가 깨지 않는다"]
a --> a1["코드에서 술어 없는 대기를 검색한다"]
b --> b1["덤프에서 대기 스레드를 식별한다"]
a1 -.-> a2["if를 while로 바꾸거나 술어 wait를 쓴다"]
b1 -.-> b2["잃어버린 기상이나 PulseEvent를 의심한다"]
그림 7: 증상이 「너무 멀리 진행」인지 「영영 깨어나지 않음」인지가, 의심하는 것과 조사 방법을 나눈다.
재현하고 싶으면 표준 수는 경쟁 창을 넓히는 것입니다. 물리 코어보다 많은 스레드를 쓰고, 대기와 알림 사이에 의도적인 Sleep을 넣으며, 디버그와 릴리스 빌드를 둘 다 돌려 타이밍 지터를 늘립니다. 「술어 없는 대기를 고친 뒤 재현이 멈췄다」를 확인할 때는 같은 스트레스 아래에서 비교합니다.
8. 정리 ── 체크리스트
wait에서 돌아오는 경로는 셋── 진짜 알림, 스퓨리어스 웨이크업, 가로채인 기상── 이고, 호출자는 구별할 수 없습니다. 따라서 대기는 항상 조건에 대한 while 루프로 씁니다.- 스퓨리어스 웨이크업은 Win32, C++, POSIX가 성능과의 트레이드오프로 의도적으로 허용한 동작이며, OS 수정이나 라이브러리 교체로 사라지지 않습니다. .NET의
Monitor.Wait는 이유 없이 깬다고 가정되지는 않지만, 가로채인 기상과 타임아웃이 있으므로 같은 while 규율이 여전히 필요합니다. - C++에서는 술어 형태
wait(lock, pred)를 기본으로 합니다. 라이브러리가 루프를 수행합니다. - 조건의 갱신과 확인은 같은 락 아래에서 합니다. 알림은 「상태를 갱신한 뒤」보냅니다. Win32/C++ 알림은 락을 푼 뒤에 해도 되고, C#의
Pulse는 락 안에서만입니다. - 타임아웃이 있는 대기는 기한을 고정하고 남은 시간을 다시 계산합니다. C++에서는
wait_until과 술어. - 조건 변수의 일시적 알림을 이벤트의 pulse로 재현하지 마십시오. 특히
PulseEvent는 공식 문서가 「쓰지 말고, 대신 조건 변수를 쓰라」고 분명히 말하는 것입니다. 이벤트 자체는 정지 지시,WaitForMultipleObjects와의 결합, 프로세스 간 동기화에는 여전히 올바른 도구입니다. - 검토에서는 「술어 없는 대기」와 「
if+ wait」를 기계적으로 검색합니다. 드물게 재현되는 버그를 재현을 기다리지 않고 죽일 수 있습니다.
스퓨리어스 웨이크업은, 이름의 이상함과 달리, 수정의 한 줄 키워드로 압축됩니다── if를 while로 바꾸기. 그리고 그 한 줄 뒤에는 조건 변수라는 도구의 설계 아이디어가 있습니다. 「정확한 알림은 비싸므로, 확인은 대기 측의 책임이다」. 메커니즘으로 이해하면, 언어나 프레임워크가 바뀌어도 같은 규율을 주저 없이 적용할 수 있어야 합니다.
관련 기사
- 멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
- 멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
- 멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해둘 것
- Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 입자의 폴링을 피한다
- 공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
- Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
관련 상담 영역
합동회사 코무라소프트에서는 멀티스레드 설계 리뷰, 「가끔만 재현되는」 크래시와 행의 원인 조사(덤프 분석), 레거시 동기화 코드(이벤트와 PulseEvent 의존 등)를 조건 변수 기반으로 옮기는 작업을 다루고 있습니다. 증상을 나누는 단계부터여도 괜찮습니다── 부담 없이 연락해 주십시오.
참고 링크
-
Microsoft Learn, Condition Variables. 조건 변수가 락을 원자적으로 풀고 대기에 들어가는 사용자 모드 객체라는 점, 스퓨리어스 웨이크업(명시적 기상에 묶이지 않은 기상)과 가로채인 기상(깨어난 스레드보다 다른 스레드가 먼저 도는 것)이 있어 대기에서 돌아온 뒤 while 루프에서 술어를 다시 확인해야 한다는 점, 알림은 락 안에서도 밖에서도 가능하지만 락을 푼 뒤 깨우는 편이 컨텍스트 스위치 감소에 낫다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 지정한 크리티컬 섹션을 원자적으로 풀고 조건 변수에서 기다린다는 점, 깨어난 스레드가 돌아오기 전에 크리티컬 섹션을 다시 획득한다는 점, 타임아웃 때 ERROR_TIMEOUT이 반환된다는 점, 스퓨리어스 웨이크업과 가로채인 기상이 있어 대기에서 돌아온 뒤 술어를(보통 while 루프에서) 다시 확인해야 한다는 점에 대해. ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. pthread_cond_wait / pthread_cond_timedwait의 스퓨리어스 웨이크업이 일어날 수 있다는 점, wait에서 돌아온다는 것이 술어의 값에 대해 아무것도 말하지 않으므로 술어를 다시 평가해야 한다는 점, Rationale이 「정확히 하나를 깨우는」 구현이 특히 멀티프로세서에서 조건 변수 연산을 느리게 할 수 있고 스퓨리어스 웨이크업을 허용하면 술어 확인 루프가 강제되어 앱이 더 튼튼해진다고 말한다는 점에 대해. ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. 술어 없는 wait가 스퓨리어스 웨이크업으로 언블록될 수 있다는 점, 술어 오버로드가 while (!pred()) wait(lock);과 동등하며 각 알림이나 스퓨리어스 웨이크업에서 락을 다시 획득하고 술어를 확인하는 루프로 정의된다는 점에 대해. ↩ ↩2
-
Microsoft Learn, condition_variable Class. 술어 없는 wait가 notify_one / notify_all에서 언블록되고 가짜로도 깰 수 있다고 명시된다는 점, 술어 형태 wait(lock, pred)가 사실상 while (!Pred()) wait(Lck);를 돈다는 점, wait_for / wait_until도 같은 성질과 술어 오버로드를 가진다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. Wait가 락을 풀고 대기 큐에 들어간다는 점, Pulse / PulseAll로 깨어난 뒤 락을 다시 획득할 때까지 돌아오지 않는다는 점, 의도된 사용이 깨어난 스레드가 대기에 들어가게 한 조건을 다시 평가하고 필요하면 다시 Wait를 호출하는 것이라는 점에 대해. ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). 대기 스레드가 커널 모드 APC에 의해 대기 상태에서 일시적으로 빠졌다가 APC가 끝난 뒤 돌아올 수 있어, 그 간격에 PulseEvent가 호출되면 스레드가 해제되지 않는다는 점, 따라서 PulseEvent는 신뢰할 수 없어 새 앱에서 써서는 안 되며 대신 조건 변수를 쓴다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. 크리티컬 섹션 하나와 조건 변수 둘(BufferNotEmpty와 BufferNotFull)로 생산자–소비자 큐를 구현하는 공식 샘플에 대해. 대기는 술어를 확인하는 루프 안에서 수행된다. ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). 주소의 값이 바뀌기를 기다리는 함수가 신호되면 돌아오는 것은 보장되지만 다른 이유로도 돌아올 수 있다는 점, 일찍 깨는 예로 저메모리 상태, 같은 주소에 대한 이전 기상의 포기, checked 빌드 실행이 있다는 점, 따라서 반환 후 값을 다시 비교해야 하며 공식 샘플 자체가 while 루프라는 점에 대해. ↩
-
Microsoft Learn, Monitor.PulseAll Method. PulseAll이 스레드를 대기 큐에서 준비 큐로 옮기고, 락이 풀리면 준비 큐의 다음 스레드가 락을 획득한다는 점, Pulse / PulseAll / Wait는 동기화 블록 안에서만 호출할 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Waits and APCs. 커널 APC가 선점적으로 실행되고, 시스템이 내부에서 대기 API에서 돌아오지 않은 채 대기를 중단했다가 재개하므로, KePulseEvent 같은 일시적 신호가 그 간격에서 놓칠 수 있다는 점에 대해. ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. PulseEvent 사용에 대한 정적 분석 경고, APC 때문에 대기 밖에 있던 스레드가 해제되지 않고 영원히 멈출 수 있다는 점, SetEvent나 다른 동기화 객체로 바꾸는 지침에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
DllMain과 로더 락 ── 「DLL 초기화에서는 아무것도 하지 말라」는 말의 진짜 이유
DllMain에서 LoadLibrary를 호출하거나 다른 스레드와 동기화해서는 안 되는 이유를 설명합니다. 로더 락이 모든 DLL 알림을 직렬화하는 구조, 데드락이 구조적으로 확정되는 고전적 시나리오, 초기화를 미루는 올바른 설계, 행(hang)...
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」병렬 처리
네이티브 코드에서 CreateThread를 여기저기 만들고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 해설...
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
「응답 없음」의 정체 ── Windows가 앱의 행을 판단하는 방식과 멈추지 않는 설계
Windows의 「응답 없음」은 창이 5초 동안 메시지를 꺼내지 않았다고 OS가 판단해 고스트 창으로 바꾸는 메커니즘입니다. 그 판단의 내부, 행의 고전적 원인, 무거운 작업을 UI 스레드에서 떼어 내는 설계, 행 조사 절차를 정리합니다.
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex를 이용한 스레드 생성, SRW 잠금과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects의 정지 설계가 정석입니다. TerminateThread...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 스퓨리어스 웨이크업은 OS나 라이브러리의 버그인가요?
- 아닙니다. 사양에 명시된 동작입니다. Win32의 SleepConditionVariableCS, C++의 std::condition_variable, POSIX의 pthread_cond_wait는 모두 공식 문서나 표준이 알림에 묶이지 않은 기상이 일어날 수 있다고 분명히 말합니다. 그것을 금지하는 구현은 이론적으로 가능하지만, 모든 조건 변수 연산(특히 멀티프로세서에서의 알림)이 느려지므로, 「대기 측이 조건을 다시 확인하면 정확성이 유지된다」는 이해 위에서 허용하는 트레이드오프입니다. 따라서 처방은 OS 수정을 기다리는 것이 아니라, 대기를 항상 while 루프 안에 쓰는 것(또는 술어 형태의 대기를 쓰는 것)입니다.
- 대기를 while 루프로 감싸면 성능이 떨어지나요?
- 실무에서 비용은 무시할 수 있습니다. while 루프가 더하는 것은 깰 때마다 조건 확인 한 번뿐이며, 이미 락을 쥔 채의 값싼 비교입니다. 스퓨리어스 웨이크업 자체는 드물므로, 추가 루프 반복은 예외적인 경우에만 일어납니다. 반대로 확인을 if로 남기는 비용은 조건이 만족되지 않은 채 처리가 진행되는 「드물게만 재현되는 버그」이며── 비교가 되지 않습니다. 조건 변수 대기 비용을 실제로 지배하는 것은 락 경합과 알림 빈도이지, while이 있는지가 아닙니다.
- C++의 술어 형태 wait를 쓰면 스퓨리어스 웨이크업을 잊어도 되나요?
- 대기 루프에 한해서는 예입니다. cv.wait(lock, pred)는 사실상 while (!pred()) wait(lock);이므로, 스퓨리어스 웨이크업과 가로채인 기상(stolen wakeup)이 모두 자동으로 흡수됩니다. 새 C++ 코드는 술어 오버로드를 기본으로 해야 합니다. 다만 술어가 읽는 공유 상태의 갱신은 같은 뮤텍스로 보호해야 하고, 알리는 쪽은 여전히 notify 전에 그 상태를 갱신해야 합니다. 술어 wait는 루프를 대신해 줄 뿐, 락 규율을 대신해 주지는 않습니다.
- C#의 Monitor.Wait에서도 같은 문제가 일어나나요?
- 예. Monitor.Wait에서 기다리는 스레드는 Pulse/PulseAll로 깨어난 뒤 Wait에서 돌아오기 전에 락을 다시 획득하지만, 그 사이에 다른 스레드가 먼저 락을 얻어 조건을 소비할 수 있습니다(가로채인 기상). Microsoft 문서는 깨어난 스레드가 대기를 유발한 조건을 다시 평가하고, 필요하면 다시 Wait를 호출한다고 가정하고 쓰여 있습니다. 따라서 C#의 기본 형태도 while (!condition) Monitor.Wait(gate);입니다. Win32와 다른 제약 하나는 Wait/Pulse를 lock 문 안에서만 호출할 수 있다는 점입니다.
- WaitForSingleObject로 이벤트를 기다릴 때도 스퓨리어스 웨이크업이 일어나나요?
- 평범한(비 alertable) 대기에서는 객체가 실제로 signaled가 될 때만 WAIT_OBJECT_0이 반환되며, 조건 변수에 있는 「이유 없는 기상」은 없습니다. 다만 「이벤트가 signaled가 되었다」와 「앱의 조건이 성립한다」는 다른 일입니다. 같은 이벤트로 여러 소비자가 깨어나면 락을 먼저 잡은 스레드가 조건을 소비하므로, 깬 뒤에도 조건을 다시 확인해야 합니다. 조건 변수의 「바로 그 순간 기다리는 사람만 깨운다」는 일시적 알림을 이벤트로 재현하려는 설계는 PulseEvent의 신뢰성 문제에도 부딪히기 쉬우므로, 프로세스 안에서 조건을 기다리는 일에는 조건 변수가 더 안전한 도구입니다.