התעוררויות ספונטניות — למה משתני תנאי מתעוררים "בלי שהודיעו להם" ואיך להמתין נכון ב-Windows

· · Windows, ריבוי תהליכונים, משתני תנאי, סנכרון, C++, C#, Win32 API, חקירת תקלות

“שמנו נתונים בתור והערנו את תהליכון העובד הממתין. זה רץ חצי שנה, ואז יום אחד ניסה לקרוא מתור ריק וקרס.” “אנחנו שולחים את ההודעה, אבל מדי פעם תהליכון לא מתעורר.” — מפגש בין תהליכונים נראה כאילו הוא עובד, והוא קרקע פורייה לבאגים שמופיעים רק לעיתים נדירות. חקירות מהסוג הזה נוחתות לעיתים קרובות על קוד שעוטף wait של משתנה תנאי ב-if. ומאחורי זה יושבת ההתעוררות הספונטנית — התופעה של חזרה מ-wait בלי שקיבלתם הודעה.

“זה מתעורר אף על פי שאף אחד לא הודיע” נשמע כמו פגם במימוש, אבל זו התנהגות ש-Win32, C++ ו-POSIX כולם כותבים בתיעוד או בתקן, ו-Monitor של .NET מתוכנן בהנחה ש”ברגע שהתעוררתם, בודקים שוב את התנאי”. למה ההתנהגות הזו מותרת? באיזו שכבה היא קורה ב-Windows? ואיך כותבים את ההמתנה כך שלעולם לא תיתקלו בה? מיועד למפתחות ולמפתחים שכותבים יישומים עסקיים ותוכנות בקרת ציוד ב-Windows, המאמר הזה מפרק ממקורות ראשוניים מהי התעוררות ספונטנית באמת, ומזקק את ההמתנה הנכונה ל-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
  • Monitor.Wait של C# צריך את אותה משמעת. התנאי יכול להיצרך במרווח בין ההתעוררות ללקיחה מחדש של הנעילה, ולכן בודקים שוב את התנאי ב-while וחוזרים ל-Wait.6
  • עדכנו ובדקו את התנאי תחת אותה נעילה. אם מסתכלים על התנאי מחוץ לנעילה ואז נכנסים ל-wait, הודעה יכולה לעבור דרך הרווח — התעוררות אבודה.1
  • אל תשחזרו את ההודעה החולפת של משתנה תנאי “העיר מי שממתין עכשיו” עם pulse על אירוע. PulseEvent במיוחד יכול להחמיץ את ההודעה ברגע ש-APC במצב ליבה מרים לרגע את ההמתנה, ומיקרוסופט עצמה אומרת, במילים מפורשות, “זה לא אמין, אל תשתמשו בזה, השתמשו במשתנה תנאי במקום”.7

מה שבהמשך עובר על המנגנונים שתומכים במסקנה הזו, לפי הסדר.

2. מהי התעוררות ספונטנית — להתעורר אינו אומר שהתנאי מתקיים

משתנה תנאי הוא פרימיטיב סנכרון ל”השכיב תהליכון עד שתנאי כלשהו מתקיים, והעיר אותו כשזה קורה”. ב-Win32 זה מבנה CONDITION_VARIABLE יחד עם SleepConditionVariableCS / SleepConditionVariableSRW (המתנה) ו-WakeConditionVariable / WakeAllConditionVariable (הודעה). ה-API של ההמתנה משחרר אטומית את הנעילה שאתם מחזיקים (critical section או נעילת SRW) והולך לישון, ובהתעוררות לוקח מחדש את הנעילה לפני החזרה.1

השאלה היא מה העובדה של “חזרתם מ-wait” באמת אומרת. באופן נאיבי רוצים לחשוב “הגיעה הודעה = התנאי מתקיים”, אבל במציאות יש שלושה מקרים שבהם wait חוזר.

מקרה הודעה התנאי בחזרה
התעוררות אמיתית כן לעיתים קרובות מתקיים, אבל לא מובטח
התעוררות ספונטנית אין כזו שמיועדת אליכם עדיין אינו מתקיים
התעוררות גנובה כן תהליכון אחר צרך אותה קודם; אינו מתקיים
שלושה מקרים שבהם wait חוזרהמתנת משתנה תנאי יכולה לחזור לא רק מהודעה אמיתית אלא גם מהתעוררות ספונטנית בלי הודעה ומהתעוררות גנובה שבה הודעה הגיעה אבל התנאי נצרך קודם, לכן כל מקרה צריך בדיקה חוזרת של התנאיחזר מ-waitהודעה אמיתיתהתעוררות ספונטנית(אין הודעה)התעוררות גנובה(התנאי כבר נצרך)בודקים שוב את התנאי, ואז ממשיכים

איור 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++ כולם נחתו בצד של “זה יכול לקרות”. הסיבה כתובה בכנות ב-Rationale של pthread_cond_wait ב-POSIX (The Open Group Base Specifications).3

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

הסיבה השנייה היא ההבחנה שהפשרה הזו אינה שוברת יישומים — היא למעשה הופכת אותם לחזקים יותר. כי התעוררויות ספונטניות מותרות, קוד נכון תמיד כותב לולאה שבודקת את הפרדיקט (התנאי שממתינים לו). ה-Rationale של POSIX אומר שכפיית הלולאה הזו הופכת את הקוד למתעד-את-עצמו ולחזק יותר.3 ברגע שהלולאה שם, משמעות ההודעה יורדת מ”ערובה שהתנאי מתקיים” ל”רמז שהתנאי אולי השתנה”, וצד ההמתנה סובלני לשינויי תכנון צנועים בצד המודיע (להעיר יותר מדי, להעיר באצווה וכדומה).

התעוררויות גנובות הן עניין מבני עוד יותר. תמיד יש פער זמן בין שהמודיע קורא ל-WakeConditionVariable לבין שהתהליכון שהתעורר לוקח מחדש את הנעילה וחוזר מ-wait. אם תהליכון שלישי יכול לקחת את הנעילה במרווח הזה, הוא יכול לצרוך את התנאי (תוכן התור וכדומה) קודם. זה פער שאף ליטוש של המימוש לא יכול למחוק, כי הוא בא מצורת כלי משתנה התנאי עצמו.

ציר הזמן של התעוררות גנובההיצרן שם פריט אחד בתור ומעיר את צרכן A הממתין, אבל לפני ש-A לוקח מחדש את הנעילה צרכן B לוקח את הנעילה ולוקח את הפריט האחד, כך שהתור ריק עד ש-A מתעוררצרכן Bיצרןצרכן A(ממתין)צרכן Bיצרןצרכן A(ממתין)התעורר, ממתין ללקיחה מחדש של הנעילההתור ריק(נגנב)הוספת פריט אחד לתורWakeConditionVariableלקיחת הנעילה ולקיחת פריט אחדלקיחה מחדש של הנעילה וחזרה מ-waitבדיקה חוזרת בלולאת while והמתנה שוב

איור 2: “התעוררות גנובה”, שבה תהליכון שלישי צורך את התנאי בפער הזמן בין הודעה להתעוררות, יכולה לקרות תחת כל מימוש.

במילים אחרות, גם אם מערכת ההפעלה הייתה ממגרת לחלוטין התעוררויות ספונטניות, כל עוד התעוררויות גנובות קיימות עדיין אי אפשר לכתוב “התעוררתי = התנאי מתקיים”. לולאת הבדיקה החוזרת של הממתין נדרשת בכל מקרה, ובהינתן זאת, זול יותר להתיר התעוררויות ספונטניות ולשמור על המימוש מהיר — זה שיפוט התכנון שמשתני תנאי נושאים כבר עשרות שנים.

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

התכונה הזו מראה את פניה בכל שכבה של פרימיטיב סנכרון ב-Windows שתשתמשו בה. כדי להרגיש שאי אפשר לברוח מזה לא משנה מול איזו שכבת API כותבים, נסתכל על השכבות הייצוגיות.

משתני תנאי של Win32 (CONDITION_VARIABLE) מתועדים, כפי שכבר צוין, על SleepConditionVariableCS / SleepConditionVariableSRW ככפופים גם להתעוררויות ספונטניות וגם להתעוררויות גנובות, ואתם נדרשים לבדוק שוב את הפרדיקט בלולאת while.2 דוגמת השימוש הרשמית (תור יצרן–צרכן) גם כותבת את ההמתנה בתוך לולאת while.8

WaitOnAddress הנמוך יותר הוא API המתנה פרימיטיבי יותר ממשתנה תנאי: “המתן עד שהערך בכתובת נתונה משתנה” (Windows 8 ואילך). גם ל-API הקרוב-לתחתית הזה יש תיעוד שקובע “מובטח לחזור כשהכתובת מסומנת, אבל מותר גם לחזור מסיבות אחרות”, ומפרט כדוגמאות להתעוררות מוקדמת מצב זיכרון נמוך, נטישת הערכה קודמת לאותה כתובת, והרצת בניית checked. לכן דוגמת השימוש של התיעוד עצמו היא בצורה של “לולאת while שמשווה את הערך שוב”.9

std::condition_variable של C++ זהה. התיעוד של MSVC אומר על wait בלי פרדיקט שהוא “חוסם עד שמסומן בקריאה ל-notify_one / notify_all. הוא יכול גם להתעורר ספונטנית”, ומסביר שצורת הפרדיקט wait(lock, pred) מריצה בפועל את הקוד הבא.5

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

במילים אחרות, wait בצורת פרדיקט שמומלץ ב-C++ אינו אלא הספרייה שלוקחת מכם את “עטפו ב-while”, כפי שהמאמר מתאר. גם cppreference קובע ש-wait בלי פרדיקט יכול להיות משוחרר ספונטנית.4

Monitor.Wait / Pulse של .NET יש מבנה תורים משלו של תור המתנה ותור מוכן, אבל המשמעת אינה משתנה. תהליכון שמתעורר מ-Pulse / PulseAll עובר לתור המוכן וחוזר מ-Wait בסדר שבו הוא יכול לקחת מחדש את הנעילה. תהליכון אחר שיכול לצרוך את התנאי במרווח לפני שהנעילה נלקחת מחדש זהה ל-Win32, וגם התיעוד כתוב בהנחה ש”התהליכון שהתעורר מעריך מחדש את התנאי שגרם לו להיכנס להמתנה, וקורא שוב ל-Wait אם צריך”.610

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

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

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

מכאן ואילך, מימוש. יש רק שלושה עקרונות.

  1. החזיקו את מה שממתינים לו כמצב (פרדיקט), לא כ”הודעה”. התנאי הוא מצב משותף שמוגן בנעילה — “האם התור אינו ריק?”, “האם הדגל מוגדר?” — לא “האם התעוררתי?”.
  2. שימו תמיד את wait בתוך לולאת while על התנאי. בכל פעם שמתעוררים, בודקים את התנאי, והולכים לישון שוב אם הוא אינו מתקיים.
  3. עדכנו ובדקו את התנאי תחת אותה נעילה. המודיע מעדכן את המצב ואז מודיע.
זרימת לולאת המתנה נכונהלוקחים את הנעילה ובודקים את התנאי; אם אינו מתקיים, משחררים את הנעילה והולכים לישון; בהתעוררות לוקחים מחדש את הנעילה וחוזרים לבדיקת התנאי. ממשיכים עם הנעילה מוחזקת רק כשהתנאי מתקייםלאכןלקיחת הנעילההאם התנאי מתקיים?wait(שחרור הנעילה ושינה)התעוררות(לקיחה מחדש של הנעילה)המשך בזמן שעדיין מחזיקים את הנעילה

איור 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++ — עשו מהמתנת פרדיקט את ברירת המחדל

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);
זרימה נכונה של המתנה עם פקיעת זמןמקבעים קודם את המועד האחרון, ובכל התעוררות בודקים את התנאי ואת המועד האחרון; אם עוד נשאר זמן, מחשבים מחדש את הזמן הנותר וחוזרים להמתנהכןלאכןלאקיבוע המועד האחרוןהאם התנאי מתקיים?המשך לעיבודהאם המועד האחרון עבר?טיפול בפקיעת זמןחישוב זמן נותר והמתנה

איור 5: המתנה עם פקיעת זמן אינה מעבירה שוב “אותו משך המתנה”; היא מחשבת מחדש זמן נותר ממועד אחרון.

ב-C++, אפשר להשאיר את החישוב הזה, כולל המועד האחרון, לעומס wait_until (זמן מוחלט) ועוד פרדיקט. גם כשהוא חוזר על פקיעת זמן הוא נותן לכם את הערך הסופי של הפרדיקט, כך שאפשר גם להחליט “האם פקע הזמן, או שהספקנו?” על הפרדיקט.5

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

בדיקה פעם אחת בלבד עם if. זה כוכב המאמר. ברגע שקורה התעוררות ספונטנית או התעוררות גנובה, העיבוד ממשיך כשהתנאי אינו מתקיים. לקיחה מתור ריק, מגע בנתונים לא מאותחלים, שחרור כפול — הסימפטום הופך ל”קריסה או קלקול נתונים שמופיעים רק מדי פעם”.

בדיקה או עדכון של התנאי מחוץ לנעילה. אם הממתין מסתכל על התנאי מחוץ לנעילה, מחליט “עוד לא”, ובפער לפני כניסה ל-wait המודיע מעדכן את המצב ושולח הודעה, ההודעה נורה על משתנה תנאי בלי ממתין ונעלמת. הממתין אז נכנס ל-wait וממשיך להמתין להודעה שלעולם לא תבוא שוב. זו התעוררות אבודה, תמונת הראי של התעוררות ספונטנית. הסיבה ש-API ההמתנה של משתנה תנאי מתוכנן “לשחרר אטומית את הנעילה ולהירדם” היא בדיוק לסגור את הפער הזה.1 זה לא קורה כל עוד שומרים על משמעת נעילה.

ציר הזמן של התעוררות אבודהאם הממתין בודק את התנאי מחוץ לנעילה והמודיע מעדכן את המצב ומודיע בפער לפני כניסה ל-wait, ההודעה נשלחת למשתנה תנאי בלי ממתין ונעלמת, והממתין ממשיך להמתין להודעה שלעולם לא תבואמודיעממתיןמודיעממתיןאין ממתין ברגע הזהההודעה כבר נעלמה והוא לעולם לא מתעוררבדיקת התנאי מחוץ לנעילה(אינו מתקיים)עדכון המצב והודעהכניסה ל-wait

איור 6: אם בודקים את התנאי מחוץ לנעילה, ההודעה מחליקה דרך הפער בין הבדיקה ל-wait — “התעוררות אבודה”.

שחזור ההודעה החולפת של משתנה תנאי עם pulse על אירוע. אירועים עצמם (CreateEvent + SetEvent) אינם אנטי-דפוס. אות הערכה בהגדרה שבה צרכן יחיד מעבד את התור עד שהוא ריק, או הוראת עצירה שברגע שהורמה לעולם אינה מורדת (אירוע איפוס-ידני), הם שימושים נכונים באירוע; וכשרוצים לחבר אותו עם יעדי המתנה אחרים דרך WaitForMultipleObjects, או לחצות גבול תהליך, משתנה תנאי — אובייקט במצב משתמש שאי אפשר לשתף בין תהליכים — הוא זה שאי אפשר להשתמש בו.1 מה שמסוכן הוא לנסות לשחזר, בפעולות אירוע, את ההודעה החולפת של משתנה תנאי ש”מעירה רק את התהליכונים שממתינים ברגע הזה ולא משאירה מצב מאחור”. הרעיון הזה כמעט תמיד מוביל לפריט הבא, PulseEvent.

שימוש ב-PulseEvent. זה API ש, על אירוע איפוס-ידני, “מעיר את כל מי שממתין כרגע ומחזיר מיד את האירוע למצב לא-מסומן”, אבל מיקרוסופט עצמה קובעת בתיעוד ש”הפונקציה הזו אינה אמינה ואין להשתמש בה. היא קיימת בעיקר לתאימות לאחור. השתמשו במשתנה תנאי במקום.” הסיבה היא שתהליכון ממתין יכול להיות מוסר זמנית ממצב ההמתנה על ידי APC במצב ליבה ולחזור להמתנה אחרי שה-APC מסתיים. אם PulseEvent נקרא במרווח הקצר הזה, התהליכון הזה אינו נכלל בין “אלה שהמתינו ברגע שנקרא” ואינו מתעורר.7 APC של ליבה הם משהו שמערכת ההפעלה משתמשת בו בפנים; היישום לא יכול לשלוט בהם.11 הבעיה הזו היא גם אזהרת ניתוח סטטי (C28648).12 אם התעוררות ספונטנית היא בעיית “להעיר עוד”, זו בעיית “לישון יותר מדי כשצריך היה להתעורר”, ולולאת while לא יכולה להציל אתכם — כי ההודעה עצמה אבדה.

שליחת ההודעה בלבד קודם, בלי להחזיק את הנעילה, לפני עדכון המצב. קריאה ל-WakeConditionVariable בזמן שהמצב עדיין ישן, ורק אז לקיחת הנעילה ועדכון המצב — בסדר הזה, התהליכון שהתעורר עדיין רואה את התנאי כלא מתקיים כשהוא בודק, והולך לישון שוב. אם לא מגיעה הודעה נוספת, הוא נשאר שם. שימו לב שאם כותבים “הודע → עדכן → שחרר” בזמן שעדיין מחזיקים את אותה נעילה, אין נזק אמיתי, כי הממתין לא יכול לבדוק את התנאי עד שהוא לוקח מחדש את הנעילה. גם כך, כדי שקוראים לא יצטרכו לאמת את תנאי הבטיחות הזה בכל פעם, בטוח יותר לתקנן על הסדר “עדכן את המצב תחת הנעילה, והודע אחרי זה”.

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

באגים שכוללים התעוררויות ספונטניות מאופיינים ב”מופיעים רק לעיתים נדירות”. עובדים אחורה מהסימפטום, והם מתפצלים לשתי המשפחות הבאות.

משפחה 1: העיבוד ממשיך כשהתנאי אינו מתקיים. חריגה או קריסה מלקיחה מתור ריק, תוצאות חסרות וכדומה. חשדו בהמתנה בלי פרדיקט. אפשר לסרוק את זה מכנית בסקירת קוד — חפשו מקומות שבהם ל-cv.wait( יש רק ארגומנט אחד, ומקומות שבהם SleepConditionVariableCS / Monitor.Wait עטוף ב-if במקום ב-while. הבדיקה הזו אינה דורשת להמתין לשחזור, והיא המהלך עם המינוף הגבוה ביותר שיש לכם.

משפחה 2: תהליכון שצריך להתעורר אינו (תקיעה). חשדו בהתעוררות אבודה (בדיקת התנאי מחוץ לנעילה, או הודעה מחוץ לנעילה לפני עדכון המצב) וב-PulseEvent. קחו dump מהתהליך התקוע והסתכלו על המחסנית של כל תהליכון, ותוכלו לזהות איזה תהליכון תקוע באיזה API המתנה. משם, רדפו בקוד אחרי “מי היה אמור לשלוח את ההודעה הזו, ובאיזה סדר”.

זרימת מיון מהסימפטוםאם העיבוד ממשיך כשהתנאי אינו מתקיים, סורקים המתנות בלי פרדיקט בחיפוש בקוד; אם תהליכון אינו מתעורר, מזהים את אתר ההמתנה מ-dump וחושדים בהתעוררות אבודה או ב-PulseEventבאג שמופיע רק לעיתים נדירותהעיבוד ממשיך כשהתנאי אינו מתקייםתהליכון שצריך להתעורר אינוחיפוש בקוד אחר המתנות בלי פרדיקטזיהוי תהליכונים ממתינים מ-dumpשינוי if ל-while, או המתנת פרדיקטחשד להתעוררות אבודה או ל-PulseEvent

איור 7: האם הסימפטום הוא “להתקדם יותר מדי” או “לעולם לא להתעורר” מפצל גם במה חושדים וגם איך חוקרים.

אם רוצים לשחזר, המהלך הסטנדרטי הוא להרחיב את חלון המירוץ. מגדילים רעד תזמון בשימוש ביותר תהליכונים מליבות פיזיות, בהכנסת Sleep מכוון בין המתנה להודעה, ובהרצת בניית debug ו-release. כשמאשרים ש”הפסיק להשתחזר אחרי שתיקנו את ההמתנה בלי פרדיקט”, משווים תחת אותו עומס.

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

  • הנתיבים חזרה מ-wait הם שלושה — הודעה אמיתית, התעוררות ספונטנית, והתעוררות גנובה — והקורא לא יכול להבחין ביניהם. לכן תמיד כותבים את ההמתנה כלולאת while על התנאי.
  • התעוררות ספונטנית היא התנהגות ש-Win32, C++ ו-POSIX התירו במכוון כפשרה מול ביצועים, והיא לא תיעלם עם תיקון מערכת הפעלה או החלפת ספרייה. Monitor.Wait של .NET אינו מניח התעוררות בלי סיבה, אבל כי התעוררויות גנובות ופקיעות זמן קיימות, אותה משמעת while עדיין נדרשת.
  • ב-C++, בחרו כברירת מחדל בצורת הפרדיקט wait(lock, pred). הספרייה מבצעת את הלולאה.
  • עדכנו ובדקו את התנאי תחת אותה נעילה. שלחו את ההודעה “אחרי עדכון המצב”. הודעת Win32/C++ יכולה לקרות אחרי שחרור הנעילה; Pulse של C# הוא רק בתוך הנעילה.
  • להמתנה עם פקיעת זמן, קבעו מועד אחרון וחשבו מחדש זמן נותר. ב-C++, wait_until ועוד פרדיקט.
  • אל תשחזרו את ההודעה החולפת של משתנה תנאי עם pulse על אירוע. PulseEvent במיוחד הוא משהו שהתיעוד הרשמי קובע, במילים מפורשות, “אל תשתמשו, השתמשו במשתנה תנאי במקום”. אירועים עצמם נשארים הכלי הנכון להוראת עצירה, לחיבור עם WaitForMultipleObjects, ולסנכרון בין תהליכים.
  • בסקירה, חפשו מכנית אחר “המתנה בלי פרדיקט” ו”if + המתנה”. אפשר להרוג באג שנדיר לשחזר בלי להמתין לשחזור.

התעוררות ספונטנית, בניגוד למוזרות השם, מתעבה למילת מפתח של שורה אחת לתיקון — שנו if ל-while. ומאחורי השורה האחת הזו יושבת רעיון התכנון של כלי משתנה התנאי: “הודעה מדויקת יקרה, ולכן הבדיקה היא אחריות הממתין”. הבינו זאת כמנגנון ותוכלו להחיל את אותה משמעת בלי היסוס כשהשפה או המסגרת משתנות.

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

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

KomuraSoft LLC מטפלת בסקירות תכנון ריבוי תהליכונים, בחקירת שורש (ניתוח dump) של קריסות ותקיעות ש”משתחזרות רק מדי פעם”, ובהעברת קוד סנכרון ישן (תלוי-אירוע ו-PulseEvent וכדומה) לבסיס של משתנה תנאי. אפשר להתחיל ממיון הסימפטום — אתם מוזמנים לפנות.

קישורים

  1. Microsoft Learn, Condition Variables. על כך שמשתנה תנאי הוא אובייקט במצב משתמש שמשחרר נעילה אטומית ונכנס להמתנה; על כך שיש התעוררויות ספונטניות (התעוררויות שאינן קשורות להערכה מפורשת) והתעוררויות גנובות (תהליכון אחר רץ לפני התהליכון שהתעורר), כך שאחרי חזרה מהמתנה צריך לבדוק שוב את הפרדיקט בלולאת while; ועל כך שהודעה אפשרית מתוך הנעילה או מחוצה לה, אבל הערכה אחרי שחרור הנעילה טובה יותר להקטנת החלפות הקשר.  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). על שחרור אטומי של critical section שצוין והמתנה על משתנה תנאי; על כך שהתהליכון שהתעורר לוקח מחדש את ה-critical section לפני החזרה; על החזרת ERROR_TIMEOUT בפקיעת זמן; ועל כך שיש התעוררויות ספונטניות והתעוררויות גנובות, כך שאחרי חזרה מהמתנה צריך לבדוק שוב את הפרדיקט (בדרך כלל בלולאת while).  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. על כך שהתעוררויות ספונטניות מ-pthread_cond_wait / pthread_cond_timedwait יכולות להתרחש; על כך שחזרה מ-wait אינה אומרת דבר על ערך הפרדיקט, ולכן צריך להעריך מחדש את הפרדיקט; ועל כך שה-Rationale קובע שמימוש ש”מעיר בדיוק אחד” יכול להאט פעולות משתנה תנאי במיוחד על מעבדים מרובים, ושהתרת התעוררויות ספונטניות כופה לולאת בדיקת פרדיקט והופכת יישומים לחזקים יותר.  2 3

  4. cppreference.com, std::condition_variable::wait. על כך ש-wait בלי פרדיקט יכול להיות משוחרר בהתעוררות ספונטנית; ועל כך שעומס הפרדיקט שקול ל-while (!pred()) wait(lock); ומוגדר כלולאה שלוקחת מחדש את הנעילה ובודקת את הפרדיקט בכל הודעה או התעוררות ספונטנית.  2

  5. Microsoft Learn, condition_variable Class. על כך ש-wait בלי פרדיקט מצוין כמשתחרר ב-notify_one / notify_all ויכול גם להתעורר ספונטנית; על כך שצורת הפרדיקט wait(lock, pred) מריצה בפועל while (!Pred()) wait(Lck);; ועל כך של-wait_for / wait_until יש אותה תכונה ועומס פרדיקט.  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. על הדוגמה הרשמית שמממשת תור יצרן–צרכן עם critical section אחד ושני משתני תנאי (BufferNotEmpty ו-BufferNotFull). ההמתנה מבוצעת בתוך לולאה שבודקת את הפרדיקט. 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). על כך שהפונקציה שממתינה לשינוי ערך בכתובת מובטחת לחזור כשמסומנת אבל מותר לה גם לחזור מסיבות אחרות; על דוגמאות להתעוררות מוקדמת כולל מצב זיכרון נמוך, נטישת הערכה קודמת לאותה כתובת, והרצת בניית 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 או באובייקט סנכרון אחר. 

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

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

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

שאלות נפוצות

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

האם התעוררות ספונטנית היא באג במערכת ההפעלה או בספרייה?
לא — זה התנהגות שכתובה במפרט. SleepConditionVariableCS של Win32, std::condition_variable של C++, ו-pthread_cond_wait של POSIX, לכולם יש תיעוד רשמי או תקן שקובע במפורש שהתעוררות שאינה קשורה להודעה יכולה להתרחש. מימוש שיאסור זאת אפשרי תיאורטית, אבל הוא היה מאט כל פעולת משתנה תנאי (במיוחד הודעה על מעבדים מרובים), ולכן הפשרה היא להתיר זאת בהבנה ש"הנכונות נשמרת אם הממתין בודק שוב את התנאי". התרופה אינה להמתין לתיקון מערכת הפעלה, אלא תמיד לכתוב המתנה בתוך לולאת while (או להשתמש בהמתנה בצורת פרדיקט).
האם עטיפת המתנה בלולאת while פוגעת בביצועים?
בפועל העלות זניחה. כל מה שלולאת while מוסיפה הוא בדיקת תנאי אחת נוספת בכל התעוררות, והשוואה זולה בזמן שכבר מחזיקים את הנעילה. התעוררויות ספונטניות עצמן נדירות, כך שאיטרציית הלולאה הנוספת קורה רק במקרים חריגים. העלות של להשאיר את הבדיקה כ-if, לעומת זאת, היא "באג שמשתחזר רק לעיתים נדירות" שבו העיבוד ממשיך כשהתנאי אינו מתקיים — אין השוואה. מה שבאמת שולט בעלות המתנת משתנה תנאי הוא תחרות על נעילות ותדירות ההודעה, לא האם ה-while שם.
אם משתמשים בהמתנת פרדיקט של C++, אפשר לשכוח מהתעוררויות ספונטניות?
ללולאת ההמתנה, כן: cv.wait(lock, pred) הוא למעשה while (!pred()) wait(lock); כך שגם התעוררויות ספונטניות וגם התעוררויות גנובות נספגות אוטומטית. קוד C++ חדש צריך לבחור כברירת מחדל בעומס הפרדיקט. עדיין צריך להגן על עדכונים למצב המשותף שהפרדיקט קורא עם אותו mutex, והמודיע עדיין צריך לעדכן את המצב לפני קריאה ל-notify. המתנת פרדיקט לוקחת מכם את הלולאה; היא לא לוקחת מכם את משמעת הנעילה.
האם אותה בעיה קורה עם Monitor.Wait של C#?
כן. תהליכון שממתין ב-Monitor.Wait מתעורר מ-Pulse/PulseAll ואז לוקח מחדש את הנעילה לפני החזרה מ-Wait, אבל במרווח הזה תהליכון אחר יכול לקחת את הנעילה קודם ולצרוך את התנאי (התעוררות גנובה). התיעוד של מיקרוסופט כתוב בהנחה שהתהליכון שהתעורר מעריך מחדש את התנאי שגרם לו להמתין, וקורא שוב ל-Wait אם צריך. לכן הצורה הבסיסית ב-C# היא גם while (!condition) Monitor.Wait(gate);. אילוץ אחד ששונה מ-Win32 הוא שאפשר לקרוא ל-Wait/Pulse רק מתוך משפט lock.
האם התעוררויות ספונטניות קורות גם כשממתינים לאירוע עם WaitForSingleObject?
בהמתנה רגילה (לא-alertable), WAIT_OBJECT_0 מוחזר רק כשהאובייקט באמת הופך מסומן; אין "התעוררות בלי סיבה" מהסוג שיש למשתני תנאי. עם זאת, "האירוע הפך מסומן" ו"התנאי של היישום מתקיים" הם דברים שונים. אם כמה צרכנים מתעוררים מאותו אירוע, התהליכון שלוקח את הנעילה ראשון צורך את התנאי, ולכן עדיין צריך לבדוק שוב את התנאי אחרי התעוררות. תכנונים שמנסים לשחזר את ההודעה החולפת של משתנה תנאי "להעיר רק מי שממתין ברגע הזה" עם אירוע נוטים גם להיתקל בבעיית האמינות של PulseEvent, ולכן להמתנה על תנאי בתוך תהליך, משתנה תנאי הוא הכלי הבטוח יותר.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג