למה עדיף המתנה מונחית אירועים על פני ‏Sleep(1) ב-Windows

· עודכן בתאריך: · · פיתוח Windows, סנכרון, אירועים, טיימר, תכנון

במאמר הקודם, מדריך מעשי ל-soft real-time ב-Windows, כתבנו על הימנעות מלולאה מחזורית שמסתמכת על Sleep. במאמר הזה נתמקד בנקודה אחת מתוך זה - למה עדיף להעדיף event wait על פני timer wait קצר.

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

ב-Windows, כשמתכננים “לבדוק כל פרק זמן קבוע” באמצעות Sleep(1) או wait עם timeout קצר, בהכרח מקבלים השפעה של רזולוציית שעון המערכת ו-עיכוב תזמון שמגיע אחר כך. בהגדרות רגילות, בדרך כלל מונח בבסיס platform timer resolution בסדר גודל של 15.6 מילישנייה, ולכן גם אם הכוונה הייתה “לבדוק שוב אחרי מילישנייה”, בפועל ההמתנה נוטה להיות די גסה.

מצד שני, אם מה שבאמת רוצים להמתין לו - הגעת עבודה, השלמת קלט/פלט, בקשת עצירה, שינוי מצב - הוא לא “זמן” אלא “אירוע”, אין צורך לבדוק במרווחים קבועים. עדיף שהצד שבו קרה האירוע יבצע signal, והצד הממתין יחכה ל-event - זה פתרון ישיר יותר גם ל-latency, גם ל-CPU וגם לצריכת חשמל.

המתנה מונחית אירועיםתרשים שמראה שאם מה שבאמת רוצים להמתין לו הוא אירוע ולא זמן, עדיף שהצד שבו קרה האירוע יבצע signal והצד הממתין יחכה ל-event, במקום לבדוק במרווחים קבועים, כי זה פתרון ישיר יותר ללייטנסי, ל-CPU ולצריכת חשמל.רוצים להמתין לאירועהצד שבו זה קרה עושה signalהצד הממתין מחכה ל-eventפתרון ישיר יותר ללייטנסי, ל-CPU ולחשמל

איור 1: כשממתינים לאירוע, עדיף שהצד שבו הוא קרה יודיע, ולא בדיקה תקופתית קבועה.

השאלות שהמאמר הזה רוצה לענות עליהן הן ארבע אלו:

  • למה Sleep(1) או timer wait קצר לא מדויקים כפי שנדמה
  • למה event wait פחות מושפע מהמגבלה הזו
  • באילו מצבים כדאי לבחור event ולא timer
  • ובכל זאת, באילו מצבים כדאי להשתמש ב-timer

מונחים שמופיעים במאמר הזה

הנה קודם כל קיצורים שמופיעים בגוף המאמר בלי הסבר.

מונח משמעות
‏platform timer resolution /‏ system clock resolution המרווח שבו ה-OS מעדכן את הזמן. קביעת ה-timeout ב-timed wait נגררת אחרי הרזולוציה הזו
‏ISR (Interrupt Service Routine) עיבוד שרץ בעדיפות עליונה כשמתרחשת פסיקה. כל עוד הוא רץ, ה-thread שלכם ממתין
‏DPC (Deferred Procedure Call) עיבוד דחוי בעדיפות גבוהה שה-ISR מניח “להמשיך אחר כך”. מספיק לקרוא ל-ISR ול-DPC יחד גורמי השהיה הנובעים מטיפול בפסיקות במאמר הזה
‏IOCP (I/O Completion Port) המנגנון של Windows שמרכז הודעות השלמה של קלט/פלט אסינכרוני בתור אחד, ומקבל אותן קבוצת threads ייעודית
WaitOnAddress ‏API סנכרון להמתנה “עד שהערך בכתובת זיכרון מסוימת משתנה”. רק בתוך אותו תהליך (מפורט בסעיף 5.3)
‏signal-לעשות לגרום לתנאי שהצד הממתין מחכה לו להתקיים. עבור event זה קריאה ל-SetEvent

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

  • אם ממתינים להגעת עבודה או להשלמת קלט/פלט, עדיף לחכות ל-event ולא לטיימר.
  • ה-timed wait של Windows בהכרח מושפע מרזולוציית שעון המערכת.
  • Sleep(1) לא אומר “יתעורר בדיוק אחרי מילישנייה”.
  • חוץ מזה, גם אחרי שה-timeout עובר, ה-thread רק הופך ל-ready, וריצה מיידית אינה מובטחת.
  • לכן תכנון של “בעצם רוצים להמתין לאירוע, אבל בודקים עם טיימר” נחות גם מבחינת latency וגם מבחינת צריכת חשמל.
  • כדאי לצמצם את השימוש ב-timer רק למקרים שבהם הזמן עצמו הוא באמת התנאי.

בשפה מעשית, זה מסתכם בערך בזה:

  • “לשלוח metrics כל 5 שניות” -> עבודה של timer
  • “לפעול מיד כשנכנסת עבודה לתור” -> עבודה של event / semaphore / condition variable /‏ WaitOnAddress
  • “להמשיך את העיבוד כשקלט/פלט הסתיים” -> עבודה של completion / event
  • “לעצור כשמגיעה בקשת עצירה” -> עבודה של stop event / cancellation
הקו המפריד בין טיימר ל-eventתרשים שמראה שמשתמשים בטיימר רק כשהזמן עצמו הוא התנאי, וכשממתינים לאירוע כמו הגעת עבודה, השלמת קלט/פלט או בקשת עצירה, ממתינים ל-event.הזמן עצמואירוערוצים להמתין לזמן או לאירוע?עבודה של timerעבודה של המתנה ל-eventהגעת עבודה / השלמת קלט-פלט / בקשת עצירה

איור 2: מצמצמים את הטיימר למקרים שהזמן עצמו הוא התנאי, ולאירועים ממתינים עם event.

מפת הידע של המאמר

טיימר wait קצר ב-Windows כבול לרזולוציית שעון המערכת, ובנוסף גם אחרי ה-timeout ה-thread רק הופך ל-ready וזמן תחילת הריצה תלוי במתזמן - אי-ודאות כפולה - ולכן תכנון של polling עם Sleep(1) שבודק תור או בקשת עצירה פחות מדויק ממה שנדמה. אם עוברים למודל מונחה אירועים שבו ה-producer מודיע עם SetEvent וה-consumer ממתין עם WaitForSingleObject או WaitForMultipleObjects, סיום ההמתנה הופך מ’תם הזמן’ ל-‘signal’, וההתעוררויות לשווא נעלמות. השלמת קלט/פלט מתאימה ל-event של overlapped I/O או ל-IOCP, שינוי ערך בתוך אותו תהליך מתאים ל-WaitOnAddress, ורק כשהזמן עצמו הוא התנאי משתמשים ב-waitable timer - העלאת הדיוק עם timeBeginPeriod אינה פתרון שורש.

מפת הידע של תשאול בטיימר מול המתנה מונחית אירועיםתרשים שמראה של-timer wait קצר יש אי-ודאות כפולה של רזולוציית שעון המערכת והשהיית תזמון, איך המתנה מונחית אירועים מתאימה להגעה לתור, השלמת קלט/פלט, בקשת עצירה, ושינוי ערך בתוך אותו תהליך, ואת היחס בין waitable timer ל-WaitOnAddress.מחייבעלול לגרום לעלול לגרום לשימוש לא מומלץ למענה מומלץ למשתמש במענה מומלץ למענה מומלץ לשימוש לא מומלץ למענה מומלץ לשימוש לא מומלץ למענה מומלץ למענה מומלץ לעלול לגרום למענה מומלץ לשימוש לא מומלץ לנבדק באמצעותעלול לגרום למשתמש במשתמש בשימוש לא מומלץ לתשאול בטיימר (לולאת המתנה)תכנון המתנה מונחית אירועיםsystem clock resolution(platform timer resolution)השהיית התזמוןהמתנה להגעת עבודה לתוראובייקט האירוע של Windowsפונקציות ההמתנה של Windows‏ (Wait Functions)Overlapped I/Oהמתנה להשלמת קלט/פלטיציאת השלמת קלט/פלט (IOCP)המתנה לבקשת עצירהWaitOnAddress APIהמתנה לשינוי ערך בתוך אותו תהליךתחרות נתונים (data race)טיימר בר-המתנה (waitable timer)המתנה שהתנאי שלה הוא הזמן עצמו‏timeBeginPeriod (בקשת רזולוציית טיימר)GetSystemTimeAdjustmentגורמי השהיה הנובעים מטיפול בפסיקות (ISR/DPC)

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. מה הבעיה

2.1 ‏timed wait כבול לרזולוציית שעון המערכת

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

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

בדיקת הרזולוציה בסביבה שלכם

“בסדר גודל של 15.6ms” הוא כלל אצבע כללי, ולכן עדיף לבדוק את הערך בסביבה שלכם בעצמכם. יש שתי דרכים לבדוק.

הראשונה היא לקרוא ל-GetSystemTimeAdjustment. הפרמטר השני, lpTimeIncrement, מחזיר את המרווח שבו המערכת מעדכנת את שעון היום ביחידות של 100 ננושנייה. אם מדובר בסדר גודל של 15.6ms, יתקבל ערך בסביבות 150,000.

#include <windows.h>
#include <cstdio>

int main()
{
    DWORD adjustment = 0;
    DWORD increment = 0;
    BOOL adjustmentDisabled = FALSE;

    if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
    {
        std::printf("GetSystemTimeAdjustment failed. GetLastError=%lu\n", GetLastError());
        return 1;
    }

    // increment ביחידות של 100ns, לכן ממירים למילישניות לתצוגה
    std::printf("time increment = %lu (100ns) = %.4f ms\n",
                increment,
                increment / 10000.0);
    return 0;
}

הדרך השנייה היא להריץ את ClockRes של Sysinternals. זהו כלי קטן שקורא בפנים לאותה GetSystemTimeAdjustment ומציג את רזולוציית שעון המערכת - כלומר, את ה-timer resolution המקסימלי שהאפליקציה יכולה לקבל. כשרוצים לבדוק בלי לכתוב קוד, זה הדרך המהירה.

יש לשים לב שבתיעוד, lpTimeIncrement מתואר כערך קבוע שנקבע על ידי המערכת בעת ההפעלה, ולא משתנה תוך כדי ריצה. כלומר זה ערך שמראה “את הרזולוציה הגולמית של הסביבה הזו”, ולא כלי למדידת התוצאה של קריאה ל-timeBeginPeriod. על כך נדבר בסעיף 6.3.

שתי דרכים לבדוק את הרזולוציה הגולמיתתרשים שמראה שאת רזולוציית הטיימר בסביבה שלכם אפשר לבדוק על ידי קריאה ל-GetSystemTimeAdjustment וקבלת המרווח ביחידות של 100 ננושנייה, או על ידי הרצת ClockRes של Sysinternals שקורא בפנים לאותו API.רוצים לדעת את הרזולוציה הגולמיתקריאה ל-GetSystemTimeAdjustmentהרצת ClockResמקבלים מרווח ביחידות 100 ננושנייהבפנים קורא לאותו API

איור 3: אפשר לבדוק את הרזולוציה הגולמית של הסביבה בשתי דרכים - קריאה בקוד או שימוש ב-ClockRes.

2.2 גם כשהזמן מגיע, אין ערובה לביצוע מיידי

עוד סיבוך: ברגע שה-timeout עובר, ה-thread לא בהכרח מתחיל לרוץ באופן מיידי.

כפי שכתוב גם בתיאור של Sleep, אחרי שזמן ההמתנה מסתיים ה-thread הופך ל-ready, אבל אין ערובה שהוא יקבל CPU ויתחיל לרוץ מיד. יש השפעה של threads אחרים, priority, מצב idle של ה-CPU,‏ DPC / ISR, ותחרות על נעילות.

כלומר, ב-timer wait קצר יש לפחות 2 שלבים של אי-ודאות.

  1. עצם קביעת ה-timeout נגררת אחרי רזולוציית הטיימר
  2. גם אחרי ה-timeout, תחילת הריצה תלויה במתזמן
שני שלבי אי-הוודאות של timer wait קצרתרשים שמראה שב-timer wait קצר יש שני שלבי אי-ודאות - קביעת ה-timeout נגררת אחרי רזולוציית הטיימר, ואחר כך ה-thread רק הופך ל-ready ותחילת הריצה תלויה במתזמן.מתחילים timer wait קצרקביעת timeout תלויה ברזולוציהה-thread רק הופך ל-readyתחילת ריצה תלויה במתזמןהשפעה של threads אחרים ו-DPC / ISR

איור 4: קביעת ה-timeout ותחילת הריצה - בשתי הנקודות האלה נוצר פער מזמן ההמתנה שהוזן.

2.3 ‏Sleep(1) לא אומר מחזור של 1ms

כשרואים Sleep(1), קל לחשוב שמדובר ב-loop שרץ “כל 1ms”. אבל בפועל, אסור לקרוא את זה כך.

while (!g_stop)
{
    Step();
    Sleep(1);
}

המצב האמיתי של ה-loop הזה הוא כזה.

  • זמן הריצה של Step() נוסף בכל פעם
  • זמן ההמתנה של Sleep(1) עצמו נגרר אחרי הרזולוציה
  • גם כשמתעוררים, אין ערובה לריצה מיידית
המצב האמיתי של לולאת Sleep(1)תרשים שמראה שלולאת Sleep(1) לא מתאימה למחזור של מילישנייה, כי זמן הריצה של Step נוסף בכל פעם, זמן ההמתנה עצמו נגרר אחרי הרזולוציה, וגם אחרי ההתעוררות אין ערובה לריצה מיידית.זמן ריצה של Step נוסףלא מתאים למחזור של מילישנייהההמתנה נגררת אחרי הרזולוציהאין ערובה לריצה מיידית אחרי ההתעוררות

איור 5: שלושה פערים מצטברים, ולכן Sleep(1) לא מתפקד כמחזור של מילישנייה אחת.

3. למה event wait עדיף

3.1 תנאי סיום ההמתנה הופך מ”תם הזמן” ל-“signal”

היתרון של event wait נובע מכך שהמשמעות של ההמתנה משתנה.

‏timer wait עובד כך:

  • גם אם עדיין שום דבר לא קרה
  • כשמגיע פרק זמן קבוע - מתעוררים
  • ואחרי ההתעוררות בודקים “האם קרה משהו”

‏event wait עובד כך:

  • הצד שבו קרה משהו מבצע signal
  • כש-signal קורה - ההמתנה מסתיימת
  • ברגע ההתעוררות, כבר יש סיבה

כשמציירים את זה בתרשים, רואים שעצם אופן סיום ההמתנה שונה.

ההבדל בין timer wait ל-event wait בסיום ההמתנהתרשים שמראה שב-timer wait מתעוררים לפי רזולוציית הטיימר ובודקים אם קרה משהו, וב-event wait הצד שקרה בו האירוע מבצע signal כך שבזמן ההתעוררות כבר יש סיבה.event wait: מעירים כי קרה משהוהצד שבו זה קרה עושה signalממתיניםבזמן ההתעוררות כבר יש סיבהמעבדיםtimer wait: מתעוררים כי הזמן הגיעלאכןמתעוררים לפי רזולוציית הטיימרממתיניםקרה משהו?מעבדים

איור 6: רק ל-timer wait יש לולאת “התעוררות לשווא”, וב-event wait יש כבר סיבה בזמן ההתעוררות.

רק בצד ה-timer wait יש לולאה שמתעוררת לשווא וחוזרת. זו נקודה שמשפיעה גם על latency וגם על צריכת חשמל.

3.2 בחירת הכלי לפי מה שרוצים להמתין לו

אז איזה כלי בוחרים בפועל? ההחלטה הראשונית מסתכמת בטבלה הזו.

מה רוצים להמתין לו דוגמה לא טובה בחירה ראשונית
שנכנסת עבודה לתור TryPop עם Sleep(1) event / semaphore
שקלט/פלט הסתיים לבדוק את המצב עם timer event של overlapped I/O /‏ IOCP
שמגיעה בקשת עצירה לבדוק stop flag כל 100ms stop event / cancellation
שינוי ערך בתוך אותו תהליך while (flag == 0) Sleep(1) WaitOnAddress
שהגיע זמן מסוים לכפות שימוש ב-event timer / waitable timer

3.3 גם event הוא לא קסם

‏event wait עדיף במובן שבו לא צריך להתעורר לפי רזולוציית הטיימר, אבל זה לא אומר שברגע ה-signal רצים בלייטנסי אפס מוחלט.

גם ב-event wait יש השפעה של:

  • ‏scheduler latency
  • ‏thread priority
  • מצב חשמל של ה-CPU
  • תחרות על נעילות
  • ‏page fault
  • ‏DPC / ISR

אבל לפחות אפשר להיפטר מ“לישון עד ה-tick הבא של הטיימר” - אופן ההמתנה המיותר הזה.

מה event wait מסיר ומה נשארתרשים שמראה שגם ב-event wait יש השפעה של scheduler latency, thread priority ו-DPC / ISR, אבל אופן ההמתנה המיותר של שינה עד ה-tick הבא של הטיימר נעלם.ממתינים עם eventהשפעה של scheduler וכדומה נשארתההמתנה עד ה-tick הבא נעלמתsignal לא אומר לייטנסי אפס

איור 7: event הוא לא קסם, אבל לפחות ההצורך להתעורר לפי רזולוציית הטיימר נעלם בוודאות.

4. אנטי-דפוסים אופייניים

4.1 ‏polling לתור עם Sleep(1)

הכי נפוץ הוא זה.

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

הכתיבה הזו נראית פשוטה, אבל יש בה שלוש בעיות.

  1. מתעוררים בקביעות גם כשהתור ריק
  2. ה-latency נגרר אחרי רזולוציית הטיימר
  3. חיסרון גם מבחינת צריכת חשמל
שלוש הבעיות ב-polling עם Sleep(1)תרשים שמראה שכש-polling לתור נעשה עם Sleep(1), יש שלוש בעיות - מתעוררים בקביעות גם כשהתור ריק, ה-latency נגרר אחרי רזולוציית הטיימר, וחיסרון גם מבחינת צריכת חשמל.Sleep(1) בבדיקה תקופתית של תורמתעוררים גם כשריקlatency נגרר אחרי הרזולוציהחיסרון גם ב-power

איור 8: ל-loop של polling שנראה פשוט יש שלושה חסרונות - התעוררות, עיכוב וצריכת חשמל.

4.2 ניטור מצב עם Thread.Sleep(1) /‏ Task.Delay(1)

אותו ריח מופיע גם ב-‏C# / .NET.

while (!stoppingToken.IsCancellationRequested)
{
    if (_queue.TryDequeue(out WorkItem? item))
    {
        await ProcessAsync(item, stoppingToken);
        continue;
    }

    await Task.Delay(1, stoppingToken);
}

זה נראה עדין בזכות ה-async, אבל מהות התכנון עדיין polling.

5. איך מתקנים את זה

5.1 ה-producer מבצע signal ברגע ההגעה

אם ממתינים להגעה לתור, מחליפים מ-polling ל-signal שמבצע ה-producer.

  • ה-producer מכניס item לתור
  • מיד אחרי ההכנסה, מבצע SetEvent
  • ה-consumer ממתין עם WaitForSingleObject או WaitForMultipleObjects
  • כשמתעורר, מרוקן (drain) את התור
הצורה שבה ה-producer מבצע signalתרשים שמראה שה-producer קורא ל-SetEvent מיד אחרי שהוא מכניס item לתור, ה-consumer ממתין עם WaitForSingleObject וכדומה, וכשמתעורר מרוקן את התור.consumerqueueproducerconsumerqueueproducerמכניס itemמיד אחר כך SetEventמתעורר מההמתנהמרוקן (drain) את התור

איור 9: הצד שיודע שהעבודה הגיעה, ה-producer, מודיע, ולכן ה-consumer לא מתעורר לשווא.

5.2 ממתינים ל-work ול-stop יחד עם WaitForMultipleObjects

עבור worker פשוט, הצורה הזו ברורה.

HANDLE waits[2] = { _stopEvent, _workEvent };  // index 0 = stop, index 1 = work

for (;;)
{
    // bWaitAll = FALSE, לכן הערך המוחזר הוא "האינדקס של ה-handle הראשון שקיבל signal"
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    // כישלון הוא WAIT_FAILED ((DWORD)0xFFFFFFFF). הסיבה מתגלה רק דרך GetLastError
    if (rc == WAIT_FAILED)
    {
        throw std::system_error(
            static_cast<int>(GetLastError()),
            std::system_category(),
            "WaitForMultipleObjects failed.");
    }

    if (rc == WAIT_OBJECT_0)  // stop
    {
        return;
    }

    if (rc == WAIT_OBJECT_0 + 1)  // work
    {
        DrainQueue();
        continue;
    }

    // הגעה לכאן לא צפויה בהמתנת INFINITE
    // (מקרים כמו WAIT_TIMEOUT או WAIT_ABANDONED_0). לא בולעים אלא מפילים
    throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}

בדוגמה הזו יש שלוש נקודות חשובות.

  • Sleep(1) נעלם
  • כשה-item מגיע, ה-producer קורא ל-SetEvent
  • ה-worker ממתין ל-stop ול-work יחד

רק לגבי טיפול בערך המוחזר, נוסיף הערה על נקודה שקל לפספס בפועל.

  • כש-bWaitAll הוא FALSE, הערך המוחזר בהצלחה נמצא בטווח WAIT_OBJECT_0 עד WAIT_OBJECT_0 + nCount - 1, וההפרש מ-WAIT_OBJECT_0 הוא האינדקס במערך. אם כותבים רק שני תנאים - == WAIT_OBJECT_0 ו-!= WAIT_OBJECT_0 + 1 - זה נשבר ברגע שמגדילים ל-3 handles
  • כשכמה handles מקבלים signal בו-זמנית, מוחזר האינדקס הקטן יותר. בדוגמה למעלה, stop ממוקם באינדקס 0 כדי שבקשת העצירה לא תיפספס
  • כישלון לא מוחזר כחריגה, אלא כערך WAIT_FAILED (‏(DWORD)0xFFFFFFFF). הסיבה מתגלה רק על ידי קריאה ל-GetLastError. אם כותבים rc != הערך הצפוי כתנאי כללי ל”כישלון”, נעלמות סיבות כמו handle שנסגר או חוסר הרשאת SYNCHRONIZE
  • אם מערבבים mutex ברשימת ההמתנה, יכולים להתקבל גם ערכים מסוג WAIT_ABANDONED_0. בדוגמה הזו ממתינים רק ל-events, ולכן זה מטופל כלא-צפוי

5.3 באותו תהליך, WaitOnAddress מועמד טוב

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

התחושה לגבי בחירת הכלי היא בערך כך.

היבט event / semaphore / waitable object WaitOnAddress
טווח ההמתנה גם בין תהליכים, אפשר לתת שם רק בתוך אותו תהליך
הצד שמעיר SetEvent /‏ ReleaseSemaphore וכדומה WakeByAddressSingle /‏ WakeByAddressAll
הכנה מוקדמת דרוש יצירת אובייקט kernel וניהול handle מספיק שיש משתנה שממתינים לו
גרסאות נתמכות זמין כבר זמן רב Windows 8 /‏ Windows Server 2012 ואילך
קישור Kernel32.lib Synchronization.lib

יש שלוש נקודות שכדאי לא לפספס בשימוש.

  1. חובה להשתמש בזוג עם WakeByAddressSingle או WakeByAddressAll. אם הצד ששינה את הערך לא קורא לזה, ה-thread הממתין לא יתעורר. אם רוצים להעיר רק אחד - Single, אם את כולם - All
  2. WaitOnAddress עלול לחזור גם בלי שהתבצע signal. גם בתיעוד הרשמי כתוב שאפשר להתעורר מוקדם, למשל במצב זיכרון נמוך. אחרי החזרה, חייבים תמיד לקרוא את הערך שוב כדי לוודא שהוא באמת השתנה - כותבים זאת כ-לולאת while
  3. הגודל שאפשר להמתין לו הוא אחד מתוך 1, 2, 4 או 8 בייטים
  4. חובה שהדגל יהיה אטומי.WakeByAddressSingle רק מעיר את ה-thread הממתין, ואינה הופכת את הכתיבה שקדמה לה לא לאטומית ולא לגלויה. אם קוראים וכותבים משתנה רגיל משני הצדדים, ב-C++ זו תחרות נתונים (התנהגות לא מוגדרת), ובבנייה מותאמת ייתכן שהעדכון לא ייראה ו-thread ימשיך להיתקע גם אחרי שהעירו אותו. הצד הכותב עושה release, הצד הקורא עושה acquire
// הצד הממתין והצד המעיר הם threads שונים, ולכן הדגל חייב להיות אטומי.
// אם ULONG רגיל נקרא ונכתב משני הצדדים, ב-C++ זו תחרות נתונים (התנהגות לא מוגדרת),
// ובבנייה מותאמת ייתכן שהערך יישאר ברגיסטר בלי שהעדכון ייראה,
// וה-thread ימשיך להיתקע גם אחרי שהעירו אותו
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
              "צריך להיות lock-free כי מעבירים אותו ל-WaitOnAddress");

// הצורה הבסיסית של "המתנה עד ש-g_ready מפסיק להיות 0"
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);

while (captured == undesired)
{
    // ייתכנו חזרות מוקדמות, לכן אחרי כל חזרה קוראים שוב
    WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
    captured = g_ready.load(std::memory_order_acquire);
}

הצד שמשנה את הערך מעדכן ומעיר.

// כותבים עם release. כך, גם הנתונים שהוכנו לפני השורה הזו (ה-payload למטה)
// יראו בוודאות אצל הצד שקורא עם acquire. מה שמבטיח את הסדר הוא ה-store הזה,
// לא ה-WakeByAddressSingle ── היא רק מעירה את ה-thread הממתין,
// ואינה הופכת את הכתיבה שקדמה לה לאטומית או לגלויה
g_payload = ...;                                  // נתונים שרוצים להעביר יחד
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);
הזרימה הזוגית של WaitOnAddressתרשים שמראה שהצד המשנה מכין נתונים וכותב את הדגל עם release, ואז מעיר עם WakeByAddressSingle, והצד הממתין קורא שוב את הערך עם acquire בלולאת while כדי להתמודד עם חזרה מוקדמת.הצד הממתיןהדגל האטומיהצד המשנההצד הממתיןהדגל האטומיהצד המשנהקורא עם acquireWaitOnAddress עד שינויכותב עם releaseWakeByAddressSingleאחרי ההתעוררות, קורא שוב

איור 10: מי שמעיר הוא WakeByAddress, ומי שמבטיח את גילוי הערך הם release ו-acquire.

6. מתי בכל זאת משתמשים בטיימר

6.1 כשהזמן עצמו הוא התנאי

כמובן שיש מקומות שבהם באמת משתמשים בטיימר.

  • שליחת metrics כל 5 שניות
  • retry אחרי 200ms
  • ניקוי cache כל דקה
  • המתנה עד שעת יעד ואז timeout

כאן מה שרוצים להמתין לו הוא באמת זמן.

6.2 שימוש ב-waitable timer

ב-Windows, כשממתינים ל”זמן עצמו”, עדיף להשתמש ב-waitable timer במקום לצבור Sleep בצורה רשלנית - זה הופך את הכוונה לברורה יותר.

6.3 לא להפוך את timeBeginPeriod לשימוש קבוע

כשמדאיגה הדיוק של timer wait קצר, קל לרצות להוסיף timeBeginPeriod(1). אבל עדיף לא להפוך את זה לבחירה ראשונה קבועה.

יש שלוש סיבות.

  1. יש עלות ב-power / performance
  2. בגרסאות עדכניות של Windows ההתנהגות מעט מסובכת יותר
  3. לעיתים קרובות זה לא מתקן את שורש הבעיה
למה לא הופכים את timeBeginPeriod לשימוש קבועתרשים שמראה שגם כשהדיוק של timer wait קצר מדאיג, עדיף לא להפוך את timeBeginPeriod לבחירה ראשונה קבועה, כי יש עלות ב-power וב-performance, ההתנהגות ב-Windows החדשות מסובכת יותר, ולעיתים קרובות זה לא מתקן את שורש הבעיה.הדיוק מדאיגרוצים להוסיף timeBeginPeriodלא הופכים לבחירה ראשונה קבועהיש עלותההתנהגות מעט מסובכתשורש הבעיה לא תוקן

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

7. רשימת בדיקה בזמן code review

  • האם נבנתה לולאת בדיקה תקופתית עם Sleep(1) /‏ Thread.Sleep(1) /‏ Task.Delay(1)
  • האם בעצם ממתינים להגעה לתור, להשלמת קלט/פלט או לבקשת עצירה, אבל מבצעים timer poll
  • האם העיצוב מאפשר signal מצד ה-producer או ה-completion
  • אפשר לחכות ל-stop ול-work יחד ב-wait אחד
  • אם מדובר בשינוי ערך בתוך אותו תהליך, אפשר לכתוב עם WaitOnAddress
  • במקום שבו משתמשים בטיימר - האם מה שבאמת רוצים להמתין לו הוא “זמן”

8. סיכום

תכנון של “לבדוק כל פרק זמן קבוע” באמצעות timer wait קצר ב-Windows בהכרח מושפע מרזולוציית הטיימר ומהמתזמן. לכן Sleep(1) או timeout קצר הם המתנה פחות מדויקת ממה שנראה.

מצד שני, כשמה שבאמת רוצים להמתין לו - הגעת עבודה, השלמת קלט/פלט, בקשת עצירה, שינוי מצב - הוא “אירוע”, event wait טבעי הרבה יותר.

לסיכום, זה מתמצה בשורה אחת:

אם ממתינים לזמן - טיימר. אם ממתינים לאירוע - event.

ברגע שהקו הזה ברור,

  • קל יותר לחזות latency
  • פוחתים ה-wakeup תקופתיים המיותרים
  • יש שיפור גם מבחינת צריכת חשמל
  • כוונת הקוד הופכת לברורה יותר

זה משפיע במידה כזו.

זמן שייך לטיימר, אירוע שייך ל-eventתרשים שמראה שכשהקו המפריד ברור - אם ממתינים לזמן משתמשים בטיימר, ואם ממתינים לאירוע משתמשים ב-event - זה מוביל ל-latency שקל לחזות, פחות wakeup תקופתיים מיותרים, שיפור בצריכת חשמל, וכוונת קוד ברורה יותר.זמןאירועמה רוצים להמתין לו?ממתינים עם טיימרממתינים עם eventהקו המפריד ברורlatency קל יותר לחזותפחות wakeup מיותרים

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

9. מקורות

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

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

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

שאלות נפוצות

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

למה Sleep(1) ב-Windows לא מתעורר בדיוק אחרי מילישנייה?
כי הדיוק של timeout ב-timed wait של Windows תלוי ברזולוציית שעון המערכת, ובהגדרות רגילות זה בדרך כלל מבוסס על platform timer resolution בסדר גודל של 15.6 מילישנייה. חוץ מזה, גם כשזמן ההמתנה מסתיים, ה-thread רק הופך ל-ready, ואין ערובה שהוא יקבל CPU ויתחיל לרוץ מיד. יש השפעה של threads אחרים, priority, מצב idle של ה-CPU,‏ DPC / ISR, ותחרות על נעילות. במילים אחרות, ב-timer wait קצר יש לפחות שני שלבים של אי-ודאות - קביעת ה-timeout מושפעת מרזולוציית הטיימר, וגם התחלת הריצה בפועל אחרי ה-timeout תלויה במתזמן.
מה הבעיה בעיצוב שבו מבצעים polling לתור עם Sleep(1) או Task.Delay(1)?
יש שלוש בעיות. הראשונה - מתעוררים בקביעות גם כשהתור ריק. השנייה - ה-latency נגרר אחרי רזולוציית הטיימר. השלישית - יש חיסרון גם מבחינת צריכת חשמל. גם לולאת await Task.Delay(1) ב-C# נראית עדינה, אבל מהותה התכנונית היא עדיין polling. הדרך לתקן היא לגרום ל-producer לקרוא ל-SetEvent מיד אחרי שהוא מכניס פריט לתור, וה-consumer מחכה עם WaitForSingleObject או WaitForMultipleObjects. אם ממתינים ל-stop event ול-work event יחד ב-wait אחד, אפשר להגיב מיד גם לבקשת עצירה.
איך בוחרים בין המתנה בטיימר להמתנה באירוע?
הקו המפריד הוא: אם ממתינים לזמן - טיימר, אם ממתינים לאירוע - event. עיבוד שבו הזמן עצמו הוא התנאי, כמו שליחת metrics כל 5 שניות, הוא עבודה של waitable timer. הגעת עבודה לתור מתאימה ל-event או semaphore, השלמת קלט/פלט מתאימה ל-event של overlapped I/O או ל-IOCP, בקשת עצירה מתאימה ל-stop event או cancellation, ושינוי ערך בתוך אותו תהליך מתאים ל-WaitOnAddress. כשהקו הזה ברור, קל יותר לחזות latency, פוחתים ה-wakeup התקופתיים המיותרים, וגם כוונת הקוד ברורה יותר.
אפשר לפתור את זה פשוט על ידי הגברת דיוק הטיימר עם timeBeginPeriod(1)?
עדיף לא להפוך את זה לבחירה ראשונה קבועה. יש שלוש סיבות - יש עלות ב-power וב-performance, ההתנהגות מעט מסובכת יותר ב-Windows החדשות, ולעיתים קרובות זה לא מתקן את שורש הבעיה. אם מה שבאמת רוצים להמתין לו הוא אירוע כמו הגעת עבודה או השלמת קלט/פלט, עדיף לשנות את התכנון להמתנה מונחית אירועים שבה הצד שמתעורר מבצע signal, במקום להעלות את דיוק הטיימר - זה פתרון ישיר יותר גם ל-latency, גם ל-CPU וגם לצריכת חשמל.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג