كيف نختار بين PeriodicTimer و System.Threading.Timer و DispatcherTimer - تنظيم العمل الدوريّ في .NET أوّلاً

· · C#, .NET, WPF, Timer, Design

في المقال السابق دليل عمليّ للزمن الناعم على Windows - قائمة تحقّق لخفض الـ latency، نظّمتُ كيفيّة تجنّب الحلقات الدوريّة المعتمدة على Sleep، ومتى يجب التفكير بأسلوب التصميم المدفوع بالأحداث أو بـ waitable timers.

ولكن، ماذا عن تطوير تطبيقات .NET الاعتياديّ؟ الثلاثيّ الذي يُربك عادةً هناك هو PeriodicTimer و System.Threading.Timer و DispatcherTimer.

كلّها تُسمّى “مؤقّتات”، لكنّها مختلفة جدّاً:

  • مؤقّت يسمح لك بانتظار الـ ticks بـ await
  • مؤقّت يُطلق callbacks على الـ ThreadPool
  • مؤقّت يعمل على Dispatcher الخاصّ بـ UI thread

أنواع الأخطاء التي تظهر في المشاريع الواقعيّة هي عادةً:

  • وضع async lambda داخل System.Threading.Timer رغم أنّ العمل الفعليّ غير متزامن
  • تحديث واجهة WPF مباشرةً من callback لمؤقّت ThreadPool
  • وضع عمل ثقيل في DispatcherTimer وإبطاء الواجهة بأكملها
  • خلط “العمل الدوريّ الاعتياديّ للتطبيق” في هذا المقال ذهنيّاً مع “دقّة التوقيت في الزمن الناعم” من المقال السابق

يفترض هذا المقال في الغالب تطبيقات C# / .NET اعتياديّة على .NET 6 وما بعد، ويُنظّم PeriodicTimer و System.Threading.Timer و DispatcherTimer بترتيب عمليّ.

تشمل الأهداف النموذجيّة:

  • workers و background services
  • تطبيقات الـ console
  • المهامّ الخلفيّة على جانب الخادم في ASP.NET Core
  • تطبيقات WPF لسطح المكتب

عندما أقول DispatcherTimer هنا، أعني أساساً System.Windows.Threading.DispatcherTimer الخاصّ بـ WPF. يحمل WinUI / UWP فكرةً مماثلة. أمّا في WinForms، فإنّ System.Windows.Forms.Timer هو عادةً مؤقّت الواجهة الأكثر طبيعيّةً للنظر فيه.

كذلك، يدور هذا المقال حول كيفيّة كتابة العمل الدوريّ على جانب التطبيق.
إذا كان الموضوع الحقيقيّ هو دقّة التوقيت بحدّ ذاتها، فإنّ ذلك يعود إلى مقال الزمن الناعم السابق.

المحتويات

  1. النسخة المختصرة
  2. أوّلاً: تنظيم الأمر في صفحة واحدة
  3. التمييزات الواجب إجراؤها أوّلاً
  4. الأنماط النموذجيّة
  5. أنماط مضادّة شائعة
  6. قائمة تحقّق للمراجعة
  7. دليل تقريبيّ كقواعد إبهام
  8. الخلاصة
  9. مراجع

1. النسخة المختصرة

  • إذا أردت كتابة عمل بفترات ثابتة بأسلوب طبيعيّ مبنيّ على await، فابدأ بـ PeriodicTimer
  • إذا أردت إطلاق callbacks خفيفة على الـ ThreadPool في فترات منتظمة، فاستخدم System.Threading.Timer
  • إذا أردت تحديثات دوريّة للواجهة على UI thread الخاصّ بـ WPF، فاستخدم DispatcherTimer
  • callbacks الـ System.Threading.Timer يمكن أن تتداخل؛ إن دفعت إليه عملاً غير متزامن باستخفاف، تتفوّض الأمور بسرعة
  • يسمح لك DispatcherTimer بلمس الواجهة مباشرةً، لكنّ العمل الثقيل هناك يمكن أن يبطئ الواجهة بأكملها
  • بمعنى الزمن الناعم في المقال السابق، لا ينبغي معاملة أيّ من هذه الثلاثة على أنّها الأداة الرئيسيّة للانتظار عالي الدقّة

الأسئلة الثلاثة الواجب فصلها أوّلاً هي:

  1. أيّ thread أو context يجب أن يُشغّل هذا العمل؟
  2. هل تريد أن يُقرأ جسم المعالجة بشكل طبيعيّ بأسلوب async / await؟
  3. هل يمكن تحمّل تداخل الـ callbacks؟

مجرّد فصل هذه الأسئلة يجعل الاختيار أسهل بكثير.

2. أوّلاً: تنظيم الأمر في صفحة واحدة

2.1. الصورة الإجماليّة

flowchart LR
    A["You want to do something periodically"] --> B{"Should it run on the UI thread?"}
    B -- "Yes" --> C["DispatcherTimer"]
    B -- "No" --> D{"Do you want the body to read naturally as<br/>async / await?"}
    D -- "Yes" --> E["PeriodicTimer"]
    D -- "No" --> F{"Do you want a light callback<br/>on the ThreadPool?"}
    F -- "Yes" --> G["System.Threading.Timer"]
    F -- "No" --> H["Consider a different design<br/>Channel / BackgroundService / events / waitable timer"]

في العمل اليوميّ، يكفي هذا التفرّع عادةً.

إذا أردت أكثر تخمين أوّل أماناً:

  • للعمل الدوريّ غير المتزامن، ابدأ بـ PeriodicTimer
  • لتحديثات الواجهة، ابدأ بـ DispatcherTimer

System.Threading.Timer مفيد، لكنّه يحمل خصائص أكثر حول تداخل الـ callbacks والـ lifetime. إنّه أكثر مزاجيّةً قليلاً بوصفه “أوّل مؤقّت”.

2.2. أوّل جدول قرار

الموقف الخيار الأوّل المكان الذي يعمل فيه لماذا يلائم أوّل تحذير
عمل I/O غير متزامن دوريّ مثل HTTP / DB / عمل الملفّات PeriodicTimer داخل الـ async flow الحاليّ يُقرأ الكود طبيعيّاً مع await، والإلغاء واضح افترض مؤقّت واحد / مستهلك واحد؛ لا يُوازن العمل المتأخّر تلقائيّاً
heartbeat / metrics / فحوصات انتهاء صلاحيّة الـ cache الخفيفة System.Threading.Timer ThreadPool نموذج callback خفيف الوزن، يسهل ربطه بتصاميم قائمة على الـ callbacks الـ callbacks قابلة للدخول مجدّداً عمليّاً؛ يمكن أن تتداخل؛ احتفظ بمرجع
تحديثات واجهة WPF دوريّة مثل ساعة أو عرض حالة DispatcherTimer Dispatcher الخاصّ بـ WPF (UI thread) يمكنك لمس الواجهة مباشرةً، والأولويّة جزء من النموذج لا ضمان لتوقيت إطلاق دقيق؛ العمل الثقيل يحجب الواجهة
مشكلة تكون فيها دقّة التوقيت بحدّ ذاتها هي الجوهر لا تجعل هذه الثلاثة الأداة الرئيسيّة - تصبح المشكلة عن استراتيجيّة الانتظار بدلاً من اختيار مؤقّت على جانب التطبيق عُد إلى تصميم event / waitable timer / scheduling

أهمّ شيء في هذا الجدول هو أنّ اسم المؤقّت يهمّ أقلّ من سياق التنفيذ ونموذج المعالجة. حين تختار الفرق بشكل سيّئ هنا، يكون الخطأ غالباً ليس في “اسم API الخاطئ”، بل في عدم النظر إلى أين يعمل الكود.

3. التمييزات الواجب إجراؤها أوّلاً

3.1. أسلوب callback مقابل أسلوب انتظار الـ tick

هذا التمييز وحده يُزيل كثيراً من الإرباك.

  • System.Threading.Timer و DispatcherTimer هما بأسلوب callback / event
  • PeriodicTimer بأسلوب انتظار الـ tick عبر await

إذاً:

  • مؤقّتات الـ callback تعني “المؤقّت يستدعيك”
  • PeriodicTimer يعني “أنت تنتظر الـ tick التالي”

إذا كان العمل الفعليّ غير متزامن بالفعل وأردت قراءته على شكل:

  • انتظر
  • نفّذ العمل
  • انتظر مرّةً أخرى

عندئذٍ يكون PeriodicTimer عادةً الأنسب.

في المقابل، إذا:

  • كان التصميم بالفعل قائماً على callbacks
  • كان العمل قصيراً ومتزامناً
  • أردت فقط بدءاً دوريّاً

عندئذٍ يلائم System.Threading.Timer بشكل أكثر طبيعيّة.

PeriodicTimer مفيد، لكنّه ليس سحريّاً. لم يُصمَّم حول مستهلكين متزامنين متعدّدين ينتظرون نفس المؤقّت، ويمكن أن تنطوي ticks تحدث بينما لا تنتظر إلى حدث واحد عمليّاً.

3.2. التنفيذ على ThreadPool مقابل التنفيذ على UI thread

السؤال التالي هو أين يعمل الكود فعلاً.

تعمل callbacks الـ System.Threading.Timer على الـ ThreadPool، لا على الـ thread الذي أنشأ المؤقّت. يجعله ذلك ملائماً للعمل الخلفيّ، لكن ليس للتلاعب المباشر بالواجهة.

في المقابل، يعمل DispatcherTimer على Dispatcher الخاصّ بـ WPF. يعني هذا أنّ معالج Tick الخاصّ به يستطيع لمس الواجهة مباشرةً.

هذا تمييز كبير.

  • يحتاج مؤقّت ThreadPool إلى marshaling صريح إذا أردت تحديث الواجهة
  • DispatcherTimer ملائم لعمل الواجهة، لكن ذلك يعني أيضاً أنّ عمله يستهلك وقت UI thread

لذا فإنّ كون DispatcherTimer “آمناً للوصول المباشر إلى الواجهة” هو قوّته ومخاطرته معاً.

3.3. العمل الدوريّ وضمانات التوقيت مشكلتان مختلفتان

هذه أهمّ صلة بمقال الزمن الناعم السابق.

عبارة “افعل شيئاً دوريّاً” يمكن أن تعني مشكلات مختلفة جدّاً:

  • “نفّذ هذا كلّ بضع ثوانٍ كمهمّة تطبيق”
  • “نفّذ هذا كلّ 1 ms بأقلّ jitter ممكن”

System.Threading.Timer خفيف الوزن وعمليّ، لكنّه ليس أداةً متخصّصةً في دقّة التوقيت. يتأثّر DispatcherTimer أيضاً بطابور الواجهة وأولويّة الـ Dispatcher. يبدو PeriodicTimer دقيقاً من اسمه، لكنّ قوّته الحقيقيّة هي مدى طبيعيّة كتابة async loop به، لا ضمانات التوقيت الصارمة.

لذا من الأكثر أماناً فصل:

  • العمل الدوريّ على جانب التطبيق
  • دقّة التوقيت بوصفها مشكلة زمن حقيقيّ

إن اختلطت هاتان معاً، تصبح المناقشات حول المؤقّتات مشوّشةً بسرعة.

4. الأنماط النموذجيّة

4.1. للعمل الدوريّ غير المتزامن، استخدم PeriodicTimer

إذا أردت عملاً دوريّاً غير متزامن داخل worker أو BackgroundService أو حلقة مقيمة مماثلة، يكون 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 الـ callback ومعالجة الاستثناءات

إنّه مريح بشكل خاصّ حين يكون الجسم في الغالب مرتبطاً بـ I/O:

  • استدعاء HTTP
  • استعلام قاعدة بيانات
  • قراءة الملفّات
  • انتظار APIs غير متزامنة أخرى

تحذيران مهمّان جدّاً:

  1. افترض مؤقّت واحد / مستهلك واحد
  2. قرّر بشكل صريح ما يجب فعله إذا استغرق العمل وقتاً أطول من الفترة

لا يوازن PeriodicTimer تلقائيّاً العمل المتأخّر بهدف “اللحاق”. إنّه أداة لكتابة async periodic loop واضح، لا scheduler يصحّح تصميمك نيابةً عنك.

إذا كانت قابليّة الاختبار مهمّة، فإنّ overloads المُنشِئ التي تتكامل مع TimeProvider مفيدة بهدوء أيضاً.

4.2. لـ callbacks خفيفة على ThreadPool، استخدم System.Threading.Timer

إذا أردت فقط إطلاق callback قصير دوريّاً، فإنّ System.Threading.Timer مباشر.

الحالات النموذجيّة:

  • إرسال heartbeat
  • التقاط metrics خفيفة
  • فحوصات سريعة لانتهاء صلاحيّة الـ cache
  • ربط trigger دوريّ صغير بتصميم قائم على الـ callbacks
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 السابق.

إذاً:

  • تعمل الـ callbacks على ThreadPool
  • التداخل ممكن
  • إذا كان العمل أطول من الفترة، يمكن أن تتراكم الـ callbacks

إذا لم يكن العمل تافهاً، يكون من الأفضل عادةً:

  • تجاوز triggers المكرّرة
  • وضع العمل في طابور في مكان آخر
  • أو التحوّل إلى PeriodicTimer

نقطة عمليّة أخرى هي الاحتفاظ بمرجع. حتّى أثناء كونه نشطاً، يمكن أن يصبح System.Threading.Timer قابلاً للجمع إن فقدت كلّ المراجع إليه. كذلك، بعد استدعاء Dispose()، يمكن أن يعمل callback كان قد أُدرج في الطابور لاحقاً.

إذاً System.Threading.Timer:

  • خفيف الوزن
  • سريع
  • بسيط

لكن فقط إن كنت مستعدّاً لامتلاك سلوك الـ callback بشكل صحيح.

4.3. لتحديثات واجهة 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);
    }
}

الجزء الجيّد هو أنّ Tick يعمل على Dispatcher الخاصّ بـ WPF، لذا فإنّ تحديثات الواجهة مباشرة.

يلائم ذلك سيناريوهات مثل:

  • ساعة
  • عرض حالة اتّصال خفيف
  • triggers لإعادة تقييم الأوامر
  • التحديث الدوريّ لقيم موجودة بالفعل على الشاشة

لكنّ المقايضة تتغيّر معه:

لأنّ DispatcherTimer يعمل على UI thread، فإنّ أيّ عمل ثقيل في Tick يتنافس مباشرةً مع الإدخال والـ layout والعرض.

كذلك، DispatcherTimer ليس أداة “إطلاق في الوقت المطلوب بالضبط”. إنّه يتأثّر بطابور Dispatcher والأولويّة.

لذا في الممارسة:

  • اجعل معالجات Tick خفيفة
  • انقل عمل I/O أو الـ CPU الثقيل إلى مكان آخر
  • أوقف المؤقّت بشكل صريح وألغِ الاشتراك عند الإغلاق

4.4. للمعالجة الدوريّة بأسلوب الزمن الناعم، انظر إلى أدوات مختلفة

هذه هي الصلة بالمقال السابق.

ذلك المقال لم يكن عن “كلّ بضع ثوانٍ تقريباً جيّد بما فيه الكفاية”. كان عن كيفيّة خفض الـ jitter وتفويت الـ deadlines.

في ذلك السياق، الموضوعات المهمّة هي أمور مثل:

  • تجنّب الانتظار النسبيّ المعتمد على Sleep
  • استخدام أسلوب الانتظار المدفوع بالأحداث أو waitable timer
  • فصل fast path و slow path
  • قياس التأخّر بشكل صريح

لذا من الأنظف فصل المساحة الإشكاليّة هكذا:

  • العمل الدوريّ غير المتزامن الاعتياديّ على جانب التطبيق
    PeriodicTimer
  • callbacks ThreadPool خفيفة الوزن
    System.Threading.Timer
  • تحديثات الواجهة
    DispatcherTimer
  • دقّة التوقيت بحدّ ذاتها كمشكلة رئيسيّة
    ← عُد إلى مناقشة الزمن الناعم

بمجرّد أن يصبح السؤال:

“أيّ مؤقّت في .NET يجب أن أستخدم إن أردت العمل كلّ 1 ms بأكبر دقّة ممكنة؟”

فإنّك لم تعد فعلاً تختار بين مؤقّتات على جانب التطبيق. إنّك تصمّم استراتيجيّة الانتظار وسلوك النظام.

5. أنماط مضادّة شائعة

5.1. تمرير async lambda مباشرةً إلى System.Threading.Timer

هذا مغرٍ جدّاً:

_timer = new Timer(async _ => await RefreshAsync(), null,
    TimeSpan.Zero, TimeSpan.FromSeconds(5));

يبدو الأمر أنيقاً، لكنّ TimerCallback هو void. يعني هذا أنّ الـ async lambda هو عمليّاً في منطقة async void.

عندئذٍ:

  • لا يمكن للمستدعي انتظاره
  • لا يمكن تنسيق الإكمال بنظافة
  • تصبح معالجة الاستثناءات أصعب
  • ولا يزال تداخل الـ callbacks مشكلةً منفصلة

إذا كان الجسم غير متزامن فعلاً، فإنّ PeriodicTimer غالباً ما يكون أوّل ما يجب التفكير فيه بدلاً من ذلك.

5.2. وضع عمل ثقيل في DispatcherTimer.Tick

لأنّ DispatcherTimer يستطيع لمس الواجهة مباشرةً، يسهل الاستمرار في إضافة عمل إلى Tick. لكن ذلك هو UI thread.

العمل المتزامن الثقيل أو I/O الحاجب أو المنطق غير المتزامن المعرّض للتداخل هناك يمكن أن يضرّ مباشرةً بالإدخال والعرض.

5.3. افتراض أنّ PeriodicTimer يلحق تلقائيّاً عند التأخّر

هذا سوء فهم شائع آخر.

PeriodicTimer ممتاز للتعبير عن async periodic loop، لكنّه لا يوازن العمل المتأخّر تلقائيّاً ولا يضمن أنّ كلّ فترة فائتة يتمّ إعادة تشغيلها على حدة.

لذا لا يزال عليك أن تقرّر:

  • هل ينبغي تجاوز التكرارات المتأخّرة؟
  • هل آخر حالة فقط هي المهمّة؟
  • هل تحتاج فعلاً إلى معالجة كلّ tick؟

5.4. تأجيل إدارة الإيقاف والـ lifetime

تكون المؤقّتات في الغالب أسهل في البدء منها في الإيقاف بنظافة.

تشمل الأخطاء النموذجيّة:

  • إنشاء System.Threading.Timer دون الاحتفاظ بمرجع
  • استدعاء Dispose على مؤقّت دون فهم أنّ callbacks مُدرجة في الطابور قد تعمل لاحقاً
  • نسيان استدعاء Stop() أو إلغاء الاشتراك في DispatcherTimer

6. قائمة تحقّق للمراجعة

عند مراجعة المعالجة الدوريّة، تحقّق من هذه بالترتيب:

  • هل المشكلة الفعليّة هي تحديثات الواجهة، أم العمل الدوريّ غير المتزامن، أم triggering بأسلوب الـ callback؟
  • هل يطابق المؤقّت المختار سياق التنفيذ؟
  • هل يمكن أن تتداخل الـ callbacks، وإن لم يكن، فأين يُمنع ذلك؟
  • هل الـ lifetime صريح والإيقاف نظيف؟
  • هل يستخدم الكود عن غير قصد مؤقّتاً على جانب التطبيق لمشكلة دقّة التوقيت؟

7. دليل تقريبيّ كقواعد إبهام

ما تريد فعله أوّل شيء يجب اختياره
كتابة عمل دوريّ غير متزامن طبيعيّ PeriodicTimer
إطلاق callbacks دوريّة خفيفة على الـ ThreadPool System.Threading.Timer
تحديث واجهة WPF دوريّاً DispatcherTimer
التحكّم بطابور خلفيّ مرتّب Channel<T> + worker
ملاحقة دقّة التوقيت بحدّ ذاتها تصميم مدفوع بالأحداث / waitable timer / تصميم scheduler

8. الخلاصة

يصبح الاختيار بين PeriodicTimer و System.Threading.Timer و DispatcherTimer أسهل بكثير بمجرّد أن تسأل:

  1. أين يجب أن يعمل هذا العمل؟
  2. هل أريد أن يُقرأ الجسم بوصفه تدفّقاً غير متزامن طبيعيّاً؟
  3. هل يمكن تحمّل تداخل الـ callbacks؟

بمجرّد أن تتّضح هذه الإجابات، يكون اختيار المؤقّت بحدّ ذاته مباشراً عادةً.

9. مراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة