למה עדיף המתנה מונחית אירועים על פני Sleep(1) ב-Windows
· עודכן בתאריך: · Go Komura · פיתוח 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 וגם לצריכת חשמל.
flowchart TB
accTitle: המתנה מונחית אירועים
accDescr: תרשים שמראה שאם מה שבאמת רוצים להמתין לו הוא אירוע ולא זמן, עדיף שהצד שבו קרה האירוע יבצע signal והצד הממתין יחכה ל-event, במקום לבדוק במרווחים קבועים, כי זה פתרון ישיר יותר ללייטנסי, ל-CPU ולצריכת חשמל.
d1["רוצים להמתין לאירוע"] --> d2["הצד שבו זה קרה עושה signal"]
d2 --> d3["הצד הממתין מחכה ל-event"]
d3 -.-> d4["פתרון ישיר יותר ללייטנסי, ל-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
flowchart TB
accTitle: הקו המפריד בין טיימר ל-event
accDescr: תרשים שמראה שמשתמשים בטיימר רק כשהזמן עצמו הוא התנאי, וכשממתינים לאירוע כמו הגעת עבודה, השלמת קלט/פלט או בקשת עצירה, ממתינים ל-event.
q1{"רוצים להמתין לזמן או לאירוע?"}
q1 -->|"הזמן עצמו"| t1["עבודה של timer"]
q1 -->|"אירוע"| e1["עבודה של המתנה ל-event"]
e1 -.-> rei["הגעת עבודה / השלמת קלט-פלט / בקשת עצירה"]
איור 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 אינה פתרון שורש.
flowchart LR
accTitle: מפת הידע של תשאול בטיימר מול המתנה מונחית אירועים
accDescr: תרשים שמראה של-timer wait קצר יש אי-ודאות כפולה של רזולוציית שעון המערכת והשהיית תזמון, איך המתנה מונחית אירועים מתאימה להגעה לתור, השלמת קלט/פלט, בקשת עצירה, ושינוי ערך בתוך אותו תהליך, ואת היחס בין waitable timer ל-WaitOnAddress.
timer_polling["תשאול בטיימר (לולאת המתנה)"]
event_driven_wait["תכנון המתנה מונחית אירועים"]
system_clock_resolution["system clock resolution(platform timer resolution)"]
scheduler_latency["השהיית התזמון"]
queue_arrival_wait["המתנה להגעת עבודה לתור"]
windows_event_object["אובייקט האירוע של Windows"]
wait_functions["פונקציות ההמתנה של Windows (Wait Functions)"]
overlapped_io["Overlapped I/O"]
io_completion_wait["המתנה להשלמת קלט/פלט"]
iocp["יציאת השלמת קלט/פלט (IOCP)"]
stop_request_wait["המתנה לבקשת עצירה"]
waitonaddress["WaitOnAddress API"]
same_process_value_change_wait["המתנה לשינוי ערך בתוך אותו תהליך"]
data_race["תחרות נתונים (data race)"]
waitable_timer["טיימר בר-המתנה (waitable timer)"]
time_based_wait["המתנה שהתנאי שלה הוא הזמן עצמו"]
timebeginperiod["timeBeginPeriod (בקשת רזולוציית טיימר)"]
getsystemtimeadjustment["GetSystemTimeAdjustment"]
interrupt_processing_delay["גורמי השהיה הנובעים מטיפול בפסיקות (ISR/DPC)"]
timer_polling -->|"מחייב"| system_clock_resolution
timer_polling -.->|"עלול לגרום ל"| scheduler_latency
event_driven_wait -.->|"עלול לגרום ל"| scheduler_latency
timer_polling -->|"שימוש לא מומלץ ל"| queue_arrival_wait
windows_event_object -->|"מענה מומלץ ל"| queue_arrival_wait
wait_functions -->|"משתמש ב"| windows_event_object
overlapped_io -->|"מענה מומלץ ל"| io_completion_wait
iocp -->|"מענה מומלץ ל"| io_completion_wait
timer_polling -->|"שימוש לא מומלץ ל"| io_completion_wait
windows_event_object -->|"מענה מומלץ ל"| stop_request_wait
timer_polling -->|"שימוש לא מומלץ ל"| stop_request_wait
wait_functions -->|"מענה מומלץ ל"| stop_request_wait
waitonaddress -->|"מענה מומלץ ל"| same_process_value_change_wait
waitonaddress -.->|"עלול לגרום ל"| data_race
waitable_timer -->|"מענה מומלץ ל"| time_based_wait
timebeginperiod -->|"שימוש לא מומלץ ל"| queue_arrival_wait
system_clock_resolution -->|"נבדק באמצעות"| getsystemtimeadjustment
interrupt_processing_delay -.->|"עלול לגרום ל"| scheduler_latency
iocp -->|"משתמש ב"| overlapped_io
wait_functions -->|"משתמש ב"| waitable_timer
timer_polling -->|"שימוש לא מומלץ ל"| same_process_value_change_wait
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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.
flowchart TB
accTitle: שתי דרכים לבדוק את הרזולוציה הגולמית
accDescr: תרשים שמראה שאת רזולוציית הטיימר בסביבה שלכם אפשר לבדוק על ידי קריאה ל-GetSystemTimeAdjustment וקבלת המרווח ביחידות של 100 ננושנייה, או על ידי הרצת ClockRes של Sysinternals שקורא בפנים לאותו API.
g1["רוצים לדעת את הרזולוציה הגולמית"] --> g2["קריאה ל-GetSystemTimeAdjustment"]
g1 --> g3["הרצת ClockRes"]
g2 --> g4["מקבלים מרווח ביחידות 100 ננושנייה"]
g3 -.-> g5["בפנים קורא לאותו API"]
איור 3: אפשר לבדוק את הרזולוציה הגולמית של הסביבה בשתי דרכים - קריאה בקוד או שימוש ב-ClockRes.
2.2 גם כשהזמן מגיע, אין ערובה לביצוע מיידי
עוד סיבוך: ברגע שה-timeout עובר, ה-thread לא בהכרח מתחיל לרוץ באופן מיידי.
כפי שכתוב גם בתיאור של Sleep, אחרי שזמן ההמתנה מסתיים ה-thread הופך ל-ready, אבל אין ערובה שהוא יקבל CPU ויתחיל לרוץ מיד.
יש השפעה של threads אחרים, priority, מצב idle של ה-CPU, DPC / ISR, ותחרות על נעילות.
כלומר, ב-timer wait קצר יש לפחות 2 שלבים של אי-ודאות.
- עצם קביעת ה-timeout נגררת אחרי רזולוציית הטיימר
- גם אחרי ה-timeout, תחילת הריצה תלויה במתזמן
flowchart TB
accTitle: שני שלבי אי-הוודאות של timer wait קצר
accDescr: תרשים שמראה שב-timer wait קצר יש שני שלבי אי-ודאות - קביעת ה-timeout נגררת אחרי רזולוציית הטיימר, ואחר כך ה-thread רק הופך ל-ready ותחילת הריצה תלויה במתזמן.
s1["מתחילים timer wait קצר"] --> s2["קביעת timeout תלויה ברזולוציה"]
s2 --> s3["ה-thread רק הופך ל-ready"]
s3 --> s4["תחילת ריצה תלויה במתזמן"]
s3 -.-> s5["השפעה של threads אחרים ו-DPC / ISR"]
איור 4: קביעת ה-timeout ותחילת הריצה - בשתי הנקודות האלה נוצר פער מזמן ההמתנה שהוזן.
2.3 Sleep(1) לא אומר מחזור של 1ms
כשרואים Sleep(1), קל לחשוב שמדובר ב-loop שרץ “כל 1ms”.
אבל בפועל, אסור לקרוא את זה כך.
while (!g_stop)
{
Step();
Sleep(1);
}
המצב האמיתי של ה-loop הזה הוא כזה.
- זמן הריצה של
Step()נוסף בכל פעם - זמן ההמתנה של
Sleep(1)עצמו נגרר אחרי הרזולוציה - גם כשמתעוררים, אין ערובה לריצה מיידית
flowchart TB
accTitle: המצב האמיתי של לולאת Sleep(1)
accDescr: תרשים שמראה שלולאת Sleep(1) לא מתאימה למחזור של מילישנייה, כי זמן הריצה של Step נוסף בכל פעם, זמן ההמתנה עצמו נגרר אחרי הרזולוציה, וגם אחרי ההתעוררות אין ערובה לריצה מיידית.
p1["זמן ריצה של Step נוסף"] --> p4["לא מתאים למחזור של מילישנייה"]
p2["ההמתנה נגררת אחרי הרזולוציה"] --> p4
p3["אין ערובה לריצה מיידית אחרי ההתעוררות"] --> p4
איור 5: שלושה פערים מצטברים, ולכן Sleep(1) לא מתפקד כמחזור של מילישנייה אחת.
3. למה event wait עדיף
3.1 תנאי סיום ההמתנה הופך מ”תם הזמן” ל-“signal”
היתרון של event wait נובע מכך שהמשמעות של ההמתנה משתנה.
timer wait עובד כך:
- גם אם עדיין שום דבר לא קרה
- כשמגיע פרק זמן קבוע - מתעוררים
- ואחרי ההתעוררות בודקים “האם קרה משהו”
event wait עובד כך:
- הצד שבו קרה משהו מבצע signal
- כש-signal קורה - ההמתנה מסתיימת
- ברגע ההתעוררות, כבר יש סיבה
כשמציירים את זה בתרשים, רואים שעצם אופן סיום ההמתנה שונה.
flowchart TB
accTitle: ההבדל בין timer wait ל-event wait בסיום ההמתנה
accDescr: תרשים שמראה שב-timer wait מתעוררים לפי רזולוציית הטיימר ובודקים אם קרה משהו, וב-event wait הצד שקרה בו האירוע מבצע signal כך שבזמן ההתעוררות כבר יש סיבה.
subgraph TimerWait["timer wait: מתעוררים כי הזמן הגיע"]
T1["ממתינים"] --> T2["מתעוררים לפי רזולוציית הטיימר"]
T2 --> T3{"קרה משהו?"}
T3 -- "לא" --> T1
T3 -- "כן" --> T4["מעבדים"]
end
subgraph EventWait["event wait: מעירים כי קרה משהו"]
E1["ממתינים"] --> E2["הצד שבו זה קרה עושה signal"]
E2 --> E3["בזמן ההתעוררות כבר יש סיבה"]
E3 --> E4["מעבדים"]
end
איור 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 הבא של הטיימר” - אופן ההמתנה המיותר הזה.
flowchart TB
accTitle: מה event wait מסיר ומה נשאר
accDescr: תרשים שמראה שגם ב-event wait יש השפעה של scheduler latency, thread priority ו-DPC / ISR, אבל אופן ההמתנה המיותר של שינה עד ה-tick הבא של הטיימר נעלם.
e1["ממתינים עם event"] --> e2["השפעה של scheduler וכדומה נשארת"]
e1 --> e3["ההמתנה עד ה-tick הבא נעלמת"]
e2 -.-> e4["signal לא אומר לייטנסי אפס"]
איור 7: event הוא לא קסם, אבל לפחות ההצורך להתעורר לפי רזולוציית הטיימר נעלם בוודאות.
4. אנטי-דפוסים אופייניים
4.1 polling לתור עם Sleep(1)
הכי נפוץ הוא זה.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
הכתיבה הזו נראית פשוטה, אבל יש בה שלוש בעיות.
- מתעוררים בקביעות גם כשהתור ריק
- ה-latency נגרר אחרי רזולוציית הטיימר
- חיסרון גם מבחינת צריכת חשמל
flowchart TB
accTitle: שלוש הבעיות ב-polling עם Sleep(1)
accDescr: תרשים שמראה שכש-polling לתור נעשה עם Sleep(1), יש שלוש בעיות - מתעוררים בקביעות גם כשהתור ריק, ה-latency נגרר אחרי רזולוציית הטיימר, וחיסרון גם מבחינת צריכת חשמל.
a1["Sleep(1) בבדיקה תקופתית של תור"] --> b1["מתעוררים גם כשריק"]
a1 --> b2["latency נגרר אחרי הרזולוציה"]
a1 --> b3["חיסרון גם ב-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) את התור
sequenceDiagram
accTitle: הצורה שבה ה-producer מבצע signal
accDescr: תרשים שמראה שה-producer קורא ל-SetEvent מיד אחרי שהוא מכניס item לתור, ה-consumer ממתין עם WaitForSingleObject וכדומה, וכשמתעורר מרוקן את התור.
participant P as producer
participant Q as queue
participant C as consumer
P->>Q: מכניס item
P->>C: מיד אחר כך SetEvent
C->>C: מתעורר מההמתנה
C->>Q: מרוקן (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 |
יש שלוש נקודות שכדאי לא לפספס בשימוש.
- חובה להשתמש בזוג עם
WakeByAddressSingleאוWakeByAddressAll. אם הצד ששינה את הערך לא קורא לזה, ה-thread הממתין לא יתעורר. אם רוצים להעיר רק אחד - Single, אם את כולם - All WaitOnAddressעלול לחזור גם בלי שהתבצע signal. גם בתיעוד הרשמי כתוב שאפשר להתעורר מוקדם, למשל במצב זיכרון נמוך. אחרי החזרה, חייבים תמיד לקרוא את הערך שוב כדי לוודא שהוא באמת השתנה - כותבים זאת כ-לולאת while- הגודל שאפשר להמתין לו הוא אחד מתוך 1, 2, 4 או 8 בייטים
- חובה שהדגל יהיה אטומי.
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);
sequenceDiagram
accTitle: הזרימה הזוגית של WaitOnAddress
accDescr: תרשים שמראה שהצד המשנה מכין נתונים וכותב את הדגל עם release, ואז מעיר עם WakeByAddressSingle, והצד הממתין קורא שוב את הערך עם acquire בלולאת while כדי להתמודד עם חזרה מוקדמת.
participant W as הצד המשנה
participant F as הדגל האטומי
participant S as הצד הממתין
S->>F: קורא עם acquire
S->>S: WaitOnAddress עד שינוי
W->>F: כותב עם release
W->>S: WakeByAddressSingle
S->>F: אחרי ההתעוררות, קורא שוב
איור 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).
אבל עדיף לא להפוך את זה לבחירה ראשונה קבועה.
יש שלוש סיבות.
- יש עלות ב-power / performance
- בגרסאות עדכניות של Windows ההתנהגות מעט מסובכת יותר
- לעיתים קרובות זה לא מתקן את שורש הבעיה
flowchart TB
accTitle: למה לא הופכים את timeBeginPeriod לשימוש קבוע
accDescr: תרשים שמראה שגם כשהדיוק של timer wait קצר מדאיג, עדיף לא להפוך את timeBeginPeriod לבחירה ראשונה קבועה, כי יש עלות ב-power וב-performance, ההתנהגות ב-Windows החדשות מסובכת יותר, ולעיתים קרובות זה לא מתקן את שורש הבעיה.
t1["הדיוק מדאיג"] --> t2["רוצים להוסיף timeBeginPeriod"]
t2 --> t3["לא הופכים לבחירה ראשונה קבועה"]
t3 -.-> r1["יש עלות"]
t3 -.-> r2["ההתנהגות מעט מסובכת"]
t3 -.-> r3["שורש הבעיה לא תוקן"]
איור 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 תקופתיים המיותרים
- יש שיפור גם מבחינת צריכת חשמל
- כוונת הקוד הופכת לברורה יותר
זה משפיע במידה כזו.
flowchart TB
accTitle: זמן שייך לטיימר, אירוע שייך ל-event
accDescr: תרשים שמראה שכשהקו המפריד ברור - אם ממתינים לזמן משתמשים בטיימר, ואם ממתינים לאירוע משתמשים ב-event - זה מוביל ל-latency שקל לחזות, פחות wakeup תקופתיים מיותרים, שיפור בצריכת חשמל, וכוונת קוד ברורה יותר.
m1{"מה רוצים להמתין לו?"}
m1 -->|"זמן"| m2["ממתינים עם טיימר"]
m1 -->|"אירוע"| m3["ממתינים עם event"]
m2 --> m4["הקו המפריד ברור"]
m3 --> m4
m4 -.-> m5["latency קל יותר לחזות"]
m4 -.-> m6["פחות wakeup מיותרים"]
איור 12: הקפדה על שורה אחת בלבד משנה את הלייטנסי, את צריכת החשמל ואת קריאות הכוונה של הקוד.
9. מקורות
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- WaitForMultipleObjects function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- WakeByAddressAll function
- GetSystemTimeAdjustment function
- ClockRes - Sysinternals
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
טבלת החלטה: לסיים או להמשיך אחרי חריגה בלתי צפויה
המאמר מסדר מתי כדאי לסיים אפליקציה ומתי אפשר להמשיך לפעול אחרי חריגה בלתי צפויה, מנקודת המבט של השחתת מצב, תופעות לוואי חיצוניות, threads...
רשימת בדיקה מינימלית לאבטחה בפיתוח יישומי Windows
מסדרים בצורת רשימת בדיקה את היסודות של הרשאות, חתימה, עדכונים, סודות, HTTPS, אימות קלט, טעינת DLL ולוגים ביישומים עסקיים ב-WPF / WinForm...
שלושה סוגי טיימרים ב-.NET — מתי להשתמש ב-PeriodicTimer/Timer/DispatcherTimer
מסודר כאן ההבדל בין PeriodicTimer / System.Threading.Timer / DispatcherTimer, ואיך לבחור ביניהם לעיבוד async, callback ב-ThreadPool וע...
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות
המאמר מסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, מנקודת המבט של פספוסים, התראות כפולות, מלכודות בקביעת סיום, סריקה חו...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
תכנון המתנה, בחירת primitives לסנכרון, והתפשרות בין השהיה לצריכת חשמל ב-soft real-time הם נושא שמתאים היטב לייעוץ טכני ולסקירת תכנון.
פיתוח יישומי Windows
החלפת timer polling בעיצוב מונחה אירועים באפליקציות ובשירותי Windows היא נושא שמשפיע ישירות על איכות המימוש בפיתוח אפליקציות Windows.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- למה 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 וגם לצריכת חשמל.