Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows

· עודכן בתאריך: · · Windows, multithreading, condition variable, synchronization, C++, C#, Win32 API, חקירת תקלות

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 22 Aug 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI (ארכיון רשום): 10.5281/zenodo.22176635)

מזהי ה־DOI שלהלן מפנים לגרסאות שנשמרו בעבר בארכיון, ועשויים שלא להתאים לטקסט הנוכחי. להפניה לטקסט הנוכחי, השתמשו בכתובת של דף זה.

Go Komura (2026). Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows. KomuraSoft LLC. https://comcomponent.com/he/blog/windows-condition-variable-spurious-wakeup/

DOI (ארכיון רשום)
10.5281/zenodo.22176635
DOI (הגרסה האחרונה שנרשמה)
10.5281/zenodo.22176636

“שמנו את הנתונים ואז עשינו notify, ובכל זאת ה-worker קורא מ-queue ריק וקורס.” “אנחנו בטוחים שהודענו, ובכל זאת מדי פעם ה-thread לא חוזר מה-wait.” שניהם באגים של rendezvous, אבל המקומות לבדוק שונים.

מרכז המאמר הוא עיקרון אחד: חזרה מ-wait של condition variable אינה מבטיחה שהתנאי שהמתנתם לו מתקיים. ברגע שמבינים spurious wakeup, שחוזר בלי notify, ו-stolen wakeup, שבו התנאי נצרך אחרי ה-notify, מתברר למה צריך while ולא if. Lost wakeup, שבו מפספסים notify, מטופל בנפרד משני אלה.

מה רוצים לדעת או במה תקועים איפה לקרוא
למה wait מתעורר בלי notify, ומה ההבדל מ-stolen wakeup מה מובטח ברגע החזרה, למה המפרט מתיר זאת
קוד נכון ל-Win32, ל-C++ ול-C# הצורה הבסיסית לפי שפה
צוין timeout, ובכל זאת ה-wait הולך ומתארך המתנה לפי deadline
Thread לא חוזר אף על פי שהודענו Lost wakeup ו-PulseEvent
חקירת crash או hang שמופיע רק לעיתים נדירות שלבי חקירה לפי תסמין

קהל היעד הוא מפתחים שכותבים אפליקציות עסקיות ותוכנות בקרת ציוד ב-Windows. המאמר מאשר את המנגנון ממקורות ראשוניים ומחבר אותו למימושי Win32 (C), C++ ו-C#.

1. קודם כל, המסקנות

קוד wait צריך לשמור שלושה כללים.

עיקרון מה הקוד חייב לעשות
לחכות ל-state, לא ל-notify להפוך את התנאי ל-“האם ה-queue אינו ריק?” וכדומה, לא ל-“האם התעוררתי?”
לבדוק שוב את התנאי בכל חזרה while (!condition) wait(...); ב-C++, להשתמש ב-overload עם predicate wait(lock, pred)
להגן על הבדיקה ועל העדכון באותו lock לעדכן את ה-state לפני notify, כדי ששום notify לא יחמוק ברווח בין הבדיקה ל-wait

Win32, C++ ו-POSIX כולם מתירים, במפרט, wakeups שאינם קשורים ל-notify מפורש. מעל זה, גם כשמגיע notify, thread אחר יכול לצרוך את התנאי קודם (stolen wakeup), ולכן הבדיקה החוזרת חובה. גם Monitor.Wait של C# משמש תחת אותה משמעת, עם stolen wakeup בראש.12345

הזהירות הנוספת היא לא לשחזר notify חולף של condition variable עם pulse על event. PulseEvent במיוחד יכול לפספס notify, וההנחיה של Microsoft היא לא להשתמש בו באפליקציות חדשות ולהשתמש ב-condition variable במקום.6

שאר המאמר מתקדם בסדר הזה: איך wakeups עובדים (פרקים 2 עד 4), המימוש הנכון (פרק 5), דפוסים להימנע מהם ואיך לחקור (פרקים 6 ו-7), ורשימת בדיקה (פרק 8).

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 17, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. מהו spurious wakeup — “התעוררתי” אינו אומר “התנאי מתקיים”

2.1 Condition variable משחרר את ה-lock, ממתין, ולוקח אותו מחדש לפני החזרה

Condition variable הוא פרימיטיב סנכרון שגורם ל-thread להמתין עד שתנאי כלשהו מתקיים. ב-Win32 הוא משתמש במבנה וב-APIs הבאים.1

תפקיד סוג או API של Win32
מייצג את ה-condition variable CONDITION_VARIABLE
משחרר את ה-lock וממתין SleepConditionVariableCS / SleepConditionVariableSRW
מעיר threads ממתינים WakeConditionVariable / WakeAllConditionVariable

ה-API של ה-wait משחרר אטומית את ה-critical section או את ה-SRW lock שה-thread מחזיק ונכנס ל-wait. אחרי ההתעוררות הוא לוקח מחדש את אותו lock לפני החזרה לקורא.1

אבל לקיחה מחדש של ה-lock וחזרה הם דבר שונה מכך שהתנאי שהאפליקציה המתינה לו מתקיים.

2.2 להבחין בין notify אמיתי, spurious wakeup ו-stolen wakeup

משאירים את הטיפול ב-timeouts לפרק 5, ומשווים קודם את שלושת מקרי ה-wakeup.

מקרה קשר ל-notify מפורש איך לקרוא את החזרה
Wakeup אמיתי יש notify התנאי לעיתים קרובות מתקיים, אבל עצם החזרה לבדה אינה מבטיחה זאת
Spurious wakeup אינו קשור ל-notify מפורש שנועד להעיר את ה-thread הזה חוזר אף על פי שהתנאי עדיין אינו מתקיים
Stolen wakeup יש notify Thread אחר צרך את התנאי קודם, ולכן הוא כבר אינו מתקיים
שלושה מקרים שבהם wait חוזרWait של condition variable חוזר לא רק מ-notify אמיתי אלא גם מ-spurious wakeup בלי notify ומ-stolen wakeup שבו notify הגיע אבל התנאי נצרך קודם, לכן בכל מקרה צריך לבדוק שוב את התנאיחזר מ-waitNotify אמיתיSpurious wakeup (אין notify)Stolen wakeup (התנאי כבר נצרך)בודקים שוב את התנאי, ואז ממשיכים

איור 1: יש שלושה נתיבים חזרה מ-wait, והקורא אינו יכול לדעת באיזה מהם הלך, לכן תמיד צריך לבדוק שוב את התנאי.

Spurious wakeup אינו מוגבל למקרה שבו שום notify לא נשלח בשום מקום במערכת. כש-notifications מגיעים במנה קצרה, המימוש יכול, מסיבותיו שלו, להעיר threads ממתינים נוספים כקבוצה. מנקודת המבט של thread שאין לו notify מפורש מתאים, גם זה spurious wakeup.

Microsoft Learn קובע ש-condition variables כפופים גם ל-spurious wakeup וגם ל-stolen wakeup, ומבקש לבדוק שוב את ה-predicate, בדרך כלל בלולאת while, אחרי שה-wait חוזר. ה-predicate כאן הוא התנאי שממתינים לו, כמו “ה-queue אינו ריק.”1

2.3 להחליט אם להמשיך לפי התנאי הנוכחי, לא לפי למה התעוררתם

הקורא אינו יכול לדעת איזה משלושת הנתיבים החזיר אותו. לכן הצורה היא: לבדוק את התנאי עצמו בכל חזרה, ולהמתין שוב אם הוא אינו מתקיים.

ה-while אינו מונע את ה-spurious wakeup או את ה-stolen wakeup עצמם. הוא שם כדי שבכל אחד מהם, העיבוד לא ימשיך כשהתנאי אינו מתקיים. שומרים על הבדיקה החוזרת הזו ועל ההגנה באותו lock שמכוסה בפרק 5, והקוד מטפל נכון בכל נתיב wakeup.

3. למה המפרט מתיר זאת — notify מדויק הוא יקר

3.1 כדי שכל פעולה לא תשלם על notify קפדני

מימוש שלעולם אינו מייצר spurious wakeup אפשרי תיאורטית. הסיבה ש-POSIX, Windows ו-C++ בכל זאת מתירים אותם נמצאת בביצועים ובתכנון שבו ה-waiter בודק שוב את התנאי. גם ה-Rationale של pthread_cond_wait ב-POSIX מסביר את ההחלטה הזו.3

מימוש קפדני של notify ש”מעיר בוודאות בדיוק thread אחד” מוסיף עלות סנכרון לכל פעולת condition variable, במיוחד על multiprocessors. ה-scheduler יושב בין ה-notify ל-wakeup, ויש גם interrupts ו-preemption. התרת wakeup נוסף נדיר ובדיקה חוזרת בצד הממתין משאירה את המימוש מהיר יותר.3

לולאת הבדיקה החוזרת יש גם תועלת נוספת: היא הופכת את הכוונה למפורשת בקוד והופכת את הקוד לחסין יותר. מתייחסים ל-notify לא כאל “ערובה שהתנאי מתקיים” אלא כאל רמז שהתנאי אולי השתנה, והקוד סובל שינויים בצד המודיע כמו העלאת threads נוספים או העלאתם כקבוצה.3

3.2 גם בלי spurious wakeup, stolen wakeup נשאר

Stolen wakeup קורה ברווח הזמן בין ה-notify ללקיחה מחדש של ה-lock. גם אם ה-producer שם פריט אחד ב-queue ומעיר את consumer A, אם consumer B לוקח את הפריט לפני ש-A לוקח מחדש את ה-lock, ה-queue ריק ברגע ש-A חוזר.

ציר הזמן של stolen wakeupה-producer שם פריט אחד ב-queue ומעיר את consumer A הממתין, אבל לפני ש-A לוקח מחדש את ה-lock, consumer B לוקח את ה-lock ולוקח את הפריט האחד, כך שה-queue ריק ברגע ש-A מתעוררConsumer BProducerConsumer A (ממתין)Consumer BProducerConsumer A (ממתין)התעורר, ממתין ללקיחה מחדש של ה-lockה-queue ריק (stolen)הוספת פריט אחד ל-queueWakeConditionVariableלקיחת ה-lock ולקיחת פריט אחדלקיחה מחדש של ה-lock וחזרה מ-waitבדיקה חוזרת בלולאת while והמתנה שוב

איור 2: Stolen wakeup, שבו thread שלישי צורך את התנאי ברווח הזמן בין notify ל-wakeup, יכול לקרות תחת כל מימוש.

כל עוד thread שלישי יכול לקחת את ה-lock קודם, ליטוש המימוש לבדו אינו יכול לבטל את ה-stolen wakeup הזה. גם אם אפשר היה לבטל spurious wakeup לגמרי, לולאת ה-waiter עדיין נדרשת כל עוד stolen wakeup קיים. לאור זאת, החלטת התכנון היא להתיר spurious wakeup ולהשאיר condition variables מהירים.

4. באילו שכבות זה מופיע ב-Windows

4.1 משמעת הבדיקה החוזרת משותפת; סיבות ההתעוררות מופרדות

API או ספרייה למה הבדיקה החוזרת נדרשת
Condition variables של Win32 Spurious wakeup ו-stolen wakeup מתועדים במפורש2
WaitOnAddress מותר לחזור מוקדם מסיבות שאינן signal לכתובת שניתנה7
std::condition_variable של C++ wait בלי predicate יכול להתעורר spuriously48
Monitor.Wait של .NET Thread אחר יכול לצרוך את התנאי בין ה-wakeup ללקיחה מחדש של ה-lock5

גם דוגמת ה-producer-consumer queue הרשמית של Win32 כותבת את ה-wait בלולאת while.9

WaitOnAddress, זמין מ-Windows 8 ואילך, הוא API נמוך שממתין שהערך בכתובת נתונה ישתנה. כדוגמאות לחזרה מוקדמת בלי signal, התיעוד הרשמי מציין מצב זיכרון נמוך, נטישת wake קודם לאותה כתובת, והרצה על checked build. דוגמת השימוש שלו גם היא לולאת while שמשווה שוב את הערך.7

4.2 ה-predicate wait של C++ מבצע את הלולאה בשבילכם

התיעוד של MSVC מסביר ש-wait(lock, pred) מריץ בפועל את הבא.4

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

גם cppreference קובע במפורש ש-wait בלי predicate יכול לחזור spuriously. עם צורת ה-predicate, אפשר להשאיר את לולאת הבדיקה החוזרת הזו לספרייה.8

4.3 ב-C#, מתמקדים ב-stolen wakeup ובלקיחה מחדש של ה-lock

Monitor.Wait / Pulse משתמשים ב-waiting queue וב-ready queue. Thread שהתעורר מ-Pulse / PulseAll עובר ל-ready queue ויוצא מ-Wait רק אחרי שהוא לוקח מחדש את ה-lock. זה ש-thread אחר יכול לצרוך את התנאי קודם במרווח הזה זהה ל-Win32.510

עם Monitor.Wait של .NET, במקום להניח wakeup בלי סיבה מהסוג שיש ל-condition variable, חושבים על זה בנפרד: אותה משמעת while נדרשת בגלל stolen wakeup ו-timeouts. התיעוד גם מניח שימוש שבו ה-thread מעריך מחדש את התנאי שגרם לו להמתין וקורא שוב ל-Wait אם צריך.5

כל שכבה דורשת בדיקה חוזרת של ה-predicateבכל שכבה, std::condition_variable של C++, Monitor של .NET, CONDITION_VARIABLE של Win32, ו-WaitOnAddress הנמוך, התיעוד הרשמי דורש לבדוק שוב את התנאי אחרי התעוררותC++ std::condition_variableבהתעוררות, בודקים שוב את התנאי (while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

איור 3: לא משנה השפה או ה-framework, כל שכבת wait-primitive דורשת רשמית בדיקה חוזרת אחרי התעוררות.

5. הדרך הנכונה להמתין — לכתוב זאת עם while ועם predicate

הצורה המשותפת: לבדוק את התנאי, ולהמשיך רק כשהוא מתקיים

מה שממתינים לו אינו “האם התקבל notify” אלא shared state שמוגן ב-lock. בודקים ומעדכנים את מונה ה-queue או את ה-flag בתוך אותו lock, עושים wait אם התנאי אינו מתקיים, ובודקים שוב כשחוזרים.

זרימת לולאת wait נכונהלוקחים את ה-lock ובודקים את התנאי; אם הוא אינו מתקיים, משחררים את ה-lock והולכים לישון; בהתעוררות לוקחים מחדש את ה-lock וחוזרים לבדיקת התנאי. ממשיכים עם ה-lock ביד רק כשהתנאי מתקייםלאכןלקיחת ה-lockהאם התנאי מתקיים?wait (שחרור ה-lock ושינה)התעוררות (לקיחה מחדש של ה-lock)עיבוד כשעדיין מחזיקים את ה-lock

איור 4: Wait נכון הוא לולאה, ואין רווח בין בדיקת התנאי לעיבוד (שניהם קורים כשמחזיקים את ה-lock).

עם הצורה הזו, ברגע שיוצאים מהלולאה, אישרתם שהתנאי מתקיים כשעדיין מחזיקים את ה-lock. צורכים את ה-state כפי שהוא, כך שאין רווח בין בדיקת התנאי לעיבוד שבו thread אחר יכול להיכנס באמצע.

הצורה הבסיסית ב-Win32 (C)

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // מצב משותף שמוגן על ידי cs

// מאתחלים פעם אחת ב-startup (לאתחול סטטי, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// צד הממתין (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // תמיד while, אף פעם לא if
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// כאן ה-lock מוחזק ו-queueCount > 0 מובטח
--queueCount;
LeaveCriticalSection(&cs);

// צד המודיע (producer)
EnterCriticalSection(&cs);
++queueCount;                                    // מעדכנים את המצב בתוך ה-lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // מותר להודיע אחרי שחרור ה-lock

ההבחנה לשמור היא בין עדכון ה-state לבין המקום שבו קוראים ל-notify. ++queueCount חייב תמיד לקרות בתוך ה-lock. WakeConditionVariable, לעומת זאת, אפשר לקרוא מתוך ה-lock או מחוצה לו. Microsoft אומרת שבדרך כלל עדיף להעיר אחרי שחרור ה-lock, כדי לצמצם context switches.1

הצורה הבסיסית ב-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();

קוד חדש צריך לבחור כברירת מחדל ב-wait עם predicate. while (q.empty()) cv.wait(lk); קיים הוא גם צורה נכונה, כך שאין צורך לשכתב אותו רק כי הלולאה כתובה ידנית. הבעיה היא if (q.empty()) cv.wait(lk);, שאינו בודק שוב.

צורת ה-predicate לוקחת רק את הלולאה. משמעת ההגנה על ה-shared state שה-predicate קורא עם אותו mutex גם בצד המודיע, ושל עדכון ה-state לפני notify, עדיין נדרשת.

הצורה הבסיסית ב-C#

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

// צד הממתין
lock (_gate)
{
    while (_queue.Count == 0)          // תמיד while, אף פעם לא if
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// צד המודיע
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // את Pulse של Monitor אפשר לקרוא רק בתוך ה-lock
}

Monitor.Wait / Pulse / PulseAll נקראים בתוך בלוק מסונכרן שמחזיק את ה-lock הנוגע בדבר. מחוץ ל-lock הם זורקים SynchronizationLockException. אל תבלבלו זאת עם Win32, שבו אפשר להעביר את ה-notify מחוץ ל-lock.10

המתנה עם timeout — לחשב את הזמן שנותר מ-deadline

העברת אותו ערך timeout בכל פעם מוסיפה ל-wait בכל פעם ש-spurious wakeup חוזר. משתמשים בצורה קודם מחליטים על ה-deadline, ומחשבים את הזמן שנותר בכל חזרה.

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // timeout (התנאי עדיין לא מתקיים)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // בכשל שאינו timeout, מפסיקים להמתין ויוצאים
    }
    // ERROR_TIMEOUT מקבל את הבדיקה הסופית בתנאי ה-while ובבדיקת ה-deadline
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
זרימה נכונה של wait עם timeoutקודם מחליטים על ה-deadline, ובכל התעוררות בודקים את התנאי ואת ה-deadline; אם ה-deadline עוד לא עבר, מחשבים מחדש את הזמן שנותר וחוזרים ל-waitכןלאכןלאהחלטת ה-deadlineהאם התנאי מתקיים?המשך לעיבודהאם ה-deadline עבר?טיפול ב-timeoutחישוב הזמן שנותר והמתנה

איור 5: Wait עם timeout אינו מעביר שוב “את אותו זמן המתנה”; הוא מחשב מחדש את הזמן שנותר מה-deadline.

ב-C++, אפשר להשאיר את העיבוד הזה ל-overload עם predicate של wait_until, שמקבל זמן מוחלט. כי הוא מחזיר את הערך הסופי של ה-predicate גם ב-timeout, אפשר להחליט לפי האם התנאי בסופו של דבר התקיים.4

6. דפוסים להימנע מהם

6.1 בדיקת התנאי פעם אחת בלבד עם if

כשמתרחש spurious wakeup או stolen wakeup, העיבוד ממשיך כשהתנאי אינו מתקיים. זה מופיע כ-crash נדיר או כקלקול נתונים: לקיחה מ-queue ריק, קריאת נתונים לא מאותחלים, double free. מחליפים ל-while או ל-wait עם predicate של פרק 5.

6.2 בדיקה או עדכון של התנאי מחוץ ל-lock

זה אינו עניין של “להתעורר לעיתים קרובות מדי” אלא של lost wakeup: לפספס את ה-notify ולישון לעד.

אם ה-waiter מסתכל על התנאי מחוץ ל-lock, מחליט “עדיין לא,” והמודיע מעדכן את ה-state ומודיע לפני שה-waiter נכנס ל-wait, אין waiter באותו רגע. ה-waiter נכנס ל-wait אחרי שה-notify כבר הלך, וממשיך להמתין אם לא מגיע notify נוסף.

ציר הזמן של lost wakeupאם ה-waiter בודק את התנאי מחוץ ל-lock והמודיע מעדכן את ה-state ומודיע ברווח לפני הכניסה ל-wait, ה-notify נשלח ל-condition variable בלי waiter ונעלם, וה-waiter ממשיך לחכות ל-notify שלעולם לא יגיעNotifierWaiterNotifierWaiterאין waiter ברגע הזהה-notify כבר הלך, ולכן הוא לא מתעוררבדיקת התנאי מחוץ ל-lock (אינו מתקיים)עדכון ה-state ו-notifyכניסה ל-wait

איור 6: בדיקת התנאי מחוץ ל-lock נותנת ל-notify לעבור ישר דרך הרווח בין הבדיקה ל-wait: lost wakeup.

הסיבה ש-API ה-wait של condition variable הופך את שחרור ה-lock והכניסה ל-wait לבלתי ניתנים להפרדה היא לסגור את הרווח הזה. שומרים על המשמעת של הגנה על בדיקת התנאי ועל עדכונו באותו lock וכניסה ל-API ה-wait כשמחזיקים את ה-lock, וה-lost wakeup הזה נמנע.1

6.3 שחזור notify חולף עם pulse על event

שימוש ב-CreateEvent וב-SetEvent אינו anti-pattern בפני עצמו. ל-events יש שימושים לגיטימיים כמו הבאים.

מטרה מתי להשתמש ב-event
העלאת consumer יחיד תכנון שבו ה-consumer שהתעורר מעבד את ה-queue עד שהוא ריק
סימון עצירה ייצוג הוראת עצירה שברגע שהורמה, לעולם אינה מורדת, עם manual-reset event
המתנה יחד עם יעדי wait אחרים הכללה ב-WaitForMultipleObjects
Rendezvous בין processes שימוש ש-condition variable, שאי אפשר לשתף בין processes, אינו יכול לטפל בו

Condition variable הוא אובייקט user-mode שאי אפשר לשתף בין processes. לפי השימוש, החלפת ה-event ב-condition variable אפילו אינה אפשרית.1

מה שמסוכן הוא לנסות לשחזר עם event את ההתנהגות של condition variable “להעיר רק את ה-threads שממתינים ברגע הזה ולא להשאיר מצב notify מאחור.” הרעיון הזה מוביל לבעיית PulseEvent שבאה אחר כך.

6.4 שימוש ב-PulseEvent

PulseEvent הוא API ש, על manual-reset event, מעיר את ה-threads שממתינים ברגע הזה ומחזיר מיד את ה-event למצב non-signaled. Microsoft, עם זאת, קובעת במפורש שהוא אינו אמין וקיים בעיקר לתאימות לאחור, כך שאפליקציות חדשות לא צריכות להשתמש בו וצריכות להשתמש ב-condition variable במקום.6

הסיבה היא ש-thread ממתין יכול להיות מוסר זמנית ממצב ה-wait על ידי kernel-mode APC ולחזור ל-wait אחרי שה-APC מסתיים. אם קוראים ל-PulseEvent במרווח הזה, ה-thread אינו בין “ה-threads שממתינים ברגע הקריאה” ואינו מתעורר. Kernel APCs הם התנהגות פנימית של ה-OS שהאפליקציה אינה יכולה לשלוט בה.611

הבעיה הזו היא גם אזהרת static analysis C28648.12 Spurious wakeup הוא בעיית “להתעורר לעיתים קרובות מדי”; זו בעיית “לא להתעורר כשצריך.” כי ה-notify עצמו אובד, עטיפת ה-wait ב-while לבדה אינה יכולה להציל.

6.5 Notify בלי להחזיק את ה-lock, לפני עדכון ה-state

אם מודיעים קודם בלי להחזיק את ה-lock ורק אז לוקחים את ה-lock ומעדכנים את ה-state, ה-thread שהתעורר עלול לראות את ה-state הישן ולחזור לישון. אם אין notify אחרי העדכון, הוא נשאר שם.

עם זאת, מבחינים בין שני המקרים הבאים.

סדר תוצאה
Notify מחוץ ל-lock, ואז לקיחת ה-lock ועדכון ה-state יש רווח שבו ה-thread שהתעורר בודק שוב לפני העדכון והולך לישון
Notify, עדכון, ואז שחרור, הכול כשמחזיקים את אותו lock ה-waiter אינו יכול לבדוק עד שהוא לוקח מחדש את ה-lock, כך שהסדר הזה אינו גורם נזק ממשי

במקום לבחון את הבטיחות של האחרון בכל פעם, ברור ובטוח יותר לקבע את הסדר לעדכן את ה-state בתוך אותו lock, ואז להודיע.

7. איך חוקרים כשנתקלים בזה

7.1 להפריד בין “ממשיך אף על פי שהתנאי אינו מתקיים” לבין “לא מתעורר”

תסמין מה לבדוק קודם איך לחקור
לקיחה מ-queue ריק, crash, תוצאות חסרות האם התנאי נבדק שוב אחרי ה-wait חיפוש סביב cv.wait( בלי predicate, SleepConditionVariableCS, ו-Monitor.Wait
Thread שאמור להתעורר לא חוזר, וה-process נתקע האם יש lost wakeup או PulseEvent בדיקת ה-stack של כל thread ב-dump, ומעקב מה-API ה-wait שבו הוא תקוע חזרה לצד המודיע

מציאת cv.wait(lk) בלי predicate אינה בעיה אם הוא עטוף ב-while נכון. אוספים מועמדים בחיפוש, ואז בודקים אם הקוד מסביב הוא רק if, ואם התנאי נבדק ומעודכן תחת אותו lock. אלה מקומות שאפשר לבדוק בלי לחכות לשחזור.

ל-hang, מזהים את מקום ה-wait מ-dump, ואז עוקבים בקוד מי היה אמור להודיע, ובאיזה סדר. בודקים בדיקות תנאי מחוץ ל-lock, notify מחוץ ל-lock לפני עדכון ה-state, ו-PulseEvent.

זרימת מיון מהתסמיןאם התסמין הוא עיבוד שממשיך כשהתנאי אינו מתקיים, מחפשים בקוד waits בלי predicate; אם התסמין הוא thread שלא מתעורר, מזהים את מקום ה-wait מ-dump וחושדים ב-lost wakeup או ב-PulseEventבאג שמופיע רק לעיתים נדירותהעיבוד ממשיך כשהתנאי אינו מתקייםThread שאמור להתעורר לא מתעוררחיפוש בקוד אחרי waits בלי predicateזיהוי ה-threads הממתינים מ-dumpהחלפת if ב-while או ב-predicate waitחשד ל-lost wakeup או ל-PulseEvent

איור 7: האם התסמין הוא “להתקדם רחוק מדי” או “לא להתעורר אף פעם” מכריע גם איפה להסתכל וגם איך לחקור.

7.2 להשוות לפני התיקון ואחריו תחת אותם תנאי stress

כדי לשחזר באג נדיר, מרחיבים את חלון ה-race ומגדילים את שונות התזמון. שיטות כוללות שימוש ביותר threads ממספר הליבות הפיזיות, הכנסת Sleep אבחוני בין ה-wait ל-notify, וניסיון גם ב-debug וגם ב-release.

כשמשווים כדי להסיק “זה הפסיק להשתחזר אחרי שתיקנו את ה-wait,” מפעילים את אותו stress לפני התיקון ואחריו.

8. סיכום — רשימת בדיקה

Notify הוא רמז שהתנאי אולי השתנה; הבסיס להמשך הוא התנאי עצמו, שנבדק בתוך ה-lock.

פריט צורה נכונה
אחרי חזרה מה-wait לא מנסים להבחין בין notify אמיתי, spurious wakeup ו-stolen wakeup; בודקים שוב את התנאי
איך ה-wait כתוב עוטפים ב-while. קוד C++ חדש בוחר כברירת מחדל ב-wait(lock, pred) עם predicate
Shared state מגנים על הבדיקה ועל העדכון באותו lock, ומעדכנים את ה-state לפני notify
איפה מודיעים Win32/C++ יכולים להודיע אחרי שחרור ה-lock. Pulse של C# נקרא בתוך ה-lock
Timeouts מחשבים את הזמן שנותר מ-deadline. ב-C++, משתמשים ב-wait_until עם predicate
שימוש ב-events מבחינים בין שימושים לגיטימיים ל-pulses חולפים, ולא נסמכים על PulseEvent

Spurious wakeup הוא מפרט ש-Win32, C++ ו-POSIX התירו במכוון. התגובה היא משמעת בצד הממתין, לא המתנה לתיקון OS או החלפת ספריות. Monitor.Wait של .NET מופרד מ-wakeups בלי סיבה, אבל מבצע את אותה בדיקה חוזרת כדי להתמודד עם stolen wakeup ועם timeouts.

ל-crash, מתחילים מ-“המקום שממשיך אף על פי שהתנאי אינו מתקיים”; ל-hang, מ-“המקום שמפספס את ה-notify.” משתמשים בהבחנה הזו וברשימת הבדיקה גם בסקירת קוד קיים.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC מטפלת בסקירות תכנון של קוד multithreaded, בחקירת שורש (ניתוח dump) של crashes ו-hangs ש”משתחזרים רק מדי פעם”, ובעיבוד מחדש של קוד סנכרון ישן (קוד שתלוי ב-events, ב-PulseEvent וכדומה) לבסיס של condition variable. אפשר להתחיל מבידוד התסמין; אתם מוזמנים ליצור קשר.

קישורים

  1. Microsoft Learn, Condition Variables. על כך ש-condition variable הוא אובייקט user-mode שמשחרר אטומית את ה-lock ונכנס ל-wait; על כך שיש spurious wakeup (wakeups שאינם קשורים ל-wake מפורש) ו-stolen wakeup (thread אחר רץ לפני ה-thread שהתעורר), כך שיש לבדוק שוב את ה-predicate בלולאת while אחרי חזרה מה-wait; ועל כך שאפשר להודיע מתוך ה-lock או מחוצה לו, אבל עדיף להעיר אחרי שחרור ה-lock כדי לצמצם context switches. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). על שחרור אטומי של ה-critical section שצוין והמתנה על ה-condition variable; על כך שה-thread שהתעורר לוקח מחדש את ה-critical section לפני החזרה; על כך ש-ERROR_TIMEOUT מוחזר ב-timeout; ועל כך שיש spurious wakeup ו-stolen wakeup, כך שיש לבדוק שוב את ה-predicate (בדרך כלל בלולאת while) אחרי חזרה מה-wait. ↩ ↩2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. על כך ש-spurious wakeup מ-pthread_cond_wait / pthread_cond_timedwait אפשרי; על כך שחזרה מה-wait אינה אומרת דבר על ערך ה-predicate, כך שיש להעריך מחדש את ה-predicate; ועל כך שה-Rationale קובע שמימוש ש”מעיר בדיוק אחד” יכול להאט פעולות condition variable, במיוחד על multiprocessors, ושהתרת spurious wakeup כופה לולאת בדיקת predicate והופכת אפליקציות לחסינות יותר. ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, condition_variable Class. על כך ש-wait בלי predicate מצוין כמשוחרר על ידי notify_one / notify_all וגם כמסוגל להתעורר spuriously; על כך ש-predicate wait(lock, pred) מריץ בפועל while (!Pred()) wait(Lck);; ועל כך של-wait_for / wait_until יש אותה תכונה ו-overloads עם predicate. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Monitor.Wait Method. על כך ש-Wait משחרר את ה-lock ונכנס ל-waiting queue; על כך שהוא לא חוזר אחרי שהתעורר מ-Pulse / PulseAll עד שה-lock נלקח מחדש; ועל כך שהשימוש המיועד הוא שה-thread שהתעורר מעריך מחדש את התנאי שגרם לו להמתין וקורא שוב ל-Wait אם צריך. ↩ ↩2 ↩3 ↩4

  6. Microsoft Learn, PulseEvent function (winbase.h). על כך ש-thread ממתין יכול להיות מוסר זמנית ממצב ה-wait על ידי kernel-mode APC ולחזור אחרי שה-APC מסתיים, כך שה-thread אינו משוחרר אם קוראים ל-PulseEvent במרווח הזה; ועל כך ש-PulseEvent לכן אינו אמין, אין להשתמש בו באפליקציות חדשות, ויש להחליפו ב-condition variable. ↩ ↩2 ↩3

  7. Microsoft Learn, WaitOnAddress function (synchapi.h). על כך שהפונקציה שממתינה שהערך בכתובת ישתנה מובטחת לחזור כשמסמנים אותה אבל גם מותרת לחזור מסיבות אחרות; על כך שדוגמאות להתעוררות מוקדמת הן מצב זיכרון נמוך, נטישת wake קודם לאותה כתובת, והרצה על checked build; ועל כך שיש להשוות שוב את הערך אחרי החזרה, כשהדוגמה הרשמית עצמה היא לולאת while. ↩ ↩2

  8. cppreference.com, std::condition_variable::wait. על כך ש-wait בלי predicate יכול להשתחרר מ-spurious wakeup; ועל כך ש-overload עם predicate שקול ל-while (!pred()) wait(lock); ומוגדר כלולאה שלוקחת מחדש את ה-lock ובודקת את ה-predicate בכל notify או spurious wakeup. ↩ ↩2

  9. Microsoft Learn, Using Condition Variables. על הדוגמה הרשמית שמממשת producer-consumer queue עם critical section אחד ושני condition variables (BufferNotEmpty ו-BufferNotFull). ה-wait מבוצע בתוך לולאה שבודקת את ה-predicate. ↩

  10. Microsoft Learn, Monitor.PulseAll Method. על כך ש-PulseAll מעביר את ה-threads ב-waiting queue ל-ready queue, וה-thread הבא ב-ready queue לוקח את ה-lock כשה-lock משתחרר; ועל כך ש-Pulse / PulseAll / Wait ניתנים לקריאה רק מתוך בלוק מסונכרן. ↩ ↩2

  11. Microsoft Learn, Waits and APCs. על כך ש-kernel APCs רצים באופן preemptive, ושהמערכת מפסיקה וממשיכה פנימית את ה-wait בלי לחזור מ-API ה-wait, כך שאות חולף כמו KePulseEvent יכול להיות מפוספס במרווח הזה. ↩

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. על אזהרת static analysis לגבי שימוש ב-PulseEvent; על כך ש-thread שהיה מחוץ ל-wait בגלל APC אינו משוחרר ויכול להיתקע לעד; ועל ההנחיה להחליפו ב-SetEvent או באובייקט סנכרון אחר. ↩

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

האם spurious wakeup הוא באג ב-OS או בספרייה?
לא באג; זו התנהגות שכתובה במפרט. SleepConditionVariableCS של Win32, std::condition_variable של C++, ו-pthread_cond_wait של POSIX, לכולם יש תיעוד רשמי או תקן שקובע במפורש ש-wakeup שאינו קשור ל-notify יכול להתרחש. מימוש שיאסור זאת אפשרי תיאורטית, אבל הוא היה מאט כל פעולת condition variable (במיוחד notify על multiprocessor), ולכן ההתנהגות מותרת בהבנה ש"הנכונות נשמרת אם ה-waiter בודק שוב את התנאי". התרופה אינה לחכות לתיקון OS, אלא תמיד לכתוב את ה-wait בתוך לולאת while (או עם predicate wait).
האם עטיפת wait בלולאת while פוגעת בביצועים?
בפועל העלות זניחה. כל מה שלולאת while מוסיפה הוא בדיקת תנאי אחת בכל פעם שה-thread מתעורר, והשוואה זולה בזמן שכבר מחזיקים את ה-lock. Spurious wakeups עצמם נדירים, כך שאיטרציית הלולאה הנוספת קורה רק במקרים חריגים. המחיר של להשאיר if במקום, לעומת זאת, הוא באג שמשתחזר רק לעיתים נדירות, שבו העיבוד ממשיך כשהתנאי אינו מתקיים — אין השוואה. מה שבאמת שולט בעלות wait של condition variable הוא תחרות על locks ותדירות ה-notify, לא האם ה-while שם.
אם משתמשים ב-predicate wait של C++, אפשר לשכוח מ-spurious wakeup?
ללולאת ה-wait, כן: cv.wait(lock, pred) מריץ בפועל while (!pred()) wait(lock); כך שגם spurious wakeup וגם stolen wakeup נספגים אוטומטית. קוד C++ חדש צריך לבחור כברירת מחדל בעומס ה-predicate. עדיין צריך להגן על עדכונים ל-shared state שה-predicate קורא עם אותו mutex, והמודיע עדיין צריך לעדכן את ה-state לפני קריאה ל-notify. Predicate wait לוקח מכם את הלולאה; הוא לא לוקח מכם את משמעת ה-lock.
האם אותה בעיה קורה עם Monitor.Wait של C#?
כן. Thread שממתין ב-Monitor.Wait מתעורר מ-Pulse/PulseAll ואז לוקח מחדש את ה-lock לפני היציאה מ-Wait, אבל במרווח הזה thread אחר יכול לקחת את ה-lock קודם ולצרוך את התנאי (stolen wakeup). התיעוד של Microsoft כתוב בהנחה שה-thread שהתעורר מעריך מחדש את התנאי שגרם לו להמתין, וקורא שוב ל-Wait אם צריך. לכן הצורה הבסיסית ב-C# היא גם while (!condition) Monitor.Wait(gate);. אילוץ אחד ששונה מ-Win32 הוא שאפשר לקרוא ל-Wait/Pulse רק מתוך משפט lock.
האם spurious wakeup קורה גם כשממתינים ל-event עם WaitForSingleObject?
ב-wait רגיל (לא-alertable), WAIT_OBJECT_0 מוחזר רק כשהאובייקט באמת הופך signaled; אין wakeup בלי סיבה מהסוג שיש ל-condition variable. עם זאת, "ה-event הפך signaled" ו"התנאי של האפליקציה מתקיים" הם דברים שונים. אם כמה consumers מתעוררים מאותו event, ה-thread שלוקח את ה-lock ראשון צורך את התנאי, ולכן עדיין צריך לבדוק שוב את התנאי אחרי wakeup. תכנונים שמנסים לשחזר את ה-notify החולף של condition variable "להעיר רק מי שממתין ברגע הזה" עם event נוטים גם להיתקל בבעיית האמינות של PulseEvent, ולכן ל-wait על מצב שנהיה true בתוך process, condition variable הוא הכלי הבטוח יותר.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג