במאמר הקודם, מדריך מעשי למקסום soft real-time ב-Windows רגיל — רשימת בדיקה ראשונה, סידרנו את הרעיון של הימנעות מלולאה מחזורית שמסתמכת על Sleep, ושימוש בגישה מונחית-אירועים או ב-waitable timer. במשפט אחד, המסקנה של המאמר הקודם הייתה: אם רוצים לצמצם תנודתיות במחזור ופספוסי deadline, קודם מתכננים את עצם “אופן ההמתנה”, לפני סוג הטיימר.
אז מה עושים בפיתוח אפליקציות .NET רגיל יותר?
כאן קל להתבלבל בין PeriodicTimer, System.Threading.Timer ו-DispatcherTimer.
כולם נקראים “טיימר”, אבל:
- טיימר שממתין ל-tick עם
await - טיימר שה-callback שלו מגיע מ-ThreadPool
- טיימר שרץ על ה-
Dispatcherשל ת’רד ה-UI
כלומר, האופי שלהם שונה מאוד.
flowchart TB
accTitle: שלושה טיימרים עם שמות דומים אך אופי שונה
accDescr: תרשים המראה ש-PeriodicTimer הוא טיימר שממתין ל-tick עם await, System.Threading.Timer הוא טיימר שה-callback שלו מגיע מ-ThreadPool, ו-DispatcherTimer הוא טיימר שרץ בת'רד ה-UI.
t["שלושה טיימרים"] --> pt["PeriodicTimer: ממתין עם await"]
t --> st["Timer: ה-callback מגיע"]
t --> dt["DispatcherTimer: ת'רד ה-UI"]
איור 1: כולם נקראים טיימר, אבל אופן ההמתנה ומקום הריצה שונים לגמרי.
בעבודה בפועל, מה שקל להתבלבל בו הוא בערך אלה:
- מעבירים lambda של async ל-System.Threading.Timer, למרות שזה עיבוד תקופתי אסינכרוני
- נוגעים ישירות במסך מטיימר ThreadPool, למרות שזה עדכון UI ב-WPF
- מכניסים עיבוד כבד ל-DispatcherTimer, ומאיטים גם את המסך
- הדיון על “soft real-time” מהמאמר הקודם מתערבב בראש עם הרצה תקופתית רגילה של אפליקציה
המאמר הזה מניח בעיקר אפליקציות C# / .NET כלליות מ-.NET 6 ואילך, ומסדר את PeriodicTimer / System.Threading.Timer / DispatcherTimer בסדר שמקל על ההחלטה בעבודה היומיומית.
הקהל היעד המשוער הוא בערך אלה:
- worker / שירות רקע
- אפליקציית קונסולה
- עיבוד מאחורי הקלעים ב-ASP.NET Core
- אפליקציית desktop של WPF
DispatcherTimer במאמר הזה מתייחס בעיקר ל-System.Windows.Threading.DispatcherTimer של WPF.
גם ב-WinUI / UWP קיים DispatcherTimer עם אותו רעיון.
ב-WinForms, טבעי יותר להסתכל על System.Windows.Forms.Timer בתור טיימר ה-UI.
המאמר הזה מטה את ההסבר בצד ה-UI ל-WPF, אבל גם קוראי WinForms נכללים בקהל היעד. אם קוראים DispatcherTimer כ-System.Windows.Forms.Timer, ההערות בסעיפים 4.3 ו-5.2 חלות כמו שהן. עם זאת, יש שני הבדלים:
-
System.Windows.Forms.Timerהוא טיימר חד-ת’רדי שה-Tick שלו קורה דרך לולאת ההודעות, ואין לו ציון עדיפות (DispatcherPriority) כמו ל-DispatcherTimer - בתיעוד של Microsoft נקבע שהדיוק מוגבל לכ-55 מילישניות. הוא לא מתאים למחזורים דקים — במקרה כזה כדאי לשקול טיימר שאינו טיימר UI
יש לציין: הנושא כאן הוא איך כותבים הרצה תקופתית בצד האפליקציה. כשהדיוק המחזורי עצמו הוא הנושא, חוזרים לדיון במאמר הקודם על soft real-time.
flowchart TB
accTitle: תחום הכיסוי של המאמר הזה
accDescr: תרשים המראה שאיך כותבים הרצה תקופתית בצד האפליקציה הוא תחום המאמר הזה, ואילו כשהדיוק המחזורי עצמו הוא הנושא חוזרים למאמר הקודם על soft real-time שעסק בתכנון אופן ההמתנה.
q{"מה הנושא?"}
q -->|"איך כותבים הרצה תקופתית באפליקציה"| here["דיון שלושת הטיימרים במאמר הזה"]
q -->|"הדיוק המחזורי עצמו"| rt["הדיון הקודם על תכנון אופן ההמתנה"]
איור 2: גם אם זה “לעשות משהו במרווח קבוע” — אופן כתיבת ההרצה התקופתית ותכנון דיוק המחזור הם בעיות שונות.
כמו כן, הקוד שמופיע במאמר הזה פורסם ב-GitHub כחבילת דוגמאות מלאה שאפשר לבנות ולהריץ (ספרייה ודמו קונסולה של PeriodicTimer / System.Threading.Timer, ובדיקות יחידה שבודקות את קיפול ה-tick ואת חפיפת ה-callback).
periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)
תוכן עניינים
- קודם כול, המסקנה (במשפט אחד)
- קודם כול, לסדר בדף אחד
- 2.1. התמונה הכוללת
- 2.2. טבלת ההחלטה הראשונה
- מה כדאי להבחין בו קודם
- 3.1. סוג callback, או סוג שממתין ל-tick
- 3.2. רץ ב-ThreadPool, או בת’רד ה-UI
- 3.3. עיבוד מחזורי והבטחת דיוק הם נושאים שונים
- דפוסים אופייניים
- 4.1. לעיבוד תקופתי אסינכרוני —
PeriodicTimer - 4.2. להרצת callback קל ב-ThreadPool —
System.Threading.Timer - 4.3. לעדכון UI ב-WPF —
DispatcherTimer - 4.4. לעיבוד מחזורי בכיוון soft real-time — כלי אחר
- 4.1. לעיבוד תקופתי אסינכרוני —
- אנטי-דפוסים נפוצים
- רשימת בדיקה לסקירת קוד
- חלוקה גסה בין המצבים
- סיכום
- מקורות
מפת הידע של המאמר
המאמר הזה מסדר את שלושת סוגי הטיימרים של .NET לפי המקום שבו הם פועלים ולפי אופן כתיבת העיבוד. PeriodicTimer הוא טיימר מטיפוס שמשתמש ב-CancellationToken וממתין ל-tick עם await, ומתאים לעיבוד תדיר אסינכרוני ב-worker/BackgroundService, אבל הוא לא כלי להרצה מחזורית מדויקת מאוד. System.Threading.Timer הוא טיימר קליל שה-callback שלו נקרא על ThreadPool, ומכיוון שהוא לא ממתין לסיום ה-callback הקודם עלולה להתרחש חפיפה, ואם מעבירים ל-TimerCallback שהוא void למבדה (lambda) מסוג async, זה מתנהג כמו async void, וחריגה לא מטופלת על ThreadPool עלולה להפיל את התהליך. DispatcherTimer מעובד על ה-Dispatcher של WPF ולכן יכול לעדכן את ה-UI ישירות, אבל עיבוד כבד גורם לחסימת ת’רד ה-UI, וזלזול ב-Stop() או בביטול הרשמה עלול למשוך את חיי האובייקט. גם ה-Timer של WinForms חולק את המגבלה שהדיוק מוגבל לכ-55 מילישניות.
flowchart LR
accTitle: מפת הידע של הבחירה בין PeriodicTimer, System.Threading.Timer ו-DispatcherTimer
accDescr: תרשים שמראה ש-PeriodicTimer, באמצעות CancellationToken, מתאים לעיבוד תדיר אסינכרוני ב-BackgroundService; ש-System.Threading.Timer, על ThreadPool, נושא סיכון של חפיפת callback ומלכודת בסגנון async void; ש-DispatcherTimer, על ה-Dispatcher של WPF, מוביל עדכוני UI בתמורה לסיכון של חסימת ת'רד ה-UI ושל שכחת ביטול הרשמה; ושאף אחד מהם לא מתאים לדרישה של הרצה מחזורית מדויקת מאוד.
periodictimer["PeriodicTimer"]
system_threading_timer["System.Threading.Timer"]
dispatcher_timer["DispatcherTimer"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
backgroundservice["BackgroundService"]
async_periodic_processing["עיבוד מחזורי אסינכרוני"]
precise_periodic_timing["דרישה לביצוע מחזורי מדויק"]
dotnet_threadpool["מאגר הת'רדים של .NET"]
timer_callback_overlap["חפיפה בין קריאות ה-callback של הטיימר"]
async_void_callback["callback שמתנהג כ-async void"]
unhandled_threadpool_exception["סיום התהליך עקב חריגה שלא נתפסה ב-ThreadPool"]
dispatcher_priority["DispatcherPriority"]
ui_thread_blocking["חסימת ת'רד ממשק המשתמש"]
event_subscription_leak["אי-ביטול של מנוי לאירוע"]
winforms_timer["System.Windows.Forms.Timer"]
reentrancy_guard["מנגנון מניעת כניסה חוזרת (Interlocked.Exchange וכדומה)"]
wpf_dispatcher["Dispatcher(WPF)"]
periodictimer -->|"משתמש ב"| cancellationtoken_dotnet
periodictimer -->|"מענה מומלץ ל"| backgroundservice
periodictimer -->|"מענה מומלץ ל"| async_periodic_processing
periodictimer -.->|"שימוש לא מומלץ ל"| precise_periodic_timing
system_threading_timer -->|"משתמש ב"| dotnet_threadpool
system_threading_timer -.->|"עלול לגרום ל"| timer_callback_overlap
system_threading_timer -.->|"עלול לגרום ל"| async_void_callback
system_threading_timer -.->|"שימוש לא מומלץ ל"| precise_periodic_timing
async_void_callback -->|"שימוש לא מומלץ ל"| async_periodic_processing
async_void_callback -.->|"עלול לגרום ל"| unhandled_threadpool_exception
dispatcher_timer -->|"משתמש ב"| dispatcher_priority
dispatcher_timer -.->|"עלול לגרום ל"| ui_thread_blocking
dispatcher_timer -.->|"שימוש לא מומלץ ל"| precise_periodic_timing
dispatcher_timer -.->|"עלול לגרום ל"| event_subscription_leak
winforms_timer -->|"שימוש לא מומלץ ל"| precise_periodic_timing
reentrancy_guard -->|"מענה מומלץ ל"| timer_callback_overlap
dispatcher_timer -->|"מחייב"| wpf_dispatcher
wpf_dispatcher -->|"משתמש ב"| dispatcher_priority
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 18, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
1. קודם כול, המסקנה (במשפט אחד)
- אם רוצים לכתוב עיבוד במרווח קבוע בטבעיות מבוססת
await, קודםPeriodicTimer - אם רוצים להפעיל תקופתית
callbackקל על ה-ThreadPool,System.Threading.Timer - אם רוצים לעדכן מסך בת’רד ה-UI של WPF,
DispatcherTimer -
System.Threading.Timer— ה-callback עלול לחפוף. דחיפה רשלנית של עיבוד אסינכרוני פנימה נוטה להסתבך -
DispatcherTimerמאפשר לגעת ב-UI ישירות, אבל אם מכניסים עיבוד כבד, קל שהוא יעצור גם את ה-UI - בהקשר של soft real-time מהמאמר הקודם, שלושת אלה אינם השחקנים הראשיים של המתנה מדויקת
בקיצור, מה שכדאי להסתכל עליו קודם הן שלוש הנקודות הבאות:
- באיזה ת’רד / הקשר רוצים להריץ
- האם רוצים לכתוב את גוף העיבוד ברצף עם
async/await - האם אפשר לקבל חפיפה של ה-callback
רק ההפרדה בין השלושה הזו כבר מקטינה מאוד בלבול.
flowchart TB
accTitle: שלוש השאלות שבודקים תחילה
accDescr: תרשים המראה שההפרדה בין שלוש שאלות — באיזה ת'רד או הקשר רוצים להריץ, האם רוצים לכתוב ברצף עם async ו-await, והאם אפשר לקבל חפיפת callback — מקטינה מאוד בלבול בבחירת הטיימר.
q1["איפה רוצים להריץ"] --> pick["הבחירה בטיימר מתבררת"]
q2["רוצים לכתוב ברצף עם async"] --> pick
q3["אפשר לקבל חפיפת callback"] --> pick
איור 3: לפני שם הטיימר, מפרידים בין שלוש השאלות האלה.
2. קודם כול, לסדר בדף אחד
2.1. התמונה הכוללת
flowchart LR
accTitle: תרשים ההחלטה הכולל לבחירת טיימר
accDescr: תרשים המראה שאם רוצים להריץ בת'רד ה-UI בוחרים DispatcherTimer, אחרת אם רוצים לכתוב בפשטות עם async ו-await בוחרים PeriodicTimer, אחרת אם רוצים callback קל ב-ThreadPool בוחרים System.Threading.Timer, ואם לא משהו מאלה שוקלים תכנון אחר.
A["רוצים לעשות משהו במרווח קבוע"] --> B{"רוצים להריץ בת'רד ה-UI?"}
B -- "כן" --> C["DispatcherTimer"]
B -- "לא" --> D{"רוצים לכתוב בפשטות עם async / await?"}
D -- "כן" --> E["PeriodicTimer"]
D -- "לא" --> F{"רוצים callback קל ב-ThreadPool?"}
F -- "כן" --> G["System.Threading.Timer"]
F -- "לא" --> H["לשקול תכנון אחר — Channel / BackgroundService / event / waitable timer"]
איור 4: הסדר — ת’רד ה-UI, כתיבה עם async, ואז callback קל — קובע את שלושת הטיימרים.
בעבודה בפועל, ההסתעפות הזו מספיקה ברוב המקרים. כשמתלבטים, הכי בטוח לחתוך קודם:
עיבוד אסינכרוני — PeriodicTimer, עדכון UI — DispatcherTimer.
System.Threading.Timer נוח, אבל יש לו הרגלים סביב חפיפת callback וניהול מחזור חיים, ולכן הוא קצת קפדני כבחירה ראשונה.
2.2. טבלת ההחלטה הראשונה
| מצב | הבחירה הראשונה | היכן זה רץ | הסיבה שזה מתאים | נקודה ראשונה לתשומת לב |
|---|---|---|---|---|
| רוצים להריץ עיבוד async כמו HTTP / DB / קובץ I/O במרווח קבוע | PeriodicTimer |
בתוך זרימת שיטת ה-async הנוכחית | אפשר לכתוב עם await, ועצירה וביטול פשוטים |
הנחת יסוד: טיימר אחד, consumer אחד. פיגור לא מקבל מקבילה אוטומטית |
| רוצים להריץ ב-ThreadPool heartbeat קל / שליחת מטריקות / בדיקת פקיעת cache | System.Threading.Timer |
ThreadPool | קל וקטן, מסוג callback. קל להעלות על תכנון מבוסס callback קיים | ה-callback מניח כניסה חוזרת. עלולה להיות חפיפה. שומרים הפניה |
| רוצים תצוגת שעון ב-WPF או עדכון UI קל במרווח קבוע | DispatcherTimer |
ה-Dispatcher (ת’רד ה-UI) של WPF |
אפשר לגעת ב-UI ישירות. יש עדיפות | זמן ההפעלה המדויק לא מובטח. עיבוד כבד חוסם את ה-UI |
הדיוק המחזורי הוא הנושא המרכזי, רוצים להימנע מהסתמכות על Sleep |
לא לשים את שלושת אלה במרכז | - | המטרה היא לא הרצה תקופתית של אפליקציה, אלא תכנון דיוק ההמתנה | להסתכל בצד ה-event / waitable timer |
החשוב בטבלה הזו הוא להסתכל על מקום הריצה ואופן הכתיבה, ולא על שם הטיימר. כשיש תקלה בבחירת טיימר, לרוב זה כי לא הסתכלו על “איפה זה רץ”, ולא בגלל שם ה-API.
flowchart TB
accTitle: נקודת המבט לבחירת טיימר
accDescr: תרשים המראה שבחירה לפי שם ה-API נוטה לתקלה כי לא מסתכלים איפה זה רץ, בעוד שבחירה לפי מקום הריצה ואופן הכתיבה נותנת החלטה שקשה לטעות בה.
name["בחירה לפי שם"] --> miss["תקלה כי לא בדקו איפה זה רץ"]
look["בחירה לפי מקום הריצה ואופן הכתיבה"] --> hit["בחירה שקשה לטעות בה"]
איור 5: רוב התקלות נובעות מבחירה לפי שם. מה שצריך לבדוק הוא “איפה זה רץ” ו”איך רוצים לכתוב”.
3. מה כדאי להבחין בו קודם
3.1. סוג callback, או סוג שממתין ל-tick
אם מפרידים כאן, התמונה מתבהרת בבת אחת.
-
System.Threading.Timerו-DispatcherTimerהם מסוג callback / event -
PeriodicTimerהוא סוג שממתין ל-tick עםawait
כלומר:
- סוג callback: “צד הטיימר קורא”
-
PeriodicTimer: “אנחנו ממתינים ל-tick הבא”
זה ההבדל.
אם גוף העיבוד הוא async, ורוצים לקרוא את “להמתין ← לעבד ← להמתין שוב” כזרימה אחת רציפה, PeriodicTimer טבעי יותר.
מנגד:
- רוצים להעלות על תכנון קיים מבוסס callback
- גוף העיבוד קצר וסינכרוני
- פשוט רוצים בעיטה תקופתית
במצבים כאלה, System.Threading.Timer מתאים.
PeriodicTimer נוח, אבל הוא לא פותר הכול.
זה לא מניח שליחת כמה WaitForNextTickAsync בו-זמנית לאותו טיימר,
וגם אם מתרחשים כמה tick בזמן שלא ממתינים, הם מתקפלים לתוך אחד.
חשוב לא לטעות ולחשוב “זה ישלים פיגור מעצמו”.
flowchart TB
accTitle: סוג callback לעומת סוג שממתין ל-tick
accDescr: תרשים המראה ש-System.Threading.Timer ו-DispatcherTimer הם מסוג callback שצד הטיימר קורא, ו-PeriodicTimer הוא סוג שאנחנו ממתינים ל-tick הבא עם await.
q{"איזה סוג?"}
q -->|"סוג callback"| cb["צד הטיימר קורא"]
q -->|"סוג שממתין ל-tick"| tick["אנחנו ממתינים ל-tick הבא"]
cb --> cbex["Timer ו-DispatcherTimer"]
tick --> ptex["PeriodicTimer"]
tick -.-> flow["להמתין ← לעבד ← להמתין שוב, ברצף אחד"]
איור 6: “נקרא” או “ממתין” — ההבחנה הזו לבדה כבר מבהירה הרבה.
3.2. רץ ב-ThreadPool, או בת’רד ה-UI
הדבר הבא שכדאי לבדוק הוא איפה זה מתבצע.
ה-callback של System.Threading.Timer רץ ב-ThreadPool, לא בת’רד שיצר אותו.
לכן הוא מתאים לעיבוד ברקע, אבל לא בהנחה שנוגעים ישירות ב-UI.
מצד שני, DispatcherTimer הוא טיימר UI שמשולב בתור ה-Dispatcher.
ב-WPF, הוא רץ על אותו Dispatcher, כך שאפשר לעדכן UI ישירות בתוך מטפל ה-Tick.
ההבדל הזה משמעותי מאוד.
- כדי לגעת ב-UI מטיימר ThreadPool, צריך להחזיר במפורש ל-UI
- DispatcherTimer מקל לגעת ב-UI, אבל בתמורה צורך זמן מת’רד ה-UI
כלומר, היתרון של DispatcherTimer הוא “אפשר לגעת ב-UI בבטחה”,
אבל זה גם אומר “אם מכניסים עיבוד כבד, זה גורר גם קלט וציור מחדש”.
flowchart TB
accTitle: ההבדל במקום הריצה והמחיר שלו
accDescr: תרשים המראה שה-callback של System.Threading.Timer רץ ב-ThreadPool ודורש החזרה מפורשת כדי לגעת ב-UI, בעוד DispatcherTimer רץ בת'רד ה-UI ומאפשר לגעת ב-UI ישירות, אך זה עולה בצריכת זמן שמכבידה על עיבוד כבד.
st["Timer: רץ ב-ThreadPool"] --> back["לגעת ב-UI דורש החזרה מפורשת"]
dt["DispatcherTimer: ת'רד ה-UI"] --> easy["אפשר לגעת ב-UI ישירות"]
easy --> cost["עיבוד כבד גורר קלט וציור"]
איור 7: ההבדל במקום הריצה הוא בעצם עסקת חליפין בין קלות הנגיעה ב-UI לצריכת זמן ת’רד ה-UI.
3.3. עיבוד מחזורי והבטחת דיוק הם נושאים שונים
הנקודה הזו חשובה כהמשך למאמר הקודם.
גם אם הניסוח “לעשות משהו במרווח קבוע” זהה,
- מבחינת האפליקציה, רוצים עיבוד תקופתי כל כמה שניות
- ברמה של מילישנייה עד כמה מילישניות, רוצים להתקרב ל-deadline ככל האפשר
אלה שני נושאים שונים.
System.Threading.Timer הוא טיימר קל וקל לשימוש, אבל לא כלי ייעודי לדיוק.
גם DispatcherTimer מושפע מנסיבות תור ה-Dispatcher ומהעדיפות.
גם PeriodicTimer, אם מסתכלים רק על השם, נראה “כאילו המחזור מדויק”, אבל היתרון שלו בעבודה בפועל הוא לא דיוק (precision), אלא קלות כתיבת זרימת async.
לכן:
- רוצים לכתוב הרצה תקופתית של האפליקציה
- או לכוונן דיוק המתנה
בטוח יותר להפריד ביניהם כבר בהתחלה.
אם שני הנושאים האלה מתערבבים, הדיון בבחירת הטיימר נוטה לגלוש לכיוון מוזר.
flowchart TB
accTitle: הפרדה בין הרצה תקופתית לדיוק המתנה
accDescr: תרשים המראה שרצון האפליקציה לעיבוד תקופתי כל כמה שניות שונה מרצון להתקרב ל-deadline ברמת מילישנייה, ששלושת הטיימרים לא מיועדים לדיוק.
same["רוצים לעשות משהו במרווח קבוע"] --> a["הרצה תקופתית של האפליקציה"]
same --> b["רוצים לכוונן דיוק המתנה"]
a --> timers["כאן נכנסים שלושת הטיימרים"]
b --> design["עוברים לתכנון אופן ההמתנה עצמו"]
b -.-> note["שלושת הטיימרים אינם כלי דיוק ייעודי"]
איור 8: גם אם הניסוח “מרווח קבוע” זהה, הרצה תקופתית והבטחת דיוק הם נושאים שונים.
4. דפוסים אופייניים
4.1. לעיבוד תקופתי אסינכרוני — PeriodicTimer
ב-worker, BackgroundService, או עיבוד שרץ ברקע בקונסולה,
אם רוצים להריץ עיבוד async במרווח קבוע, PeriodicTimer הכי קל לכתיבה בתור בחירה ראשונה.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class CacheRefreshWorker : BackgroundService
{
private readonly ILogger<CacheRefreshWorker> _logger;
public CacheRefreshWorker(ILogger<CacheRefreshWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CacheRefreshWorker started.");
await RefreshCacheAsync(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshCacheAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("CacheRefreshWorker stopping.");
}
}
private async Task RefreshCacheAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Refreshing cache...");
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
היתרון של הצורה הזו:
- קל לעקוב אחרי זרימת הקוד כשיטת async אחת
- קל להעביר את
CancellationTokenישירות למורד הזרם - אפשר לצמצם ניהול מחזור חיים וחריגות מבוסס callback
בפרט, כשגוף העיבוד:
- קורא ל-HTTP
- שואל DB
- קורא קובץ
- עושה await ל-API אסינכרוני אחר
כלומר מבוסס בעיקר על המתנת I/O — ההתאמה טובה מאוד.
שתי נקודות לתשומת לב:
- משתמשים בהנחת יסוד של טיימר אחד ל-consumer אחד
- קובעים בעצמכם מדיניות למקרה שהעיבוד ארוך מהמחזור
PeriodicTimer לא רץ במקביל באופן אוטומטי כדי להשלים פיגור, גם אם העיבוד הקודם התארך.
במובן הזה, זה טיימר ל”כתיבה טבעית של לולאת async במרווח קבוע”.
אם חושבים גם על נוחות בדיקה, נוח בשקט שאפשר להשתמש בבנאי (constructor) שמקבל TimeProvider.
flowchart TB
accTitle: זרימת ההרצה התקופתית עם PeriodicTimer
accDescr: תרשים המראה שממתינים עם WaitForNextTickAsync, מריצים את גוף העיבוד ה-async, וממתינים שוב, וכשה-token מתבטל יוצאים מהלולאה, כשעיבוד שמתעכב לא רץ במקביל באופן אוטומטי.
wait["המתנה עם WaitForNextTickAsync"] --> work["הרצת גוף העיבוד ה-async"]
work --> wait
wait -.->|"ביטול ה-token"| exitl["יציאה מהלולאה ועצירה"]
work -.-> note["פיגור לא מקבל מקבילה אוטומטית"]
איור 9: PeriodicTimer מאפשר לכתוב “להמתין ← לעבד ← להמתין שוב” כזרימה אחת של שיטת async.
4.2. להרצת callback קל ב-ThreadPool — System.Threading.Timer
אם רק רוצים לקרוא תקופתית ל-callback קצר, System.Threading.Timer פשוט.
לדוגמה:
- שליחת heartbeat
- איסוף מטריקות קלות
- בדיקת פקיעת תוקף קצרה
- תלייה על תכנון קיים מבוסס callback
מצבים כאלה.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class HeartbeatService : IHostedService, IDisposable
{
private readonly ILogger<HeartbeatService> _logger;
private Timer? _timer;
private int _running;
public HeartbeatService(ILogger<HeartbeatService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(OnTimer, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private void OnTimer(object? state)
{
if (Interlocked.Exchange(ref _running, 1) != 0)
{
return;
}
try
{
_logger.LogInformation("Heartbeat: {Now}", DateTimeOffset.Now);
}
finally
{
Volatile.Write(ref _running, 0);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
בדוגמה הזו הוכנס Interlocked.Exchange כי System.Threading.Timer לא ממתין לסיום ה-callback הקודם.
זו נקודה חשובה מאוד.
- ה-callback רץ ב-ThreadPool
- ה-callback מניח כניסה חוזרת (reentrant)
- אם העיבוד ארוך מהמרווח, ייתכן חפיפה
אפשר לראות את “החפיפה האפשרית” הזו בפועל בבדיקת היחידה בדוגמה. אם מכניסים לטיימר במחזור של 50ms עיבוד שאורך 300ms, וסופרים את מספר ההרצות המקבילות המרבי, מקבלים 2 ומעלה. בגרסה עם אותה שמירה עם Interlocked.Exchange כמו למעלה, גם באותם תנאים ההרצה המקבילית המרבית נשארת 1, ובמקום זאת ה-callback שהופעל בזמן ריצה מדולג.
// קטע מתוך tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs
// בלי שמירה: מחזור 50ms מול עיבוד 300ms. ממתינים עד שנצפית חפיפה
bool overlapped = await WaitUntilAsync(
() => Volatile.Read(ref maxObserved) >= 2,
TimeSpan.FromSeconds(10));
Assert.True(overlapped, "timer callbacks did not overlap within the timeout.");
// עם שמירה: גם באותם תנאים, ההרצה המקבילית המרבית נשארת 1
Assert.Equal(1, Volatile.Read(ref maxConcurrent));
אם העיבוד לא קל, אפשר:
- לדלג על הפעלה כפולה
- להכניס לתור
- לעבור ל-
PeriodicTimer
לתכנן כך רגוע יותר.
flowchart TB
accTitle: חפיפת callback ושמירה מפניה
accDescr: תרשים המראה שכיוון ש-System.Threading.Timer לא ממתין לסיום ה-callback הקודם, אם העיבוד ארוך מהמרווח יש חפיפה. שמירה עם Interlocked.Exchange משאירה הרצה מקבילית אחת ומדלגת על ההפעלה הנוכחית.
fire["הפעלת callback בכל מחזור"] --> q{"הקודם עדיין רץ?"}
q -->|"בלי שמירה"| overlap["ה-callback רץ בחפיפה"]
q -->|"עם שמירה"| skip["דילוג על ההפעלה הנוכחית"]
skip --> one["ההרצה המקבילית נשארת 1"]
איור 10: בטיימר שלא ממתין לסיום הקודם, מחליטים בעצמכם אם לאפשר חפיפה או לחסום עם שמירה.
עוד נקודה חשובה בשקט היא להחזיק בהפניה.
System.Threading.Timer נהיה מועמד ל-GC אם ההפניה נעלמת, גם אם הוא עדיין פעיל.
גם מיד אחרי קריאה ל-Dispose(), ייתכן שה-callback שכבר הוכנס לתור ירוץ אחר כך.
כלומר, System.Threading.Timer הוא:
- קל משקל
- מהיר
- פשוט
אבל בתמורה, זהו טיימר שבו אנחנו אחראים על נסיבות ה-callback.
flowchart TB
accTitle: נקודות לתשומת לב במחזור חיי System.Threading.Timer
accDescr: תרשים המראה שיש להחזיק בהפניה ל-System.Threading.Timer, אחרת הוא נהיה מועמד ל-GC, ושגם אחרי Dispose ייתכן ש-callback שכבר בתור ירוץ מאוחר יותר.
t["System.Threading.Timer"] --> ref["המשך החזקת הפניה"]
ref -.-> gc["אם ההפניה נעלמת, מועמד ל-GC"]
t --> disp["סידור עם Dispose"]
disp -.-> late["callback שכבר בתור עלול לרוץ אחר כך"]
איור 11: קל משקל, אבל בתמורה גם החזקת ההפניה וגם ה-callback שאחרי Dispose באחריותנו.
4.3. לעדכון UI ב-WPF — DispatcherTimer
ב-WPF, אם רוצים לעדכן תקופתית שעון על המסך או תצוגת מצב קלה, DispatcherTimer טבעי.
using System;
using System.Windows;
using System.Windows.Threading;
public partial class MainWindow : Window
{
private readonly DispatcherTimer _clockTimer;
public MainWindow()
{
InitializeComponent();
_clockTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
_clockTimer.Tick += ClockTimer_Tick;
_clockTimer.Start();
}
private void ClockTimer_Tick(object? sender, EventArgs e)
{
ClockText.Text = DateTime.Now.ToString("HH:mm:ss");
}
protected override void OnClosed(EventArgs e)
{
_clockTimer.Stop();
_clockTimer.Tick -= ClockTimer_Tick;
base.OnClosed(e);
}
}
DispatcherPriority.Background שמועבר לבנאי מציין באיזו עדיפות בתור ה-Dispatcher מעובד ה-Tick. גם new DispatcherTimer() בלי ארגומנטים מגיע כברירת מחדל ל-Background, כך שכאן זו רק הצהרה מפורשת של ברירת המחדל — ההתנהגות לא משתנה. Background (ערך 4) היא עדיפות של “מעבדים אחרי שכל שאר העיבוד הלא-idle הסתיים”, נמוכה מ-Input (5) ומ-Render (7). כלומר, זה לא דוחף את הטיפול בקלט או בציור כדי להריץ Tick. זה מתאים לשימושים כמו תצוגת שעון, שבהם “קצת סטייה זה בסדר, אבל לא רוצים להפריע לפעולה”. אם רוצים להוציא את תוצאת ה-Tick למסך מהר ככל האפשר, יש גם אפשרות להעלות עד Normal (9), אבל אז ההנחה שגוף ה-Tick נשאר קל מתחזקת.
flowchart TB
accTitle: המיקום של DispatcherPriority
accDescr: תרשים המראה ש-Background מעבד אחרי שכל שאר העיבוד הלא-idle הסתיים, נמוך מ-Input ו-Render, ולא דוחק קלט וציור מהתור, ושאם רוצים מהירות אפשר להעלות עד Normal בתמורה לשמירה על Tick קל.
tick["ה-Tick של DispatcherTimer"] --> bg["נכנס בעדיפות Background(ערך 4)"]
bg --> after["מעובד אחרי קלט וציור"]
after -.-> fit["מתאים לשעון שלא מפריע לפעולה"]
bg -.-> up["אם דוחק, לעלות ל-Normal(ערך 9)"]
up -.-> cond["בתמורה, Tick חייב להישאר קל"]
איור 12: עדיפות ה-Background כברירת מחדל ממקמת את ה-Tick במקום שלא דוחק קלט וציור.
היתרון של DispatcherTimer הוא שה-Tick מעובד על ה-Dispatcher של WPF, ולכן אפשר לגעת ב-UI ישירות.
לדוגמה, זה מתאים ל:
- תצוגת שעון
- עדכון תצוגה קל של מצב חיבור
- טריגר להערכה מחדש של Command
- עדכון קל של מספרים שמוצגים על המסך
מצבים כאלה.
עם זאת, גם כאן יש נקודה שמשנה את האווירה.
DispatcherTimer רץ בת’רד ה-UI, ולכן אם מכניסים עיבוד כבד למטפל ה-Tick, זה גורר איתו את הקלט, הציור ואף את מיקום מחדש ומאט אותם.
בנוסף, DispatcherTimer הוא לא כלי שמבטיח “בדיוק בזמן שנקבע”.
הוא מושפע מעבודות אחרות בתור ה-Dispatcher ומהעדיפות.
לכן, בעבודה בפועל:
- לשמור על תוכן ה-Tick קל
- להעביר I/O או CPU כבדים למקום אחר
- בסגירה, לציין במפורש את מחזור החיים עם
Stop()וביטול המנוי
מודעות לזה יוצרת יציבות.
flowchart TB
accTitle: שלוש נקודות ליציבות DispatcherTimer
accDescr: תרשים המראה שיציבות מושגת אם תוכן ה-Tick נשאר קל, עיבוד כבד של I/O או CPU מועבר לרקע, ומחזור החיים מצוין במפורש עם Stop וביטול מנוי בסגירה.
dt["תפעול DispatcherTimer"] --> l1["Tick נשאר קל"]
dt --> l2["עבודה כבדה עוברת לרקע"]
dt --> l3["Stop וביטול מנוי מציינים מחזור חיים"]
l1 -.-> why["כי זה צורך זמן מת'רד ה-UI"]
איור 13: בתמורה לנוחות הנגיעה הישירה ב-UI, נדרש תפעול שמשאיר את ה-Tick קל ומציין מחזור חיים.
4.4. לעיבוד מחזורי בכיוון soft real-time — כלי אחר
זו נקודת החיבור למאמר הקודם.
מה שנדון במאמר הקודם על soft real-time לא היה “בערך לרוץ כל כמה שניות”, אלא איך מצמצמים תנודתיות במחזור ופספוסי deadline.
בהקשר הזה:
- לא להסתמך על המתנה יחסית עם
Sleep - להשתמש בגישה מונחית-אירועים או ב-waitable timer
- להפריד בין fast path ל-slow path
- למדוד פיגור
אלה הנושאים המרכזיים.
לכן:
- עיבוד תקופתי async רגיל של אפליקציה ←
PeriodicTimer - callback על ThreadPool ←
System.Threading.Timer - עדכון UI ←
DispatcherTimer - דיוק מחזורי כשלעצמו ← עולם המאמר הקודם
נקי יותר להפריד את הבעיה כבר מההתחלה כך.
השאלה “רוצה לרוץ בדיוק ככל האפשר כל 1ms. איזה טיימר .NET מתאים?” היא בערך בחצי כבר לא שאלה של בחירת טיימר, אלא בעיה של אופן ההמתנה והתכנון.
flowchart TB
accTitle: חלוקת הבעיה לארבעה מראש
accDescr: תרשים המראה שעיבוד תקופתי async רגיל שייך ל-PeriodicTimer, callback על ThreadPool שייך ל-System.Threading.Timer, עדכון UI שייך ל-DispatcherTimer, ודיוק מחזורי כשלעצמו שייך לתכנון אופן ההמתנה מהמאמר על soft real-time.
p["מה רוצים לעשות"] --> a["async: PeriodicTimer"]
p --> b["callback: Timer"]
p --> c["UI: DispatcherTimer"]
p --> d["דיוק: תכנון אופן ההמתנה"]
איור 14: לפני “איזה טיימר”, נקי יותר לחלק כבר מההתחלה לאיזו בעיה זו שייכת.
5. אנטי-דפוסים נפוצים
5.1. העברת lambda של async ישירות ל-System.Threading.Timer
זה קורה הרבה.
_timer = new Timer(async _ => await RefreshAsync(), null,
TimeSpan.Zero, TimeSpan.FromSeconds(5));
זה נראה נקי, אבל TimerCallback הוא מסוג void.
כלומר, ה-lambda הזו של async הופכת בפועל להתנהגות מסוג async void.
כתוצאה מכך:
- הצד הקורא לא יכול לעשות await
- אי אפשר להמתין לסיום
- ניהול חריגות קשה
- צריך גם לחשוב בנפרד על חפיפת ה-callback
מצב לא נוח.
שווה לפרט קצת יותר את הסיבה לכך שניהול החריגות קשה. ב-async Task, החריגה נטענת על ה-Task, כך שהקורא מקבל אותה כשעושה await. ל-async void (או המקביל לו) אין Task כזה, ולכן החריגה שנזרקת נזרקת מחדש ישירות אל ה-SynchronizationContext שהיה בתוקף כשהשיטה התחילה. ה-callback של System.Threading.Timer רץ על ה-ThreadPool, ושם אין SynchronizationContext. כתוצאה מכך, החריגה הופכת לחריגה בלתי מטופלת בת’רד ה-ThreadPool, וכברירת מחדל היא מפילה את כל התהליך. אלא אם עוטפים את פנים ה-callback ב-try / catch בעצמנו, אי אפשר לתפוס אותה בחוץ.
flowchart TB
accTitle: לאן הולכת חריגה מ-lambda async שהועברה ל-callback
accDescr: תרשים המראה ש-TimerCallback הוא void, ולכן lambda של async שהועברה אליו הופכת ל-async void, והחריגה נזרקת מחדש להקשר ה-SynchronizationContext בזמן ההתחלה, אבל ב-ThreadPool אין הקשר כזה, ולכן היא הופכת לחריגה בלתי מטופלת שמפילה את התהליך כברירת מחדל.
ex["חריגה בתוך lambda של async"] --> void["התנהגות async void ביציאה"]
void --> ctx{"יש SynchronizationContext?"}
ctx -->|"אין ב-ThreadPool"| unh["הופכת לחריגה בלתי מטופלת ב-ThreadPool"]
unh --> crash["כברירת מחדל, מפילה את כל התהליך"]
ex -.-> guard["רק try / catch עצמי מונע את זה"]
איור 15: החריגה מה-lambda הנקייה למראה של async, ללא מקום לתפוס אותה, מפילה את התהליך.
אם גוף העיבוד הוא async, קריא יותר לשקול קודם PeriodicTimer.
5.2. הכנסת עיבוד כבד ל-Tick של DispatcherTimer
מכיוון ש-DispatcherTimer מאפשר לגעת ב-UI ישירות, מתפתים לכתוב שם הכול.
אבל זה ת’רד ה-UI.
- עיבוד סינכרוני ארוך
- חישוב CPU כבד
- I/O חוסם
- עיבוד עם
awaitארוך שעלול להתחיל פעמיים
אם מכניסים את אלה, זה מתנגש חזיתית עם קלט וציור ה-UI.
יציב יותר לשמור על Tick קל, להעביר עבודה כבדה לרקע, ולהחזיר ל-UI רק את התוצאה הנדרשת.
5.3. לחשוב ש-PeriodicTimer משלים פיגור באופן אוטומטי
גם כאן קל לטעות.
PeriodicTimer מצוין ככלי לכתיבה נקייה של לולאת async במרווח קבוע, אבל אם העיבוד הקודם התארך, הוא לא רץ במקביל באופן אוטומטי כדי להשלים את הפיגור.
אפשר לוודא את זה גם בבדיקת היחידה בדוגמה. אם משאירים טיימר במחזור של 250ms במצב שאף אחד לא ממתין למשך 1.5 שניות ואז ממתינים, ההמתנה הראשונה מתמלאת מיד בזכות מה שהצטבר, אבל ההמתנה השנייה לא מתמלאת מיד. כלומר, כמה tick שהצטברו בזמן ההזנחה מתקפלים לתוך אחד בלבד.
// קטע מתוך tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // בזמן הזה, אף אחד לא ממתין
// ההמתנה הראשונה מתמלאת מיד בזכות ה-tick שהצטבר
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);
// ההמתנה השנייה לא מתמלאת מיד (לא נשארו כמה tick)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);
מכיוון שה-tick שמתרחשים בזמן שלא ממתינים עלולים להתקפל לתוך אחד,
- האם לדלג בפיגור
- האם מספיק להסתכל רק על העדכני ביותר
- האם רוצים בהכרח לעבד את כל הכמות
אלה צריכים להיקבע בתכנון.
flowchart TB
accTitle: קיפול ה-tick בזמן שלא ממתינים
accDescr: תרשים המראה שאם PeriodicTimer מקבל כמה tick בזמן שאף אחד לא ממתין, הם מתקפלים לתוך אחד, ואז צריך להחליט בתכנון האם לדלג בפיגור, להסתכל רק על העדכני, או לעבד את כל הכמות.
idle["כמה tick בזמן שאף אחד לא ממתין"] --> fold["מתקפלים לתוך אחד"]
fold --> q{"איך מתייחסים לפיגור?"}
q --> s1["דילוג"]
q --> s2["הסתכלות רק על העדכני"]
q --> s3["עיבוד כל הכמות"]
איור 16: ה-tick שהצטברו מתקפלים לתוך אחד. אופן השלמת הפיגור נקבע בתכנון, לא אוטומטית.
5.4. דחיית ניהול העצירה ומחזור החיים
בטיימרים, יש יותר תקלות בעצירה מאשר בהפעלה.
מה שקל לפספס הוא בערך אלה:
- יוצרים
System.Threading.Timerכמשתנה מקומי בלי להחזיק בהפניה - לא עוצרים את
System.Threading.Timer, וסביבDispose()נשאר מעורפל - לא עושים
Stop()ל-DispatcherTimer, וגם לא מבטלים את מנוי ה-Tick - גם אחרי סגירת המסך, הטיימר ממשיך למשוך את מחזור חיי האובייקט
בפרט DispatcherTimer עלול להמשיך להחזיק חי את האובייקט שהשיטה שלו קשורה (bind) אליו.
אם מתקבלת תחושה מוזרה של “ה-Window הזה אמור היה להיסגר, אבל הוא עדיין נשאר” — כדאי לחשוד כאן.
flowchart TB
accTitle: מלכודות בעצירה ובניהול מחזור חיים
accDescr: תרשים המראה ש-Timer בלי החזקת הפניה או עם Dispose מעורפל משאיר את דרך העצירה לא ברורה, ו-DispatcherTimer בלי Stop וביטול מנוי ממשיך להחזיק חי את האובייקט הקשור אליו, מה שגורם ל-Window שאמור היה להיסגר להישאר.
m1["Timer בלי החזקת הפניה"] --> trouble["דרך העצירה נשארת מעורפלת"]
m2["Dispose מעורפל"] --> trouble
m3["בלי Stop ובלי ביטול מנוי"] --> keep["ממשיך להחזיק חי את הקשור"]
keep --> ghost["Window שהיה אמור להיסגר נשאר"]
איור 17: יש יותר תקלות בעצירה מאשר בהפעלה. כותבים את הסידור בסיום כבר מההתחלה.
6. רשימת בדיקה לסקירת קוד
- אפשר להסביר אם העיבוד המחזורי הזה נכתב כעדכון UI / callback על ThreadPool / לולאת async
- האם גוף העיבוד הוא async, אבל בכל זאת נדחף בכוח לתוך טיימר מסוג callback
- אם משתמשים ב-
System.Threading.Timer, האם אפשר לעמוד בחפיפת callback, או שיש שמירה מפניה - האם ל-Tick של
DispatcherTimerלא נכנס עיבוד כבד, I/O חוסם או עיבוד סינכרוני ארוך - אם משתמשים ב-
PeriodicTimer, האם נקבעה מדיניות למקרה של פיגור - האם שיטת העצירה (
Change/Dispose/Stop) והזרימה בסיום האפליקציה ברורות - האם ההפניה ל-
System.Threading.Timerנשמרת כראוי - האם יש ביטול מנוי וסידור בסגירת מסך ל-
DispatcherTimer - האם הופרד מראש אם הבעיה היא “הרצה תקופתית של האפליקציה” או “דיוק המתנה”
7. חלוקה גסה בין המצבים
נשים כאן קני מידה לעבודה בפועל.
- רוצים לקרוא ל-API כל 30 שניות ולעדכן הגדרות ←
PeriodicTimer - רוצים לשלוח heartbeat או מטריקות קלות כל 5 שניות ←
System.Threading.Timer - רוצים תצוגת שעון או עדכון סטטוס קל ב-WPF ←
DispatcherTimer - רוצים לגעת ב-UI ישירות בכל Tick ←
DispatcherTimer - גוף העיבוד התקופתי מלא ב-
await, ורוצים לטפל בטבעיות גם בעצירה וגם בחריגות ←PeriodicTimer - רוצים בעיטה קטנה מבוססת callback בעלות נמוכה ←
System.Threading.Timer - דיוק מחזורי ברמת 1–5ms הוא הליבה ← לפני שלושת אלה, להסתכל על אופן ההמתנה מהמאמר הקודם
בגסות רבה, בשורה אחת:
-
PeriodicTimerהוא טיימר בשביל async -
System.Threading.Timerהוא טיימר בשביל callback על ThreadPool -
DispatcherTimerהוא טיימר בשביל UI
עם דרך הזכירה הזו, קשה לטעות בגדול.
8. סיכום
מה שבאמת חשוב בבחירת טיימר ב-.NET הוא לא ההבדל בשמות, אלא שלוש הנקודות האלה:
- איפה זה רץ
- באיזו זרימה רוצים לכתוב
- איך מטפלים בחפיפה ובפיגור
בתור מדיניות, זה מספיק כדי להתמודד היטב:
- עיבוד תקופתי async ←
PeriodicTimer - callback קל על ThreadPool ←
System.Threading.Timer - עדכון UI ב-WPF ←
DispatcherTimer - דיוק הוא הליבה ← אופן המתנה אחר
הטיימרים מתערבבים כי השמות שלהם דומים. אבל התפקידים לא ממש דומים.
-
PeriodicTimerהוא כלי לסידור זרימת async -
System.Threading.Timerהוא כלי לבעיטה תקופתית של callback -
DispatcherTimerהוא כלי לעדכון תקופתי בת’רד ה-UI
רק חשיבה נפרדת על שלושת אלה כבר מרגיעה מאוד את הקוד.
flowchart TB
accTitle: סיכום תפקידי שלושת הטיימרים
accDescr: תרשים המראה ש-PeriodicTimer הוא כלי לסידור זרימת async, System.Threading.Timer הוא כלי לבעיטה תקופתית של callback, DispatcherTimer הוא כלי לעדכון תקופתי בת'רד ה-UI, ולדיוק פונים לתכנון אופן ההמתנה.
t["תפקידי הטיימרים"] --> pt["PeriodicTimer: async"]
t --> st["Timer: callback"]
t --> dt["DispatcherTimer: UI"]
t -.-> rt["לדיוק, לתכנון אופן ההמתנה"]
איור 18: השמות דומים, אבל התפקידים לא. עם החלוקה לשלוש הזו, קשה לטעות בגדול.
מנגד, אם זה מתערבב:
- מה שאמור להיות async נהיה דומה ל-async void
- נגיעה ישירה ב-UI וקריסה
- חפיפת callback ומצב מעורפל
- גם דיון הדיוק המחזורי מתערבב
וקורים דברים מטרידים למדי בשגרה.
קודם כול, מסתכלים מ”איפה רוצים להריץ את זה”. רק זה כבר הופך את בחירת הטיימר להרבה יותר רגועה.
9. מקורות
- חבילת הדוגמאות המלאה של המאמר הזה (ספרייה, דמו, בדיקות יחידה)
- מאמר קשור: מדריך מעשי למקסום soft real-time ב-Windows רגיל — רשימת בדיקה ראשונה
- מאמר קשור: טבלת החלטה מעשית ל-async/await ב-C# — Task.Run ו-ConfigureAwait
- מאמר קשור: async ות’רד ה-UI ב-WPF/WinForms בדף אחד
- Timers - .NET
- PeriodicTimer Class
- PeriodicTimer.WaitForNextTickAsync(CancellationToken) Method
- PeriodicTimer.Dispose Method
- PeriodicTimer Constructor
- Timer Class (System.Threading)
- Timer Constructor (System.Threading)
- Background tasks with hosted services in ASP.NET Core
- DispatcherTimer Class (System.Windows.Threading)
- DispatcherTimer Class (Microsoft.UI.Xaml)
- DispatcherTimer Constructor (ברירת המחדל היא עדיפות Background)
- DispatcherPriority Enum
- Timer Class (System.Windows.Forms) (הדיוק הוא כ-55 מילישניות)
- Async/Await - Best Practices in Asynchronous Programming (הטיפול בחריגות של async void)
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
מה זה .NET Generic Host - התשתית ל-DI, תצורה ולוגים
מסדרים את התפקיד של Generic Host דרך היחסים בין DI, תצורה, לוגים, IHostedService ו-BackgroundService, ומסכמים מנקודת מבט מעשית איפה הוא ב...
מה זה .NET Native AOT — ההבדל מ-JIT ומ-trimming
מהו Native AOT — סקירה לפי ההבדל מ-JIT, ReadyToRun, self-contained, single-file, trimming ו-source generator, וסיכום מעשי של המקרים ש...
async ות'רד ה-UI ב-WPF/WinForms בדף אחד
מסודר כאן ב-WPF / WinForms: לאן חוזרים אחרי await, Dispatcher / Invoke, ConfigureAwait(false), והמקומות שבהם .Result / .Wait() נתקעים.
מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות
המאמר מסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, מנקודת המבט של פספוסים, התראות כפולות, מלכודות בקביעת סיום, סריקה חו...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
תהליכון ה-UI וטיימרים
תהליכון ה-UI של WPF / WinForms, זרימות אסינכרוניות, Dispatcher ותכנון טיימרים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
בפיתוח אפליקציות Windows שכולל הרצה תקופתית, עדכון UI ועיבוד רקע, בחירת הטיימר משפיעה ישירות על איכות המימוש.
ייעוץ טכני וסקירת תכנון
בשלב שרוצים לסדר איפה לחלק את האחריות בין PeriodicTimer ל-DispatcherTimer, אפשר לבחון זאת כייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה ההבדל בין PeriodicTimer ל-System.Threading.Timer?
- ההבדל הגדול ביותר הוא ש-PeriodicTimer הוא טיפוס שממתין ל-tick עם await, ואילו System.Threading.Timer הוא טיפוס מסוג callback. את PeriodicTimer אפשר לכתוב כ"להמתין ← לעבד ← להמתין שוב" בתוך שיטת async אחת ברצף, וקל להעביר את ה-CancellationToken גם למורד הזרם. לעומת זאת, ה-callback של System.Threading.Timer רץ ב-ThreadPool, ולא ממתין לסיום ה-callback הקודם, כך שאם העיבוד ארוך יותר מהמרווח, ייתכן חפיפה. לעיבוד תקופתי אסינכרוני מתאים PeriodicTimer, ולהפעלה תקופתית קלה וסינכרונית של callback מתאים System.Threading.Timer.
- האם PeriodicTimer משלים באופן אוטומטי פיגור בעיבוד?
- לא. גם אם העיבוד הקודם התארך, זה לא רץ במקביל באופן אוטומטי כדי להשלים את הפיגור. גם אם מתרחשים כמה tick בזמן שלא ממתינים, הם מתקפלים לתוך אחד בלבד. לכן, האם לדלג בפיגור, להסתכל רק על העדכני ביותר, או לעבד את כל הכמות — זו החלטת תכנון. גם לא מדובר בהנחת יסוד של שליחת כמה WaitForNextTickAsync בו-זמנית לאותו טיימר.
- אסור להעביר למדה (lambda) של async ל-System.Threading.Timer?
- עדיף להימנע מזה. TimerCallback הוא מסוג void, ולכן ה-lambda של async שמעבירים הופכת בפועל להתנהגות מסוג async void. כתוצאה מכך, הצד הקורא לא יכול לעשות await, אי אפשר להמתין לסיום, ניהול החריגות נהיה קשה, וגם צריך לחשוב בנפרד על חפיפת ה-callback. אם גוף העיבוד הוא async, קריא ובטוח יותר לשקול קודם PeriodicTimer.
- מתי כדאי להשתמש ב-DispatcherTimer?
- משתמשים בו כשרוצים לעדכן UI באופן תקופתי ב-WPF, כמו תצוגת שעון או תצוגת סטטוס קלה. מכיוון ש-Tick מעובד על ה-Dispatcher (ת'רד ה-UI) של WPF, היתרון הוא שאפשר לגעת ב-UI ישירות בתוך המטפל. עם זאת, מכיוון שזה רץ בת'רד ה-UI, אם מכניסים ל-Tick עיבוד כבד או I/O חוסם, זה גורר איתו גם את הקלט והציור ומאט אותם. גם אין הבטחה להפעלה בדיוק בזמן שנקבע. יציב יותר לשמור על Tick קל, להעביר עבודה כבדה לרקע, ולציין במפורש את מחזור החיים עם Stop() וביטול המנוי כשסוגרים את המסך.