למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait
· עודכן בתאריך: · Go Komura · פיתוח Windows, סנכרון, אירועים, טיימר, תכנון
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 16 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173546)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173546 https://comcomponent.com/he/blog/windows-timer-vs-event-wait/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173546
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173547
במאמר הקודם, מדריך מעשי ל-soft real-time ב-Windows, כתבנו על הימנעות מלולאה מחזורית שמסתמכת על Sleep.
במאמר הזה נתמקד בנקודה אחת מתוך זה: למה עדיף event wait על פני timer wait קצר.
אפשר לקרוא את המאמר הזה בפני עצמו. המסקנה של המאמר הקודם היא שורה אחת: “לולאת בדיקה תקופתית שמסתמכת על Sleep לקביעת המחזור — אין ערובה לא לזמן ההמתנה ולא לתזמון ההתעוררות, ולכן אסור לבנות עליה עיבוד מחזורי”. אם זה נקלט, אפשר להמשיך בלי לקרוא את המאמר הקודם.
ב-Windows, כשמתכננים “לבדוק כל פרק זמן קבוע” באמצעות Sleep(1) או wait עם timeout קצר, בהכרח מקבלים השפעה של system clock resolution ו-עיכוב תזמון שמגיע אחר כך.
בהגדרות רגילות, בדרך כלל מונח בבסיס platform timer resolution בסדר גודל של 15.6ms, ולכן גם אם הכוונה הייתה “לבדוק שוב אחרי מילישנייה”, בפועל ההמתנה נוטה להיות די גסה.
מצד שני, אם מה שבאמת רוצים להמתין לו — הגעת עבודה, השלמת I/O, בקשת עצירה, שינוי מצב — הוא לא “זמן” אלא “אירוע”, אין צורך לבדוק במרווחים קבועים. עדיף שהצד שבו קרה האירוע יעשה signal, והצד הממתין יחכה ל-event — זה פתרון ישיר יותר גם ל-latency, גם ל-CPU וגם לצריכת חשמל.
flowchart TB
accTitle: המתנה event-driven
accDescr: אם מה שבאמת רוצים להמתין לו הוא אירוע ולא זמן, עדיף שהצד שבו קרה האירוע יעשה signal והצד הממתין יחכה ל-event, במקום לבדוק במרווחים קבועים, כי זה פתרון ישיר יותר ל-latency, ל-CPU ולצריכת חשמל.
d1["רוצים להמתין לאירוע"] --> d2["הצד שבו זה קרה עושה signal"]
d2 --> d3["הצד הממתין מחכה ל-event"]
d3 -.-> d4["פתרון ישיר יותר ל-latency, ל-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) | עיבוד שרץ בעדיפות עליונה כשמתרחשת interrupt. כל עוד הוא רץ, ה-thread שלכם ממתין |
| DPC (Deferred Procedure Call) | עיבוד דחוי בעדיפות גבוהה שה-ISR מניח “להמשיך אחר כך”. מספיק לקרוא ל-ISR ול-DPC יחד גורמי השהיה הנובעים מטיפול ב-interrupts במאמר הזה |
| IOCP (I/O Completion Port) | המנגנון של Windows שמרכז הודעות השלמה של I/O אסינכרוני בתור אחד, ומקבל אותן קבוצת threads ייעודית |
WaitOnAddress |
API סנכרון להמתנה “עד שהערך בכתובת זיכרון מסוימת משתנה”. רק בתוך אותו תהליך (מפורט בסעיף 5.3) |
| לעשות signal | לגרום לתנאי שהצד הממתין מחכה לו להתקיים. עבור event זה קריאה ל-SetEvent |
1. המסקנה בקצרה
- אם ממתינים להגעת עבודה או להשלמת I/O, עדיף לחכות ל-event ולא לטיימר.
- ה-timed wait של Windows בהכרח מושפע מ-system clock resolution.
Sleep(1)לא אומר “יתעורר בדיוק אחרי מילישנייה”.- חוץ מזה, גם אחרי שה-timeout עובר, ה-thread רק הופך ל-ready, וריצה מיידית אינה מובטחת.
- לכן תכנון של “בעצם רוצים להמתין לאירוע, אבל בודקים עם טיימר” נחות גם מבחינת latency וגם מבחינת צריכת חשמל.
- כדאי לצמצם את השימוש ב-timer רק למקרים שבהם הזמן עצמו הוא באמת התנאי.
בשפה מעשית, זה מסתכם בערך בזה:
- “לשלוח metrics כל 5 שניות” -> עבודה של timer
- “לפעול מיד כשנכנסת עבודה לתור” -> עבודה של event / semaphore / condition variable /
WaitOnAddress - “להמשיך את העיבוד כש-I/O הסתיים” -> עבודה של completion / event
- “לעצור כשמגיעה בקשת עצירה” -> עבודה של stop event / cancellation
flowchart TB
accTitle: הקו המפריד בין טיימר ל-event
accDescr: משתמשים בטיימר רק כשהזמן עצמו הוא התנאי, וכשממתינים לאירוע כמו הגעת עבודה, השלמת I/O או בקשת עצירה, ממתינים ל-event.
q1{"רוצים להמתין לזמן או לאירוע?"}
q1 -->|"הזמן עצמו"| t1["עבודה של timer"]
q1 -->|"אירוע"| e1["עבודה של המתנה ל-event"]
e1 -.-> rei["הגעת עבודה / השלמת I/O / בקשת עצירה"]
איור 2: מצמצמים את הטיימר למקרים שהזמן עצמו הוא התנאי, ולאירועים ממתינים עם event.
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 21, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. מה הבעיה
2.1 timed wait כבול ל-system clock resolution
דיוק ה-timeout ב-wait functions של Windows תלוי ב-system clock resolution.
כך גם 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 ומציג את system clock resolution — כלומר, את ה-timer resolution המקסימלי שהאפליקציה יכולה לקבל. כשרוצים לבדוק בלי לכתוב קוד, זה הדרך המהירה.
יש לשים לב שבתיעוד, lpTimeIncrement מתואר כערך קבוע שנקבע על ידי המערכת בעת ההפעלה, ולא משתנה תוך כדי ריצה. כלומר זה ערך שמראה “את הרזולוציה הגולמית של הסביבה הזו”, ולא כלי למדידת התוצאה של קריאה ל-timeBeginPeriod. על כך נדבר בסעיף 6.3.
flowchart TB
accTitle: שתי דרכים לבדוק את הרזולוציה הגולמית
accDescr: את timer resolution בסביבה שלכם אפשר לבדוק על ידי קריאה ל-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 נגררת אחרי timer resolution
- גם אחרי ה-timeout, תחילת הריצה תלויה ב-scheduler
flowchart TB
accTitle: שני שלבי אי-הוודאות של timer wait קצר
accDescr: ב-timer wait קצר יש שני שלבי אי-ודאות: קביעת ה-timeout נגררת אחרי timer resolution, ואחר כך ה-thread רק הופך ל-ready ותחילת הריצה תלויה ב-scheduler.
s1["מתחילים timer wait קצר"] --> s2["קביעת timeout תלויה ברזולוציה"]
s2 --> s3["ה-thread רק הופך ל-ready"]
s3 --> s4["תחילת ריצה תלויה ב-scheduler"]
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 מתעוררים לפי timer resolution ובודקים אם קרה משהו, וב-event wait הצד שקרה בו האירוע עושה signal כך שבזמן ההתעוררות כבר יש סיבה.
subgraph TimerWait["timer wait: מתעוררים כי הזמן הגיע"]
T1["ממתינים"] --> T2["מתעוררים לפי timer resolution"]
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 |
| ש-I/O הסתיים | לבדוק את המצב עם 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 עדיף במובן שבו לא צריך להתעורר לפי timer resolution, אבל זה לא אומר שברגע ה-signal רצים ב-latency אפס מוחלט.
גם ב-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 לא אומר latency אפס"]
איור 7: event הוא לא קסם, אבל לפחות ההצורך להתעורר לפי timer resolution נעלם בוודאות.
4. anti-patterns אופייניים
4.1 polling לתור עם Sleep(1)
הכי נפוץ הוא זה.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
הכתיבה הזו נראית פשוטה, אבל יש בה שלוש בעיות.
- מתעוררים בקביעות גם כשהתור ריק
- ה-latency נגרר אחרי timer resolution
- חיסרון גם מבחינת צריכת חשמל
flowchart TB
accTitle: שלוש הבעיות ב-polling עם Sleep(1)
accDescr: כש-polling לתור נעשה עם Sleep(1), יש שלוש בעיות: מתעוררים בקביעות גם כשהתור ריק, ה-latency נגרר אחרי timer resolution, וחיסרון גם מבחינת צריכת חשמל.
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 כדי שבקשת העצירה לא תיפספס - כישלון לא מוחזר כ-exception, אלא כערך
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 object וניהול handle | מספיק שיש משתנה שממתינים לו |
| גרסאות נתמכות | זמין כבר זמן רב | Windows 8 / Windows Server 2012 ואילך |
| קישור | Kernel32.lib |
Synchronization.lib |
יש שלוש נקודות שכדאי לא לפספס בשימוש.
- חובה להשתמש בזוג עם
WakeByAddressSingleאוWakeByAddressAll. אם הצד ששינה את הערך לא קורא לזה, ה-thread הממתין לא יתעורר. אם רוצים להעיר רק אחד — Single, אם את כולם — All WaitOnAddressעלול לחזור גם בלי שהתבצע signal. גם בתיעוד הרשמי כתוב שאפשר להתעורר מוקדם, למשל במצב זיכרון נמוך. אחרי החזרה, חייבים תמיד לקרוא את הערך שוב כדי לוודא שהוא באמת השתנה — כותבים זאת כ-לולאת while- הגודל שאפשר להמתין לו הוא אחד מתוך 1, 2, 4 או 8 בייטים
- חובה שהדגל יהיה atomic.
WakeByAddressSingleרק מעיר את ה-thread הממתין, ואינה הופכת את הכתיבה שקדמה לה לא atomic ולא לגלויה. אם קוראים וכותבים משתנה רגיל משני הצדדים, ב-C++ זו data race (התנהגות לא מוגדרת), ובבנייה מותאמת ייתכן שהעדכון לא ייראה ו-thread ימשיך להיתקע גם אחרי שהעירו אותו. הצד הכותב עושה release, הצד הקורא עושה acquire
// הצד הממתין והצד המעיר הם threads שונים, ולכן הדגל חייב להיות atomic.
// אם ULONG רגיל נקרא ונכתב משני הצדדים, ב-C++ זו data race (התנהגות לא מוגדרת),
// ובבנייה מותאמת ייתכן שהערך יישאר ברגיסטר בלי שהעדכון ייראה,
// וה-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 הממתין,
// ואינה הופכת את הכתיבה שקדמה לה ל-atomic או לגלויה
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 הדגל ה-atomic
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) - האם בעצם ממתינים להגעה לתור, להשלמת I/O או לבקשת עצירה, אבל מבצעים timer poll
- האם העיצוב מאפשר signal מצד ה-producer או ה-completion
- אפשר לחכות ל-
stopול-workיחד ב-wait אחד - אם מדובר בשינוי ערך בתוך אותו תהליך, אפשר לכתוב עם
WaitOnAddress - במקום שבו משתמשים בטיימר — האם מה שבאמת רוצים להמתין לו הוא “זמן”
8. סיכום
תכנון של “לבדוק כל פרק זמן קבוע” באמצעות timer wait קצר ב-Windows בהכרח מושפע מ-timer resolution ומה-scheduler.
לכן Sleep(1) או timeout קצר הם המתנה פחות מדויקת ממה שנראה.
מצד שני, כשמה שבאמת רוצים להמתין לו — הגעת עבודה, השלמת I/O, בקשת עצירה, שינוי מצב — הוא “אירוע”, 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: הקפדה על שורה אחת בלבד משנה את ה-latency, את צריכת החשמל ואת קריאות הכוונה של הקוד.
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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים
מתי מסיימים אפליקציית Windows אחרי exception לא צפוי ומתי אפשר להמשיך: לפי state corruption, side effect חיצוני, threads וגבול native.
למה להכניס Generic Host ו-BackgroundService לאפליקציית desktop ב-.NET
בכלי Windows ובאפליקציות long-running, איך להשתמש ב-Generic Host וב-BackgroundService כדי לרכז הפעלה, עיבוד תקופתי, shutdown, לוג, הגדרות...
FileSystemWatcher בפועל: miss ו-duplicate
איך משתמשים ב-FileSystemWatcher בזהירות: miss, duplicate, מלכודות בקביעת סיום, rescan, atomic claim ו-idempotency.
Locking ב-file handoff: file lock ו-atomic claim בפועל
איך מתכננים locking ב-file handoff: file lock, atomic claim, temp -> rename ו-idempotency, כדי להימנע מתקלות בתיקייה משותפת.
הרשת עובדת אבל Windows מציג "אין אינטרנט" — לבודד NCSI, DNS, Proxy ו-VPN ב-Windows
למה Windows מציג "אין אינטרנט" בזמן שהרשת עובדת — נקודת המוצא היא קביעת הקישוריות של NCSI. כאן מפרידים בין DNS, proxy, VPN ו-captive port...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
תכנון המתנה, בחירת synchronization primitives, והפשרה בין latency לצריכת חשמל ב-soft real-time מתאימים לייעוץ טכני ול-design review.
פיתוח יישומי Windows
החלפת timer polling בעיצוב event-driven באפליקציות ובשירותי Windows משפיעה ישירות על איכות המימוש בפיתוח אפליקציות Windows.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- למה Sleep(1) ב-Windows לא מתעורר בדיוק אחרי מילישנייה?
- כי דיוק ה-timeout ב-timed wait של Windows תלוי ב-system clock resolution, ובהגדרות רגילות זה בדרך כלל מבוסס על platform timer resolution בסדר גודל של 15.6ms. חוץ מזה, גם כשזמן ההמתנה מסתיים, ה-thread רק הופך ל-ready, ואין ערובה שהוא יקבל CPU ויתחיל לרוץ מיד. יש השפעה של threads אחרים, priority, מצב idle של ה-CPU, DPC / ISR, ותחרות על נעילות. במילים אחרות, ב-timer wait קצר יש לפחות שני שלבים של אי-ודאות: קביעת ה-timeout מושפעת מ-timer resolution, ותחילת הריצה בפועל אחרי ה-timeout תלויה ב-scheduler.
- מה הבעיה בעיצוב שבו עושים polling לתור עם Sleep(1) או Task.Delay(1)?
- יש שלוש בעיות. הראשונה: מתעוררים בקביעות גם כשהתור ריק. השנייה: ה-latency נגרר אחרי timer resolution. השלישית: יש חיסרון גם מבחינת צריכת חשמל. גם לולאת await Task.Delay(1) ב-C# נראית עדינה, אבל מהותה התכנונית היא עדיין polling. הדרך לתקן היא לגרום ל-producer לקרוא ל-SetEvent מיד אחרי שהוא מכניס פריט לתור, וה-consumer מחכה עם WaitForSingleObject או WaitForMultipleObjects. אם ממתינים ל-stop event ול-work event יחד ב-wait אחד, אפשר להגיב מיד גם לבקשת עצירה.
- איך בוחרים בין המתנה בטיימר להמתנה ב-event?
- הקו המפריד הוא: אם ממתינים לזמן — טיימר, אם ממתינים לאירוע — event. עיבוד שבו הזמן עצמו הוא התנאי, כמו שליחת metrics כל 5 שניות, הוא עבודה של waitable timer. הגעת עבודה לתור מתאימה ל-event או semaphore, השלמת I/O מתאימה ל-event של overlapped I/O או ל-IOCP, בקשת עצירה מתאימה ל-stop event או cancellation, ושינוי ערך בתוך אותו תהליך מתאים ל-WaitOnAddress. כשהקו הזה ברור, קל יותר לחזות latency, פוחתים wakeup תקופתיים מיותרים, וגם כוונת הקוד ברורה יותר.
- אפשר לפתור את זה פשוט על ידי הגברת דיוק הטיימר עם timeBeginPeriod(1)?
- עדיף לא להפוך את זה לבחירה ראשונה קבועה. יש שלוש סיבות: יש עלות ב-power וב-performance, ההתנהגות מעט מסובכת יותר ב-Windows החדשות, ולעיתים קרובות זה לא מתקן את שורש הבעיה. אם מה שבאמת רוצים להמתין לו הוא אירוע כמו הגעת עבודה או השלמת I/O, עדיף לשנות את התכנון ל-event wait שבו הצד שמתעורר עושה signal, במקום להעלות את דיוק הטיימר — זה פתרון ישיר יותר גם ל-latency, גם ל-CPU וגם לצריכת חשמל.