התעוררויות ספונטניות — למה משתני תנאי מתעוררים "בלי שהודיעו להם" ואיך להמתין נכון ב-Windows
· Go Komura · 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 חוזר.
| מקרה | הודעה | התנאי בחזרה |
|---|---|---|
| התעוררות אמיתית | כן | לעיתים קרובות מתקיים, אבל לא מובטח |
| התעוררות ספונטנית | אין כזו שמיועדת אליכם | עדיין אינו מתקיים |
| התעוררות גנובה | כן | תהליכון אחר צרך אותה קודם; אינו מתקיים |
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++ כולם נחתו בצד של “זה יכול לקרות”. הסיבה כתובה בכנות ב-Rationale של pthread_cond_wait ב-POSIX (The Open Group Base Specifications).3
הסיבה הראשונה היא ביצועים. ניסיון לממש הודעה ש”מעירה באמינות בדיוק תהליכון אחד” בקפדנות, במיוחד על מעבדים מרובים, מוסיף עלות סנכרון נוספת לכל פעולת משתנה תנאי. מתזמן יושב בין הודעה להתעוררות, ותלוי בתזמון של פסיקות והפקעה אי אפשר להימנע מ”תהליכון אחר רץ לפני זה שהתכוונתם להעיר”. לגבות מכולם את העלות של לאטום את זה לחלוטין גרוע יותר, לשמירה על משתני תנאי מהירים, מאשר לקבל ש”לפעמים אתם מעירים עוד אחד”.
הסיבה השנייה היא ההבחנה שהפשרה הזו אינה שוברת יישומים — היא למעשה הופכת אותם לחזקים יותר. כי התעוררויות ספונטניות מותרות, קוד נכון תמיד כותב לולאה שבודקת את הפרדיקט (התנאי שממתינים לו). ה-Rationale של POSIX אומר שכפיית הלולאה הזו הופכת את הקוד למתעד-את-עצמו ולחזק יותר.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: “התעוררות גנובה”, שבה תהליכון שלישי צורך את התנאי בפער הזמן בין הודעה להתעוררות, יכולה לקרות תחת כל מימוש.
במילים אחרות, גם אם מערכת ההפעלה הייתה ממגרת לחלוטין התעוררויות ספונטניות, כל עוד התעוררויות גנובות קיימות עדיין אי אפשר לכתוב “התעוררתי = התנאי מתקיים”. לולאת הבדיקה החוזרת של הממתין נדרשת בכל מקרה, ובהינתן זאת, זול יותר להתיר התעוררויות ספונטניות ולשמור על המימוש מהיר — זה שיפוט התכנון שמשתני תנאי נושאים כבר עשרות שנים.
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
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++ — עשו מהמתנת פרדיקט את ברירת המחדל
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 וממשיך להמתין להודעה שלעולם לא תבוא שוב. זו התעוררות אבודה, תמונת הראי של התעוררות ספונטנית. הסיבה ש-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 ש, על אירוע איפוס-ידני, “מעיר את כל מי שממתין כרגע ומחזיר מיד את האירוע למצב לא-מסומן”, אבל מיקרוסופט עצמה קובעת בתיעוד ש”הפונקציה הזו אינה אמינה ואין להשתמש בה. היא קיימת בעיקר לתאימות לאחור. השתמשו במשתנה תנאי במקום.” הסיבה היא שתהליכון ממתין יכול להיות מוסר זמנית ממצב ההמתנה על ידי APC במצב ליבה ולחזור להמתנה אחרי שה-APC מסתיים. אם PulseEvent נקרא במרווח הקצר הזה, התהליכון הזה אינו נכלל בין “אלה שהמתינו ברגע שנקרא” ואינו מתעורר.7 APC של ליבה הם משהו שמערכת ההפעלה משתמשת בו בפנים; היישום לא יכול לשלוט בהם.11 הבעיה הזו היא גם אזהרת ניתוח סטטי (C28648).12 אם התעוררות ספונטנית היא בעיית “להעיר עוד”, זו בעיית “לישון יותר מדי כשצריך היה להתעורר”, ולולאת while לא יכולה להציל אתכם — כי ההודעה עצמה אבדה.
שליחת ההודעה בלבד קודם, בלי להחזיק את הנעילה, לפני עדכון המצב. קריאה ל-WakeConditionVariable בזמן שהמצב עדיין ישן, ורק אז לקיחת הנעילה ועדכון המצב — בסדר הזה, התהליכון שהתעורר עדיין רואה את התנאי כלא מתקיים כשהוא בודק, והולך לישון שוב. אם לא מגיעה הודעה נוספת, הוא נשאר שם. שימו לב שאם כותבים “הודע → עדכן → שחרר” בזמן שעדיין מחזיקים את אותה נעילה, אין נזק אמיתי, כי הממתין לא יכול לבדוק את התנאי עד שהוא לוקח מחדש את הנעילה. גם כך, כדי שקוראים לא יצטרכו לאמת את תנאי הבטיחות הזה בכל פעם, בטוח יותר לתקנן על הסדר “עדכן את המצב תחת הנעילה, והודע אחרי זה”.
7. איך לחקור כשנתקלים בזה
באגים שכוללים התעוררויות ספונטניות מאופיינים ב”מופיעים רק לעיתים נדירות”. עובדים אחורה מהסימפטום, והם מתפצלים לשתי המשפחות הבאות.
משפחה 1: העיבוד ממשיך כשהתנאי אינו מתקיים. חריגה או קריסה מלקיחה מתור ריק, תוצאות חסרות וכדומה. חשדו בהמתנה בלי פרדיקט. אפשר לסרוק את זה מכנית בסקירת קוד — חפשו מקומות שבהם ל-cv.wait( יש רק ארגומנט אחד, ומקומות שבהם SleepConditionVariableCS / Monitor.Wait עטוף ב-if במקום ב-while. הבדיקה הזו אינה דורשת להמתין לשחזור, והיא המהלך עם המינוף הגבוה ביותר שיש לכם.
משפחה 2: תהליכון שצריך להתעורר אינו (תקיעה). חשדו בהתעוררות אבודה (בדיקת התנאי מחוץ לנעילה, או הודעה מחוץ לנעילה לפני עדכון המצב) וב-PulseEvent. קחו dump מהתהליך התקוע והסתכלו על המחסנית של כל תהליכון, ותוכלו לזהות איזה תהליכון תקוע באיזה API המתנה. משם, רדפו בקוד אחרי “מי היה אמור לשלוח את ההודעה הזו, ובאיזה סדר”.
flowchart TB
accTitle: זרימת מיון מהסימפטום
accDescr: אם העיבוד ממשיך כשהתנאי אינו מתקיים, סורקים המתנות בלי פרדיקט בחיפוש בקוד; אם תהליכון אינו מתעורר, מזהים את אתר ההמתנה מ-dump וחושדים בהתעוררות אבודה או ב-PulseEvent
s["באג שמופיע רק לעיתים נדירות"] --> a["העיבוד ממשיך כשהתנאי אינו מתקיים"]
s --> b["תהליכון שצריך להתעורר אינו"]
a --> a1["חיפוש בקוד אחר המתנות בלי פרדיקט"]
b --> b1["זיהוי תהליכונים ממתינים מ-dump"]
a1 -.-> a2["שינוי if ל-while, או המתנת פרדיקט"]
b1 -.-> b2["חשד להתעוררות אבודה או ל-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. ומאחורי השורה האחת הזו יושבת רעיון התכנון של כלי משתנה התנאי: “הודעה מדויקת יקרה, ולכן הבדיקה היא אחריות הממתין”. הבינו זאת כמנגנון ותוכלו להחיל את אותה משמעת בלי היסוס כשהשפה או המסגרת משתנות.
מאמרים קשורים
- Practical Multithreading Best Practices: C++ Edition — Eliminating Accidents by Structure with RAII and jthread
- Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
- Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
- למה עדיף המתנה מונחית אירועים על פני Sleep(1) ב-Windows
- המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
- The Depths of Windows I/O (Part 2) — Synchronous and Asynchronous I/O: What OVERLAPPED Really Means
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בסקירות תכנון ריבוי תהליכונים, בחקירת שורש (ניתוח dump) של קריסות ותקיעות ש”משתחזרות רק מדי פעם”, ובהעברת קוד סנכרון ישן (תלוי-אירוע ו-PulseEvent וכדומה) לבסיס של משתנה תנאי. אפשר להתחיל ממיון הסימפטום — אתם מוזמנים לפנות.
קישורים
-
Microsoft Learn, Condition Variables. על כך שמשתנה תנאי הוא אובייקט במצב משתמש שמשחרר נעילה אטומית ונכנס להמתנה; על כך שיש התעוררויות ספונטניות (התעוררויות שאינן קשורות להערכה מפורשת) והתעוררויות גנובות (תהליכון אחר רץ לפני התהליכון שהתעורר), כך שאחרי חזרה מהמתנה צריך לבדוק שוב את הפרדיקט בלולאת while; ועל כך שהודעה אפשרית מתוך הנעילה או מחוצה לה, אבל הערכה אחרי שחרור הנעילה טובה יותר להקטנת החלפות הקשר. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). על שחרור אטומי של critical section שצוין והמתנה על משתנה תנאי; על כך שהתהליכון שהתעורר לוקח מחדש את ה-critical section לפני החזרה; על החזרת 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. על הדוגמה הרשמית שמממשת תור יצרן–צרכן עם critical section אחד ושני משתני תנאי (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"
למה אסור לקרוא ל-LoadLibrary או להסתנכרן עם תהליכונים אחרים מתוך DllMain. המאמר מסביר ממקורות ראשוניים איך נעילת הטוען מסדרת כל הודעת DLL...
Win32 Thread Pool API — מקביליות בלי ליצור תהליכונים, דרך CreateThreadpoolWork
מפזרים קריאות CreateThread בכל הקוד הנייטיב? המאמר מסביר את ה-API של מאגר התהליכונים של Win32 שעוצב מחדש ב-Vista — ארבעת האובייקטים work,...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון ועד אבטחה
מדריך מעשי ל-Named Pipes, התקשורת הסטנדרטית בין תהליכים ב-Windows. המאמר מסדר ממקורות ראשוניים את הבחירה בין מצב בתים למצב הודעות, תכנון ...
מה "לא מגיב" באמת — איך Windows מחליט שיישום נתקע, ואיך לתכנן יישומים שלא
"לא מגיב" של Windows הוא מנגנון שבו מערכת ההפעלה קובעת שחלון לא שלף הודעה במשך 5 שניות ומחליפה אותו בחלון רפאים. המאמר מכסה את פנים השיפו...
יישומים שנשברים בחזרה משינה — איך אירועי צריכת החשמל של Windows עובדים ואיך לבנות יישומים עסקיים ששורדים אותם
פתחתם את המחשב הנייד והחיבורים של היישום העסקי היו מתים — הסיבה היא תכנון שלא חשב על שינה. המאמר מכסה את זרימת ההודעות WM_POWERBROADCAST,...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם התעוררות ספונטנית היא באג במערכת ההפעלה או בספרייה?
- לא — זה התנהגות שכתובה במפרט. 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, ולכן להמתנה על תנאי בתוך תהליך, משתנה תנאי הוא הכלי הבטוח יותר.