שלושה timers ב-.NET —‏ PeriodicTimer, Timer ו-DispatcherTimer

· עודכן בתאריך: · · C#, .NET, WPF, timer, תכנון

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 12 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173401)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). שלושה timers ב-.NET —‏ PeriodicTimer, Timer ו-DispatcherTimer. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173401 https://comcomponent.com/he/blog/periodictimer-system-threading-timer-dispatchertimer-guide/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173401
DOI (הגרסה הזו)
10.5281/zenodo.22173402

במאמר הקודם, מדריך מעשי למקסום soft real-time ב-Windows רגיל — checklist ראשונה, כיסינו את הרעיון של הימנעות מלולאה מחזורית שמסתמכת על Sleep, ושימוש בגישה event-driven או ב-waitable timer. במשפט אחד, המסקנה של המאמר הקודם הייתה: אם רוצים לצמצם jitter במחזור ופספוסי deadline, קודם מתכננים את עצם “אופן ההמתנה”, לפני סוג ה-timer.

אז מה עושים בפיתוח אפליקציות .NET רגיל יותר? כאן קל להתבלבל בין PeriodicTimer, System.Threading.Timer ו-DispatcherTimer.

כולם נקראים “timer”, אבל:

  • timer שממתין ל-tick עם await
  • timer שה-callback שלו מגיע מ-ThreadPool
  • timer שרץ על ה-Dispatcher של ה-UI thread

כלומר, האופי שלהם שונה מאוד.

שלושה timers עם שמות דומים אך אופי שונהתרשים המראה ש-PeriodicTimer הוא timer שממתין ל-tick עם await, System.Threading.Timer הוא timer שה-callback שלו מגיע מ-ThreadPool, ו-DispatcherTimer הוא timer שרץ ב-UI thread.שלושה timersPeriodicTimer: ממתין עם awaitTimer: ה-callback מגיעDispatcherTimer: UI thread

איור 1: כולם נקראים timer, אבל אופן ההמתנה ומקום הריצה שונים לגמרי.

בעבודה בפועל, מה שקל להתבלבל בו הוא בערך אלה:

  • מעבירים lambda של async ל-System.Threading.Timer, למרות שזה עיבוד תקופתי אסינכרוני
  • נוגעים ישירות במסך מ-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 בתור timer ה-UI. המאמר הזה מטה את ההסבר בצד ה-UI ל-WPF, אבל גם קוראי WinForms נכללים בקהל היעד. אם קוראים DispatcherTimer כ-System.Windows.Forms.Timer, ההערות בסעיפים 4.3 ו-5.2 חלות כמו שהן. עם זאת, יש שני הבדלים:

  • System.Windows.Forms.Timer הוא timer חד-thread שה-Tick שלו קורה דרך ה-message loop, ואין לו ציון עדיפות (DispatcherPriority) כמו ל-DispatcherTimer
  • בתיעוד של Microsoft נקבע שהדיוק מוגבל לכ-55 מילישניות. הוא לא מתאים למחזורים דקים — במקרה כזה כדאי לשקול timer שאינו timer UI

יש לציין: הנושא כאן הוא איך כותבים הרצה תקופתית בצד האפליקציה. כשהדיוק המחזורי עצמו הוא הנושא, חוזרים לדיון במאמר הקודם על soft real-time.

תחום הכיסוי של המאמר הזהתרשים המראה שאיך כותבים הרצה תקופתית בצד האפליקציה הוא תחום המאמר הזה, ואילו כשהדיוק המחזורי עצמו הוא הנושא חוזרים למאמר הקודם על soft real-time שעסק בתכנון אופן ההמתנה.איך כותבים הרצה תקופתית באפליקציההדיוק המחזורי עצמומה הנושא?דיון שלושת ה-timers במאמר הזההדיון הקודם על תכנון אופן ההמתנה

איור 2: גם אם זה “לעשות משהו במרווח קבוע” — אופן כתיבת ההרצה התקופתית ותכנון דיוק המחזור הם בעיות שונות.

כמו כן, הקוד שמופיע במאמר הזה פורסם ב-GitHub כחבילת דוגמאות מלאה שאפשר לבנות ולהריץ (ספרייה ודמו קונסולה של PeriodicTimer / System.Threading.Timer, ובדיקות יחידה שבודקות את קיפול ה-tick ואת overlap של ה-callback).

periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)

תוכן עניינים

  1. קודם כל, המסקנות
  2. מבט אחד על התמונה
    • 2.1. סקירה
    • 2.2. טבלת החלטה
  3. מה כדאי להבחין בו קודם
    • 3.1. סוג callback, או סוג שממתין ל-tick
    • 3.2. רץ ב-ThreadPool, או ב-UI thread
    • 3.3. עיבוד מחזורי והבטחת דיוק הם נושאים שונים
  4. דפוסים נפוצים
    • 4.1. לעיבוד תקופתי אסינכרוני — PeriodicTimer
    • 4.2. להרצת callback קל ב-ThreadPool — System.Threading.Timer
    • 4.3. לעדכון UI ב-WPF — DispatcherTimer
    • 4.4. לעיבוד מחזורי בכיוון soft real-time — כלי אחר
  5. anti-patterns נפוצים
  6. checklist ל-code review
  7. איך בוחרים בפועל
  8. סיכום
  9. מקורות

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 18, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

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

  • אם רוצים לכתוב עיבוד במרווח קבוע בטבעיות מבוססת await, קודם PeriodicTimer
  • אם רוצים להפעיל תקופתית callback קל על ה-ThreadPool, System.Threading.Timer
  • אם רוצים לעדכן מסך ב-UI thread של WPF, DispatcherTimer
  • System.Threading.Timer — ה-callback עלול לחפוף. דחיפה רשלנית של עיבוד אסינכרוני פנימה נוטה להסתבך
  • DispatcherTimer מאפשר לגעת ב-UI ישירות, אבל אם מכניסים עיבוד כבד, קל שהוא יעצור גם את ה-UI
  • בהקשר של soft real-time מהמאמר הקודם, שלושת אלה אינם השחקנים הראשיים של המתנה מדויקת

בקיצור, מה שכדאי להסתכל עליו קודם הן שלוש הנקודות הבאות:

  1. באיזה thread / context רוצים להריץ
  2. האם רוצים לכתוב את גוף העיבוד ברצף עם async / await
  3. האם אפשר לקבל overlap של ה-callback

רק ההפרדה בין השלושה הזו כבר מקטינה מאוד בלבול.

שלוש השאלות שבודקים תחילהתרשים המראה שההפרדה בין שלוש שאלות — באיזה thread או context רוצים להריץ, האם רוצים לכתוב ברצף עם async ו-await, והאם אפשר לקבל overlap של callback — מקטינה מאוד בלבול בבחירת ה-timer.איפה רוצים להריץהבחירה ב-timer מתבררתרוצים לכתוב ברצף עם asyncאפשר לקבל overlap של callback

איור 3: לפני שם ה-timer, מפרידים בין שלוש השאלות האלה.

2. מבט אחד על התמונה

2.1. סקירה

תרשים ההחלטה הכולל לבחירת timerתרשים המראה שאם רוצים להריץ ב-UI thread בוחרים DispatcherTimer, אחרת אם רוצים לכתוב בפשטות עם async ו-await בוחרים PeriodicTimer, אחרת אם רוצים callback קל ב-ThreadPool בוחרים System.Threading.Timer, ואם לא משהו מאלה שוקלים תכנון אחר.כןלאכןלאכןלארוצים לעשות משהו במרווח קבוערוצים להריץ ב-UI thread?DispatcherTimerרוצים לכתוב בפשטות עם async / await?PeriodicTimerרוצים callback קל ב-ThreadPool?System.Threading.Timerלשקול תכנון אחר — Channel / BackgroundService / event / waitable timer

איור 4: הסדר — UI thread, כתיבה עם async, ואז callback קל — קובע את שלושת ה-timers.

בעבודה בפועל, ההסתעפות הזו מספיקה ברוב המקרים. כשמתלבטים, הכי בטוח לחתוך קודם: עיבוד אסינכרוני — PeriodicTimer, עדכון UI — DispatcherTimer.

System.Threading.Timer נוח, אבל יש לו הרגלים סביב overlap של callback וניהול lifetime, ולכן הוא קצת קפדני כבחירה ראשונה.

2.2. טבלת החלטה

מצב הבחירה הראשונה היכן זה רץ הסיבה שזה מתאים נקודה ראשונה לתשומת לב
רוצים להריץ עיבוד async כמו HTTP / DB / קובץ I/O במרווח קבוע PeriodicTimer בתוך זרימת מתודת ה-async הנוכחית אפשר לכתוב עם await, ו-stop וביטול פשוטים הנחה: timer אחד, consumer אחד. פיגור לא מקבל מקבילה אוטומטית
רוצים להריץ ב-ThreadPool heartbeat קל / שליחת מטריקות / בדיקת פקיעת cache System.Threading.Timer ThreadPool קל וקטן, מסוג callback. קל להעלות על תכנון מבוסס callback קיים ה-callback מניח reentrancy. עלולה להיות overlap. שומרים הפניה
רוצים תצוגת שעון ב-WPF או עדכון UI קל במרווח קבוע DispatcherTimer ה-Dispatcher (UI thread) של WPF אפשר לגעת ב-UI ישירות. יש עדיפות זמן ההפעלה המדויק לא מובטח. עיבוד כבד חוסם את ה-UI
הדיוק המחזורי הוא הנושא המרכזי, רוצים להימנע מהסתמכות על Sleep לא לשים את שלושת אלה במרכז - המטרה היא לא הרצה תקופתית של אפליקציה, אלא תכנון דיוק ההמתנה להסתכל בצד ה-event / waitable timer

החשוב בטבלה הזו הוא להסתכל על מקום הריצה ואופן הכתיבה, ולא על שם ה-timer. כשיש תקלה בבחירת timer, לרוב זה כי לא הסתכלו על “איפה זה רץ”, ולא בגלל שם ה-API.

נקודת המבט לבחירת timerתרשים המראה שבחירה לפי שם ה-API נוטה לתקלה כי לא מסתכלים איפה זה רץ, בעוד שבחירה לפי מקום הריצה ואופן הכתיבה נותנת החלטה שקשה לטעות בה.בחירה לפי שםתקלה כי לא בדקו איפה זה רץבחירה לפי מקום הריצה ואופן הכתיבהבחירה שקשה לטעות בה

איור 5: רוב התקלות נובעות מבחירה לפי שם. מה שצריך לבדוק הוא “איפה זה רץ” ו”איך רוצים לכתוב”.

3. מה כדאי להבחין בו קודם

3.1. סוג callback, או סוג שממתין ל-tick

אם מפרידים כאן, התמונה מתבהרת בבת אחת.

  • System.Threading.Timer ו-DispatcherTimer הם מסוג callback / event
  • PeriodicTimer הוא סוג שממתין ל-tick עם await

כלומר:

  • סוג callback: “צד ה-timer קורא”
  • PeriodicTimer: “אנחנו ממתינים ל-tick הבא”

זה ההבדל.

אם גוף העיבוד הוא async, ורוצים לקרוא את “להמתין ← לעבד ← להמתין שוב” כזרימה אחת רציפה, PeriodicTimer טבעי יותר.

מנגד:

  • רוצים להעלות על תכנון קיים מבוסס callback
  • גוף העיבוד קצר וסינכרוני
  • פשוט רוצים בעיטה תקופתית

במצבים כאלה, System.Threading.Timer מתאים.

PeriodicTimer נוח, אבל הוא לא פותר הכול. זה לא מניח שליחת כמה WaitForNextTickAsync בו-זמנית לאותו timer, וגם אם מתרחשים כמה tick בזמן שלא ממתינים, הם מתקפלים לתוך אחד.

חשוב לא לטעות ולחשוב “זה ישלים פיגור מעצמו”.

סוג callback לעומת סוג שממתין ל-tickתרשים המראה ש-System.Threading.Timer ו-DispatcherTimer הם מסוג callback שצד ה-timer קורא, ו-PeriodicTimer הוא סוג שאנחנו ממתינים ל-tick הבא עם await.סוג callbackסוג שממתין ל-tickאיזה סוג?צד ה-timer קוראאנחנו ממתינים ל-tick הבאTimer ו-DispatcherTimerPeriodicTimerלהמתין ← לעבד ← להמתין שוב, ברצף אחד

איור 6: “נקרא” או “ממתין” — ההבחנה הזו לבדה כבר מבהירה הרבה.

3.2. רץ ב-ThreadPool, או ב-UI thread

הדבר הבא שכדאי לבדוק הוא איפה זה מתבצע.

ה-callback של System.Threading.Timer רץ ב-ThreadPool, לא ב-thread שיצר אותו. לכן הוא מתאים לעיבוד ברקע, אבל לא בהנחה שנוגעים ישירות ב-UI.

מצד שני, DispatcherTimer הוא timer UI שמשולב בתור ה-Dispatcher. ב-WPF, הוא רץ על אותו Dispatcher, כך שאפשר לעדכן UI ישירות בתוך handler של Tick.

ההבדל הזה משמעותי מאוד.

  • כדי לגעת ב-UI מ-timer של ThreadPool, צריך להחזיר במפורש ל-UI
  • DispatcherTimer מקל לגעת ב-UI, אבל בתמורה צורך זמן מ-UI thread

כלומר, היתרון של DispatcherTimer הוא “אפשר לגעת ב-UI בבטחה”, אבל זה גם אומר “אם מכניסים עיבוד כבד, זה גורר גם קלט ו-repaint”.

ההבדל במקום הריצה והמחיר שלותרשים המראה שה-callback של System.Threading.Timer רץ ב-ThreadPool ודורש חזרה מפורשת כדי לגעת ב-UI, בעוד DispatcherTimer רץ ב-UI thread ומאפשר לגעת ב-UI ישירות, אך זה עולה בצריכת זמן שמכבידה על עיבוד כבד.Timer: רץ ב-ThreadPoolלגעת ב-UI דורש חזרה מפורשתDispatcherTimer: UI threadאפשר לגעת ב-UI ישירותעיבוד כבד גורר קלט וציור

איור 7: ההבדל במקום הריצה הוא בעצם עסקת חליפין בין קלות הנגיעה ב-UI לצריכת זמן UI thread.

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

הנקודה הזו חשובה כהמשך למאמר הקודם.

גם אם הניסוח “לעשות משהו במרווח קבוע” זהה,

  • מבחינת האפליקציה, רוצים עיבוד תקופתי כל כמה שניות
  • ברמה של מילישנייה עד כמה מילישניות, רוצים להתקרב ל-deadline ככל האפשר

אלה שני נושאים שונים.

System.Threading.Timer הוא timer קל וקל לשימוש, אבל לא כלי ייעודי לדיוק. גם DispatcherTimer מושפע מנסיבות תור ה-Dispatcher ומהעדיפות.

גם PeriodicTimer, אם מסתכלים רק על השם, נראה “כאילו המחזור מדויק”, אבל היתרון שלו בעבודה בפועל הוא לא precision, אלא קלות כתיבת זרימת async.

לכן:

  • רוצים לכתוב הרצה תקופתית של האפליקציה
  • או לכוונן דיוק המתנה

בטוח יותר להפריד ביניהם כבר בהתחלה.

אם שני הנושאים האלה מתערבבים, הדיון בבחירת ה-timer נוטה לגלוש לכיוון מוזר.

הפרדה בין הרצה תקופתית לדיוק המתנהתרשים המראה שרצון האפליקציה לעיבוד תקופתי כל כמה שניות שונה מרצון להתקרב ל-deadline ברמת מילישנייה, ששלושת ה-timers לא מיועדים לדיוק.רוצים לעשות משהו במרווח קבועהרצה תקופתית של האפליקציהרוצים לכוונן דיוק המתנהכאן נכנסים שלושת ה-timersעוברים לתכנון אופן ההמתנה עצמושלושת ה-timers אינם כלי דיוק ייעודי

איור 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 ישירות למורד הזרם
  • אפשר לצמצם ניהול lifetime ו-exceptions מבוסס callback

בפרט, כשגוף העיבוד:

  • קורא ל-HTTP
  • שואל DB
  • קורא קובץ
  • עושה await ל-API אסינכרוני אחר

כלומר מבוסס בעיקר על המתנת I/O — ההתאמה טובה מאוד.

שתי נקודות לתשומת לב:

  1. משתמשים בהנחה של timer אחד ל-consumer אחד
  2. קובעים בעצמכם מדיניות למקרה שהעיבוד ארוך מהמחזור

PeriodicTimer לא רץ במקביל באופן אוטומטי כדי להשלים פיגור, גם אם העיבוד הקודם התארך. במובן הזה, זה timer ל”כתיבה טבעית של לולאת async במרווח קבוע”.

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

זרימת ההרצה התקופתית עם PeriodicTimerתרשים המראה שממתינים עם WaitForNextTickAsync, מריצים את גוף העיבוד ה-async, וממתינים שוב, וכשה-token מתבטל יוצאים מהלולאה, כשעיבוד שמתעכב לא רץ במקביל באופן אוטומטי.ביטול ה-tokenהמתנה עם WaitForNextTickAsyncהרצת גוף העיבוד ה-asyncיציאה מהלולאה ו-stopפיגור לא מקבל מקבילה אוטומטית

איור 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 מניח reentrancy
  • אם העיבוד ארוך מהמרווח, ייתכן overlap

אפשר לראות את “ה-overlap האפשרית” הזו בפועל בבדיקת היחידה בדוגמה. אם מכניסים ל-timer במחזור של 50ms עיבוד שאורך 300ms, וסופרים את מספר ההרצות המקבילות המרבי, מקבלים 2 ומעלה. בגרסה עם אותה שמירה עם Interlocked.Exchange כמו למעלה, גם באותם תנאים ההרצה המקבילית המרבית נשארת 1, ובמקום זאת ה-callback שהופעל בזמן ריצה מדולג.

// קטע מתוך tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs

// בלי שמירה: מחזור 50ms מול עיבוד 300ms. ממתינים עד שנצפית overlap
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

לתכנן כך רגוע יותר.

overlap של callback ושמירה מפניהתרשים המראה שכיוון ש-System.Threading.Timer לא ממתין לסיום ה-callback הקודם, אם העיבוד ארוך מהמרווח יש overlap. שמירה עם Interlocked.Exchange משאירה הרצה מקבילית אחת ומדלגת על ההפעלה הנוכחית.בלי שמירהעם שמירההפעלת callback בכל מחזורהקודם עדיין רץ?ה-callback רץ ב-overlapדילוג על ההפעלה הנוכחיתההרצה המקבילית נשארת 1

איור 10: ב-timer שלא ממתין לסיום הקודם, מחליטים בעצמכם אם לאפשר overlap או לחסום עם שמירה.

עוד נקודה חשובה בשקט היא להחזיק בהפניה. System.Threading.Timer נהיה מועמד ל-GC אם ההפניה נעלמת, גם אם הוא עדיין פעיל. גם מיד אחרי קריאה ל-Dispose(), ייתכן שה-callback שכבר הוכנס לתור ירוץ אחר כך.

כלומר, System.Threading.Timer הוא:

  • קל משקל
  • מהיר
  • פשוט

אבל בתמורה, זהו timer שבו אנחנו אחראים על נסיבות ה-callback.

נקודות לתשומת לב ב-lifetime של System.Threading.Timerתרשים המראה שיש להחזיק בהפניה ל-System.Threading.Timer, אחרת הוא נהיה מועמד ל-GC, ושגם אחרי Dispose ייתכן ש-callback שכבר בתור ירוץ מאוחר יותר.System.Threading.Timerהמשך החזקת הפניהאם ההפניה נעלמת, מועמד ל-GCסידור עם Disposecallback שכבר בתור עלול לרוץ אחר כך

איור 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 נשאר קל מתחזקת.

המיקום של DispatcherPriorityתרשים המראה ש-Background מעבד אחרי שכל שאר העיבוד הלא-idle הסתיים, נמוך מ-Input ו-Render, ולא דוחק קלט וציור מהתור, ושאם רוצים מהירות אפשר להעלות עד Normal בתמורה לשמירה על Tick קל.ה-Tick של DispatcherTimerנכנס בעדיפות Background (ערך 4)מעובד אחרי קלט וציורמתאים לשעון שלא מפריע לפעולהאם דוחק, לעלות ל-Normal (ערך 9)בתמורה, Tick חייב להישאר קל

איור 12: עדיפות ה-Background כברירת מחדל ממקמת את ה-Tick במקום שלא דוחק קלט וציור.

היתרון של DispatcherTimer הוא שה-Tick מעובד על ה-Dispatcher של WPF, ולכן אפשר לגעת ב-UI ישירות.

לדוגמה, זה מתאים ל:

  • תצוגת שעון
  • עדכון תצוגה קל של מצב חיבור
  • טריגר להערכה מחדש של Command
  • עדכון קל של מספרים שמוצגים על המסך

מצבים כאלה.

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

DispatcherTimer רץ ב-UI thread, ולכן אם מכניסים עיבוד כבד ל-handler של Tick, זה גורר איתו את הקלט, הציור ואף את layout ומאט אותם.

בנוסף, DispatcherTimer הוא לא כלי שמבטיח “בדיוק בזמן שנקבע”. הוא מושפע מעבודות אחרות בתור ה-Dispatcher ומהעדיפות.

לכן, בעבודה בפועל:

  • לשמור על תוכן ה-Tick קל
  • להעביר I/O או CPU כבדים למקום אחר
  • בסגירה, לציין במפורש את ה-lifetime עם Stop() וביטול המנוי

מודעות לזה יוצרת יציבות.

שלוש נקודות ליציבות DispatcherTimerתרשים המראה שיציבות מושגת אם תוכן ה-Tick נשאר קל, עיבוד כבד של I/O או CPU מועבר לרקע, וה-lifetime מצוין במפורש עם Stop וביטול מנוי בסגירה.תפעול DispatcherTimerTick נשאר קלעבודה כבדה עוברת לרקעStop וביטול מנוי מציינים lifetimeכי זה צורך זמן מ-UI thread

איור 13: בתמורה לנוחות הנגיעה הישירה ב-UI, נדרש תפעול שמשאיר את ה-Tick קל ומציין lifetime.

4.4. לעיבוד מחזורי בכיוון soft real-time — כלי אחר

זו נקודת החיבור למאמר הקודם.

מה שנדון במאמר הקודם על soft real-time לא היה “בערך לרוץ כל כמה שניות”, אלא איך מצמצמים jitter במחזור ופספוסי deadline.

בהקשר הזה:

  • לא להסתמך על המתנה יחסית עם Sleep
  • להשתמש בגישה event-driven או ב-waitable timer
  • להפריד בין fast path ל-slow path
  • למדוד פיגור

אלה הנושאים המרכזיים.

לכן:

  • עיבוד תקופתי async רגיל של אפליקציה ← PeriodicTimer
  • callback על ThreadPool ← System.Threading.Timer
  • עדכון UI ← DispatcherTimer
  • דיוק מחזורי כשלעצמו ← עולם המאמר הקודם

נקי יותר להפריד את הבעיה כבר מההתחלה כך.

השאלה “רוצה לרוץ בדיוק ככל האפשר כל 1ms. איזה timer .NET מתאים?” היא בערך בחצי כבר לא שאלה של בחירת timer, אלא בעיה של אופן ההמתנה והתכנון.

חלוקת הבעיה לארבעה מראשתרשים המראה שעיבוד תקופתי async רגיל שייך ל-PeriodicTimer, callback על ThreadPool שייך ל-System.Threading.Timer, עדכון UI שייך ל-DispatcherTimer, ודיוק מחזורי כשלעצמו שייך לתכנון אופן ההמתנה מהמאמר על soft real-time.מה רוצים לעשותasync: PeriodicTimercallback: TimerUI: DispatcherTimerדיוק: תכנון אופן ההמתנה

איור 14: לפני “איזה timer”, נקי יותר לחלק כבר מההתחלה לאיזו בעיה זו שייכת.

5. anti-patterns נפוצים

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
  • אי אפשר להמתין לסיום
  • ניהול exceptions קשה
  • צריך גם לחשוב בנפרד על overlap של ה-callback

מצב לא נוח.

שווה לפרט קצת יותר את הסיבה לכך שניהול ה-exceptions קשה. ב-async Task, ה-exception נטען על ה-Task, כך שהקורא מקבל אותו כשעושה await. ל-async void (או המקביל לו) אין Task כזה, ולכן ה-exception שנזרק נזרק מחדש ישירות אל ה-SynchronizationContext שהיה בתוקף כשהמתודה התחילה. ה-callback של System.Threading.Timer רץ על ה-ThreadPool, ושם אין SynchronizationContext. כתוצאה מכך, ה-exception הופך ל-unhandled exception ב-thread של ThreadPool, וכברירת מחדל הוא מפיל את כל ה-process. אלא אם עוטפים את פנים ה-callback ב-try / catch בעצמנו, אי אפשר לתפוס אותו בחוץ.

לאן הולך exception מ-lambda async שהועברה ל-callbackתרשים המראה ש-TimerCallback הוא void, ולכן lambda של async שהועברה אליו הופכת ל-async void, וה-exception נזרק מחדש להקשר ה-SynchronizationContext בזמן ההתחלה, אבל ב-ThreadPool אין הקשר כזה, ולכן הוא הופך ל-unhandled exception שמפיל את ה-process כברירת מחדל.אין ב-ThreadPoolexception בתוך lambda של asyncהתנהגות async void ביציאהיש SynchronizationContext?הופך ל-unhandled exception ב-ThreadPoolכברירת מחדל, מפיל את כל ה-processרק try / catch עצמי מונע את זה

איור 15: ה-exception מה-lambda הנקייה למראה של async, ללא מקום לתפוס אותו, מפיל את ה-process.

אם גוף העיבוד הוא async, קריא יותר לשקול קודם PeriodicTimer.

5.2. הכנסת עיבוד כבד ל-Tick של DispatcherTimer

מכיוון ש-DispatcherTimer מאפשר לגעת ב-UI ישירות, מתפתים לכתוב שם הכול. אבל זה UI thread.

  • עיבוד סינכרוני ארוך
  • חישוב CPU כבד
  • I/O חוסם
  • עיבוד עם await ארוך שעלול להתחיל פעמיים

אם מכניסים את אלה, זה מתנגש חזיתית עם קלט וציור ה-UI.

יציב יותר לשמור על Tick קל, להעביר עבודה כבדה לרקע, ולהחזיר ל-UI רק את התוצאה הנדרשת.

5.3. לחשוב ש-PeriodicTimer משלים פיגור באופן אוטומטי

גם כאן קל לטעות.

PeriodicTimer מצוין ככלי לכתיבה נקייה של לולאת async במרווח קבוע, אבל אם העיבוד הקודם התארך, הוא לא רץ במקביל באופן אוטומטי כדי להשלים את הפיגור.

אפשר לוודא את זה גם בבדיקת היחידה בדוגמה. אם משאירים timer במחזור של 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 שמתרחשים בזמן שלא ממתינים עלולים להתקפל לתוך אחד,

  • האם לדלג בפיגור
  • האם מספיק להסתכל רק על העדכני ביותר
  • האם רוצים בהכרח לעבד את כל הכמות

אלה צריכים להיקבע בתכנון.

קיפול ה-tick בזמן שלא ממתיניםתרשים המראה שאם PeriodicTimer מקבל כמה tick בזמן שאף אחד לא ממתין, הם מתקפלים לתוך אחד, ואז צריך להחליט בתכנון האם לדלג בפיגור, להסתכל רק על העדכני, או לעבד את כל הכמות.כמה tick בזמן שאף אחד לא ממתיןמתקפלים לתוך אחדאיך מתייחסים לפיגור?דילוגהסתכלות רק על העדכניעיבוד כל הכמות

איור 16: ה-tick שהצטברו מתקפלים לתוך אחד. אופן השלמת הפיגור נקבע בתכנון, לא אוטומטית.

5.4. דחיית ניהול ה-stop וה-lifetime

ב-timers, יש יותר תקלות ב-stop מאשר בהפעלה.

מה שקל לפספס הוא בערך אלה:

  • יוצרים System.Threading.Timer כמשתנה מקומי בלי להחזיק בהפניה
  • לא עוצרים את System.Threading.Timer, וסביב Dispose() נשאר מעורפל
  • לא עושים Stop() ל-DispatcherTimer, וגם לא מבטלים את מנוי ה-Tick
  • גם אחרי סגירת המסך, ה-timer ממשיך למשוך את ה-lifetime של האובייקט

בפרט DispatcherTimer עלול להמשיך להחזיק חי את האובייקט שהמתודה שלו קשורה (bind) אליו. אם מתקבלת תחושה מוזרה של “ה-Window הזה אמור היה להיסגר, אבל הוא עדיין נשאר” — כדאי לחשוד כאן.

מלכודות ב-stop ובניהול lifetimeתרשים המראה ש-Timer בלי החזקת הפניה או עם Dispose מעורפל משאיר את דרך ה-stop לא ברורה, ו-DispatcherTimer בלי Stop וביטול מנוי ממשיך להחזיק חי את האובייקט הקשור אליו, מה שגורם ל-Window שאמור היה להיסגר להישאר.Timer בלי החזקת הפניהדרך ה-stop נשארת מעורפלתDispose מעורפלבלי Stop ובלי ביטול מנויממשיך להחזיק חי את הקשורWindow שהיה אמור להיסגר נשאר

איור 17: יש יותר תקלות ב-stop מאשר בהפעלה. כותבים את ה-cleanup ב-shutdown כבר מההתחלה.

6. checklist ל-code review

  • אפשר להסביר אם העיבוד המחזורי הזה נכתב כעדכון UI / callback על ThreadPool / לולאת async
  • האם גוף העיבוד הוא async, אבל בכל זאת נדחף בכוח לתוך timer מסוג callback
  • אם משתמשים ב-System.Threading.Timer, האם אפשר לעמוד ב-overlap של callback, או שיש שמירה מפניה
  • האם ל-Tick של DispatcherTimer לא נכנס עיבוד כבד, I/O חוסם או עיבוד סינכרוני ארוך
  • אם משתמשים ב-PeriodicTimer, האם נקבעה מדיניות למקרה של פיגור
  • האם שיטת ה-stop (Change / Dispose / Stop) והזרימה ב-shutdown של האפליקציה ברורות
  • האם ההפניה ל-System.Threading.Timer נשמרת כראוי
  • האם יש ביטול מנוי ו-cleanup בסגירת מסך ל-DispatcherTimer
  • האם הופרד מראש אם הבעיה היא “הרצה תקופתית של האפליקציה” או “דיוק המתנה”

7. איך בוחרים בפועל

נשים כאן קני מידה לעבודה בפועל.

  • רוצים לקרוא ל-API כל 30 שניות ולעדכן הגדרות ← PeriodicTimer
  • רוצים לשלוח heartbeat או מטריקות קלות כל 5 שניות ← System.Threading.Timer
  • רוצים תצוגת שעון או עדכון סטטוס קל ב-WPF ← DispatcherTimer
  • רוצים לגעת ב-UI ישירות בכל Tick ← DispatcherTimer
  • גוף העיבוד התקופתי מלא ב-await, ורוצים לטפל בטבעיות גם ב-stop וגם ב-exceptions ← PeriodicTimer
  • רוצים בעיטה קטנה מבוססת callback בעלות נמוכה ← System.Threading.Timer
  • דיוק מחזורי ברמת 1–5ms הוא הליבה ← לפני שלושת אלה, להסתכל על אופן ההמתנה מהמאמר הקודם

בגסות רבה, בשורה אחת:

  • PeriodicTimer הוא timer בשביל async
  • System.Threading.Timer הוא timer בשביל callback על ThreadPool
  • DispatcherTimer הוא timer בשביל UI

עם דרך הזכירה הזו, קשה לטעות בגדול.

8. סיכום

מה שבאמת חשוב בבחירת timer ב-.NET הוא לא ההבדל בשמות, אלא שלוש הנקודות האלה:

  1. איפה זה רץ
  2. באיזו זרימה רוצים לכתוב
  3. איך מטפלים ב-overlap ובפיגור

בתור מדיניות, זה מספיק כדי להתמודד היטב:

  1. עיבוד תקופתי async ← PeriodicTimer
  2. callback קל על ThreadPool ← System.Threading.Timer
  3. עדכון UI ב-WPF ← DispatcherTimer
  4. דיוק הוא הליבה ← אופן המתנה אחר

ה-timers מתערבבים כי השמות שלהם דומים. אבל התפקידים לא ממש דומים.

  • PeriodicTimer הוא כלי לכתיבת זרימת async
  • System.Threading.Timer הוא כלי לבעיטה תקופתית של callback
  • DispatcherTimer הוא כלי לעדכון תקופתי ב-UI thread

רק חשיבה נפרדת על שלושת אלה כבר מרגיעה מאוד את הקוד.

סיכום תפקידי שלושת ה-timersתרשים המראה ש-PeriodicTimer הוא כלי לכתיבת זרימת async, System.Threading.Timer הוא כלי לבעיטה תקופתית של callback, DispatcherTimer הוא כלי לעדכון תקופתי ב-UI thread, ולדיוק פונים לתכנון אופן ההמתנה.תפקידי ה-timersPeriodicTimer: asyncTimer: callbackDispatcherTimer: UIלדיוק, לתכנון אופן ההמתנה

איור 18: השמות דומים, אבל התפקידים לא. עם החלוקה לשלוש הזו, קשה לטעות בגדול.

מנגד, אם זה מתערבב:

  • מה שאמור להיות async נהיה דומה ל-async void
  • נגיעה ישירה ב-UI וקריסה
  • overlap של callback ומצב מעורפל
  • גם דיון הדיוק המחזורי מתערבב

וקורים דברים מטרידים למדי בשגרה.

קודם כל, מסתכלים מ”איפה רוצים להריץ את זה”. רק זה כבר הופך את בחירת ה-timer להרבה יותר רגועה.

9. מקורות

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

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

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

שאלות נפוצות

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

מה ההבדל בין PeriodicTimer ל-System.Threading.Timer?
ההבדל הגדול ביותר הוא ש-PeriodicTimer הוא טיפוס שממתין ל-tick עם await, ואילו System.Threading.Timer הוא טיפוס מסוג callback. את PeriodicTimer אפשר לכתוב כ"להמתין ← לעבד ← להמתין שוב" בתוך מתודת async אחת ברצף, וקל להעביר את ה-CancellationToken גם למורד הזרם. לעומת זאת, ה-callback של System.Threading.Timer רץ ב-ThreadPool, ולא ממתין לסיום ה-callback הקודם, כך שאם העיבוד ארוך יותר מהמרווח, ייתכן overlap. לעיבוד תקופתי אסינכרוני מתאים PeriodicTimer, ולהפעלה תקופתית קלה וסינכרונית של callback מתאים System.Threading.Timer.
האם PeriodicTimer משלים באופן אוטומטי פיגור בעיבוד?
לא. גם אם העיבוד הקודם התארך, זה לא רץ במקביל באופן אוטומטי כדי להשלים את הפיגור. גם אם מתרחשים כמה tick בזמן שלא ממתינים, הם מתקפלים לתוך אחד בלבד. לכן, האם לדלג בפיגור, להסתכל רק על העדכני ביותר, או לעבד את כל הכמות — זו החלטת תכנון. גם לא מדובר בהנחה של שליחת כמה WaitForNextTickAsync בו-זמנית לאותו timer.
אסור להעביר lambda של async ל-System.Threading.Timer?
עדיף להימנע מזה. TimerCallback הוא מסוג void, ולכן ה-lambda של async שמעבירים הופכת בפועל להתנהגות מסוג async void. כתוצאה מכך, הצד הקורא לא יכול לעשות await, אי אפשר להמתין לסיום, ניהול ה-exceptions נהיה קשה, וגם צריך לחשוב בנפרד על overlap של ה-callback. אם גוף העיבוד הוא async, קריא ובטוח יותר לשקול קודם PeriodicTimer.
מתי כדאי להשתמש ב-DispatcherTimer?
משתמשים בו כשרוצים לעדכן UI באופן תקופתי ב-WPF, כמו תצוגת שעון או תצוגת סטטוס קלה. מכיוון ש-Tick מעובד על ה-Dispatcher (UI thread) של WPF, היתרון הוא שאפשר לגעת ב-UI ישירות בתוך ה-handler. עם זאת, מכיוון שזה רץ ב-UI thread, אם מכניסים ל-Tick עיבוד כבד או I/O חוסם, זה גורר איתו גם את הקלט והציור ומאט אותם. גם אין הבטחה להפעלה בדיוק בזמן שנקבע. יציב יותר לשמור על Tick קל, להעביר עבודה כבדה לרקע, ולציין במפורש את ה-lifetime עם Stop() וביטול המנוי כשסוגרים את המסך.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג