اختيار مؤقّتات .NET الثلاثة: PeriodicTimer و Timer و DispatcherTimer

· آخر تحديث: · · C#, .NET, WPF, مؤقّت, تصميم

سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)

سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.

أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240818)
أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621345)

هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.

小村 豪 (2026). اختيار مؤقّتات .NET الثلاثة: PeriodicTimer و Timer و DispatcherTimer. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621345 https://comcomponent.com/ar/blog/2026/03/12/002-periodictimer-system-threading-timer-dispatchertimer-guide/

DOI (أحدث نسخة)
10.5281/zenodo.21621345
DOI (هذه النسخة)
10.5281/zenodo.22279745

في المقالة السابقة دليل عمليّ لتحقيق زمن ناعم قدر الإمكان على Windows العاديّ: قائمة الفحص التي تُنظر أوّلاً نظّمنا تجنّب حلقة دوريّة تُترك لـ Sleep، واستخدام الدفع بالأحداث أو waitable timer. بجملة: إن أردتم تقليل اهتزاز الدورة و deadline miss، فصمّموا «طريقة الانتظار» نفسها قبل نوع المؤقّت، هذا خلاصة المقالة السابقة.

فماذا عن تطوير تطبيقات .NET اليوميّ أكثر؟ ما يسهل الحيرة فيه هنا هو PeriodicTimer و System.Threading.Timer و DispatcherTimer.

الأسماء كلّها مؤقّتات، لكنّ الطباع تختلف كثيراً:

  • مؤقّت ينتظر الـ tick بـ await
  • مؤقّت يطير فيه callback على ThreadPool
  • مؤقّت يعمل على Dispatcher لـ UI thread
ثلاثة مؤقّتات تتشابه أسماؤها وتختلف طباعهاPeriodicTimer مؤقّت ينتظر الـ tick بـ await، و System.Threading.Timer مؤقّت يطير فيه callback على ThreadPool، و DispatcherTimer مؤقّت يعمل على Dispatcher لـ UI thread.ثلاثة مؤقّتاتPeriodicTimer: الانتظار بـ awaitTimer: callback يطيرDispatcherTimer: UI thread

الشكل 1: الأسماء كلّها مؤقّتات، لكنّ طبع طريقة الانتظار وموضع التشغيل مختلف تماماً.

ما يسهل الاختلاط في العمل اليوميّ هو في الغالب هذا النطاق.

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

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

الحالات المتصوَّرة هذا النطاق.

  • worker / خدمات خلفيّة
  • تطبيقات console
  • معالجة خلفيّة في ASP.NET Core
  • تطبيقات سطح مكتب WPF

DispatcherTimer في هذه المقالة يشير أساساً إلى System.Windows.Threading.DispatcherTimer في WPF. يوجد DispatcherTimer بالفكرة نفسها في WinUI / UWP أيضاً.

في WinForms، النظر إلى System.Windows.Forms.Timer كمؤقّت واجهة أطبيعيّ. تميل هذه المقالة بشرح جانب الواجهة إلى WPF، لكنّ مستخدمي WinForms مشمولون أيضاً. إن قرأتم DispatcherTimer كـ System.Windows.Forms.Timer، تنطبق ملاحظات 4.3 و 5.2 كما هي. فوق ذلك يختلف أمران فقط.

  • System.Windows.Forms.Timer مؤقّت أحاديّ الخيط يحدث Tick عبر حلقة الرسائل، وليس فيه تعيين أولويّة مثل DispatcherPriority في DispatcherTimer
  • وثائق Microsoft تعتبر حدّ الدقّة نحو 55 ملي ثانية. لا يلائم الدورات الدقيقة، فعندئذٍ يُنظر في غير مؤقّت الواجهة

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

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

الشكل 2: حتّى مع عبارة «عمل شيء بفاصل ثابت»، كتابة التنفيذ الدوريّ وتصميم دقّة الدورة مشكلتان مختلفتان.

كذلك، الشيفرة الواردة في هذه المقالة منشورة على GitHub كمجموعة عيّنات قابلة للبناء والتشغيل (مكتبة وعرض console لـ PeriodicTimer / System.Threading.Timer، واختبارات وحدات تتحقّق من طيّ الـ tick وتداخل الـ 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. لتحديث واجهة WPF: DispatcherTimer
    • 4.4. للمعالجة الدوريّة الأقرب إلى الزمن الناعم: النظر إلى أدوات أخرى
  5. أنماط مضادّة شائعة
  6. قائمة فحص عند المراجعة
  7. دليل اختيار تقريبيّ
  8. الخلاصة
  9. المراجع

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 18، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

1. الخلاصة أوّلاً (بجملة)

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

بمعنى آخر، ما يُنظر إليه أوّلاً ثلاثة.

  1. على أيّ خيط / سياق تريدون التشغيل
  2. هل تريدون كتابة جسم المعالجة متسلسلاً بـ async / await
  3. هل تقبلون تداخل الـ callback

تمييز هذه الثلاثة وحده يصعّب الحيرة كثيراً.

الأسئلة الثلاثة التي تُنظر أوّلاًتمييز أين تريدون التشغيل، وهل تريدون كتابة الجسم متسلسلاً بـ async/await، وهل تقبلون تداخل الـ callback، وحده يصعّب الحيرة.أين تريدون التشغيليتحدّد اختيار المؤقّتهل تريدون الكتابة متسلسلة بـ asyncهل تقبلون تداخل الـ callback

الشكل 3: فكّروا في هذه الأسئلة الثلاثة قبل اسم المؤقّت.

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

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

نعملانعملانعملاالرغبة في عمل شيء بفاصل ثابتالتشغيل على UI thread؟DispatcherTimerهل تريدون كتابة الجسم بـ async / await بصراحة؟PeriodicTimerهل تريدون تشغيل callback خفيف على ThreadPool؟System.Threading.Timerالنظر في تصميم آخر: Channel / BackgroundService / event / waitable timer

الشكل 4: القطع بالترتيب: UI thread، ثمّ الكتابة بـ async، ثمّ callback خفيف، يحدّد المؤقّتات الثلاثة.

في العمل اليوميّ يكفي هذا التفرّع في الغالب. عند الحيرة، الأصعب انحرافاً هو القطع أوّلاً: غير المتزامن فـ PeriodicTimer، وتحديث الواجهة فـ DispatcherTimer.

System.Threading.Timer مريح، لكنّ له عادات تداخل الـ callback وإدارة العمر، لذا هو أصعب مزاجاً قليلاً كالخيار الأوّل.

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

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

المهمّ في هذا الجدول هو النظر إلى موضع التنفيذ وطريقة الكتابة أكثر من اسم المؤقّت. عند وقوع حادث في اختيار المؤقّت، الغالب أنّ «أين يعمل» لم يُنظر إليه أكثر من اسم الـ API.

نقطة النظر في اختيار المؤقّتالحادث في اختيار المؤقّت يحدث عند الاختيار بالاسم، والنظر إلى موضع التنفيذ أين يعمل وإلى طريقة الكتابة كيف تريدون الكتابة يصعّب الانحراف.الاختيار بالاسمحادث بعدم النظر إلى أين يعملالاختيار بموضع التنفيذ وطريقة الكتابةاختيار يصعب انحرافه

الشكل 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 عدّة مرّات أثناء عدم الانتظار، تُطوى في مرّة واحدة.

المهمّ عدم إساءة فهم هذا على أنّه «يلحق من تلقاء نفسه».

نوع callback ونوع انتظار الـ tickSystem.Threading.Timer و DispatcherTimer من نوع callback يستدعيكم فيه جانب المؤقّت، و PeriodicTimer من نوع تنتظرون فيه الـ tick التالي بـ await.نوع callbackنوع انتظار الـ tickأيّ النوعينجانب المؤقّت يستدعيكمأنتم تنتظرون الـ tick التاليTimer و DispatcherTimerPeriodicTimerانتظر ثمّ عالج ثمّ انتظر مجدّداً في خطّ واحد

الشكل 6: «تُستدعَون» أم «تنتظرون». هذا التمييز وحده يحسّن الرؤية دفعة واحدة.

3.2. العمل على ThreadPool، أم على UI thread

ما ينبغي النظر إليه تالياً هو أين يُنفَّذ.

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

أمّا DispatcherTimer فمؤقّت واجهة مدمج في طابور Dispatcher. في WPF يعمل على الـ Dispatcher نفسه، فيمكن تحديث الواجهة كما هي داخل معالج Tick.

هذا الفرق كبير جدّاً.

  • لمس الواجهة من مؤقّت ThreadPool يحتاج عودة صريحة إلى الواجهة
  • DispatcherTimer يسهل لمس الواجهة، وبالمقابل يستخدم وقت UI thread

أي أنّ قوّة DispatcherTimer «لمس الواجهة بأمان»، ومعناها في الوقت نفسه «إدخال معالجة ثقيلة يسحب الإدخال وإعادة الرسم أيضاً».

فرق موضع التنفيذ والمقابلcallback لـ System.Threading.Timer يعمل على ThreadPool لذا لمس الواجهة يحتاج عودة صريحة، و DispatcherTimer يلمس الواجهة كما هي مقابل استخدام وقت UI thread.Timer: يعمل على ThreadPoolلمس الواجهة يحتاج عودة صريحةDispatcherTimer: UI threadلمس الواجهة كما هيالمعالجة الثقيلة تسحب الإدخال والرسم

الشكل 7: فرق أين يُنفَّذ يصير مقابلاً بين سهولة لمس الواجهة واستهلاك UI thread.

3.3. المعالجة الدوريّة وضمان الدقّة حديثان مختلفان

هذا مهمّ كصلة بالمقالة السابقة.

حتّى مع العبارة نفسها «عمل شيء بفاصل ثابت»،

  • من مصلحة التطبيق، معالجة دوريّة كلّ بضع ثوانٍ
  • الاقتراب قدر الإمكان من deadline بمستوى 1ms إلى بضعة ms

مشكلتان مختلفتان.

System.Threading.Timer مؤقّت خفيف سهل التعامل، لكنّه ليس أداة مخصّصة للدقة. DispatcherTimer أيضاً يتأثّر بظروف طابور Dispatcher وبالأولويّة.

PeriodicTimer أيضاً يبدو من الاسم وحده «كأنّ الدورة مضبوطة»، لكنّ قوّته في العمل اليوميّ ليست precision بقدر سهولة كتابة تدفّق async.

لذا الأأمن تمييز أوّلاً:

  • هل تريدون كتابة تنفيذ دوريّ للتطبيق
  • أم تريدون ضبط دقّة الانتظار

اختلاط هذين يوجّه نقاش اختيار المؤقّت تدريجيّاً في اتجاه غريب.

فصل التنفيذ الدوريّ عن دقّة الانتظارمصلحة التطبيق في معالجة دوريّة كلّ بضع ثوانٍ، ومشكلة دقّة الانتظار للاقتراب من deadline بمستوى ملي ثانية، مختلفتان، والمؤقّتات الثلاثة ليست أدوات مخصّصة للدقة.الرغبة في عمل شيء بفاصل ثابتتنفيذ دوريّ للتطبيقضبط دقّة الانتظاردور المؤقّتات الثلاثةإلى تصميم طريقة الانتظار نفسهاالثلاثة ليست أدوات دقّة مخصّصة

الشكل 8: حتّى مع عبارة «فاصل ثابت» نفسها، عالجوا التنفيذ الدوريّ وضمان الدقّة كمشكلتين مختلفتين.

4. الأنماط الشائعة

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

في worker أو BackgroundService أو معالجة مقيمة في console وغيرها، إن أردتم تشغيل معالجة 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

خصوصاً إن كان جسم المعالجة مركّزاً على انتظار I/O مثل:

  • استدعاء HTTP
  • الاستعلام من DB
  • قراءة ملفّ
  • await لـ API غير متزامن آخر

فالتوافق جيّد جدّاً.

الملاحظتان اثنتان.

  1. الاستخدام بافتراض مستهلك واحد لكلّ مؤقّت
  2. تقرير السياسة بأنفسكم عندما يطول وقت المعالجة عن الدورة

PeriodicTimer لا يُوازي تلقائيّاً ليلحق لمجرّد أنّ المعالجة السابقة طالت. بهذا المعنى هو مؤقّت لـ «كتابة حلقة async بفاصل ثابت طبيعيّاً».

إن نظرتم أيضاً إلى سهولة الاختبار، فاستخدام مُنشئ يستقبل TimeProvider مريح بهدوء أيضاً.

تدفّق الحلقة الدوريّة بـ PeriodicTimerالانتظار للـ tick التالي بـ WaitForNextTickAsync، وتنفيذ جسم المعالجة async ثمّ الانتظار مجدّداً، كتدفّق واحد، والخروج من الحلقة بإلغاء CancellationToken.إلغاء الـ tokenالانتظار بـ WaitForNextTickAsyncتنفيذ جسم المعالجة asyncالخروج من الحلقة والتوقّفالتأخّر لا يُوازى تلقائيّاً

الشكل 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 يفترض قابليّة إعادة الدخول
  • إن طالت المعالجة عن الفاصل، قد يتداخل

هذا «قد يتداخل» جُعل قابلاً للرصد فعلاً في اختبار وحدات العيّنة. بإدخال معالجة تستغرق 300ms في مؤقّت دورته 50ms وعدّ القيمة القصوى لعدد التنفيذ المتزامن، تصير 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
تداخل الـ callback والحراسةSystem.Threading.Timer لا ينتظر اكتمال الـ callback السابق، لذا قد يتداخل إن طالت المعالجة عن الفاصل. إدخال حراسة Interlocked.Exchange يُبقي التنفيذ المتزامن 1، ويُتخطّى الـ callback الذي أُطلق أثناء التنفيذ.بلا حراسةمع حراسةإطلاق callback عند كلّ فاصلهل السابق ما يزال قيد التنفيذ؟الـ callback يعمل متداخلاًتخطي إطلاق هذه المرّةالتنفيذ المتزامن يبقى 1

الشكل 10: في مؤقّت لا ينتظر الاكتمال السابق، قرّروا بأنفسكم قبول التداخل أو ردّه بالحراسة.

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

أي أنّ System.Threading.Timer:

  • خفيف
  • سريع
  • بسيط

لكنّه بالمقابل مؤقّت تتولّون فيه ظروف الـ callback بأنفسكم كما ينبغي.

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

الشكل 11: مقابل الخفّة، تتولّون الاحتفاظ بالمرجع و callback بعد Dispose أيضاً.

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);
    }
}

DispatcherPriority.Background الممرَّر إلى المُنشئ تعيين لأيّ أولويّة في طابور Dispatcher يُعالَج Tick. new DispatcherTimer() بلا وسائط يصير أيضاً Background افتراضيّاً، لذا هذا مجرّد إظهار للقيمة الافتراضيّة وليس تغييراً للسلوك. Background (القيمة 4) أولويّة «تُعالَج بعد انتهاء كلّ المعالجات غير الخاملة»، وهي أدنى من Input (5) و Render (7). أي أنّ Tick لا يُشغَّل بدفع معالجة الإدخال أو الرسم جانباً. هذا يلائم استخدامات مثل عرض الساعة: «لا بأس بانزياح بسيط، لكن لا نريد إعاقة التشغيل». إن أردتم إخراج نتيجة Tick إلى الشاشة بأسرع ما يمكن، فثمّة خيار الرفع إلى Normal (9)، لكن عندئذٍ يصير افتراض الإبقاء على محتوى Tick خفيفاً أقوى.

موضع DispatcherPriorityBackground أولويّة تُعالَج بعد انتهاء كلّ المعالجات غير الخاملة، وهي أدنى من Input و Render، فلا يُشغَّل Tick بدفع الإدخال أو الرسم. إن أردتم الإسراع إلى الشاشة فثمّة خيار الرفع إلى Normal.Tick لـ DispatcherTimerالتكديس بـ Background (القيمة 4)يُعالَج بعد الإدخال والرسميلائم الساعة التي لا تعيق التشغيلإن استُعجل فالرفع إلى Normal (القيمة 9)وبالمقابل افتراض الإبقاء على Tick خفيفاً

الشكل 12: أولويّة Background الافتراضيّة تكدّس Tick في موضع «لا يدفع الإدخال أو الرسم جانباً».

حسن DispatcherTimer أنّ Tick يُعالَج على Dispatcher في WPF، فيمكن لمس الواجهة كما هي.

هذا يلائم مواضع مثل:

  • عرض ساعة
  • تحديث عرض خفيف لحالة الاتّصال
  • مثير لإعادة تقييم Command
  • تحديث خفيف لرقم ظاهر على الشاشة

غير أنّ ثمّة نقطة يتغيّر فيها الجوّ هنا أيضاً.

DispatcherTimer يعمل على UI thread، لذا إدخال معالجة ثقيلة في معالج Tick يبطئ الإدخال والرسم وإعادة الترتيب كما هي.

كذلك DispatcherTimer ليس أداة تضمن «اللحظة المحدّدة تماماً». يتأثّر بأعمال أخرى على طابور Dispatcher وبالأولويّة.

لذا في العمل اليوميّ يستقرّ الأمر إن وعيتم تقريباً:

  • الإبقاء على محتوى Tick خفيفاً
  • إبعاد I/O أو CPU الثقيل إلى موضع آخر
  • عند الإغلاق استدعاء Stop() وإلغاء الاشتراك لإعلان العمر
ثلاث نقاط تستقرّ بـ DispatcherTimerالإبقاء على محتوى Tick خفيفاً، وإبعاد I/O أو CPU الثقيل إلى موضع آخر، وإعلان العمر بـ Stop وإلغاء الاشتراك عند إغلاق الشاشة، تستقرّ بهذه الثلاث.تشغيل DispatcherTimerالإبقاء على محتوى Tick خفيفاًإبعاد العمل الثقيل إلى الخلفيّةإعلان العمر بـ Stop وإلغاء الاشتراكلأنّه يستخدم وقت UI thread

الشكل 13: مقابل راحة لمس الواجهة كما هي، يلزم تشغيل يُبقي Tick خفيفاً ويعلن العمر.

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

هذه نقطة الاتّصال بالمقالة السابقة.

ما عالجته مقالة الزمن الناعم السابقة لم يكن «يكفي أن يعمل تقريباً كلّ بضع ثوانٍ»، بل كيف يُقلَّل اهتزاز الدورة و deadline miss.

في ذلك السياق يصير الموضوع:

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

لذا الأصفى فصل المشكلة من البداية هكذا:

  • معالجة دوريّة async يوميّة للتطبيق ← PeriodicTimer
  • callback على ThreadPool ← System.Threading.Timer
  • تحديث الواجهة ← DispatcherTimer
  • دقّة الدورة نفسها هي البطلة ← عالم المقالة السابقة

سؤال «نريد الدوران مضبوطاً قدر الإمكان كلّ 1ms. أيّ مؤقّت .NET أفضل» نصفه لم يعد اختيار مؤقّت، بل مشكلة طريقة الانتظار والتصميم.

فصل المشكلة إلى أربع من البدايةالمعالجة الدوريّة async اليوميّة للتطبيق PeriodicTimer، و callback على ThreadPool هو System.Threading.Timer، وتحديث الواجهة DispatcherTimer، وإن كانت دقّة الدورة البطلة فحديث جانب الزمن الناعم عن تصميم طريقة الانتظار.المرادasync: PeriodicTimercallback: Timerالواجهة: DispatcherTimerالدقّة: تصميم طريقة الانتظار

الشكل 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 بأنفسكم، لا يمكن التقاطها من الخارج.

وجهة استثناء lambda من نوع async عند تمريرها إلى callbackTimerCallback من نوع void لذا تصير lambda من نوع async تعادل async void، وتُعاد الاستثناءات إلى SynchronizationContext عند البدء، لكنّه غير موجود على ThreadPool فيصير استثناءً غير معالَج ويسقط العمليّة افتراضيّاً.غير موجود على ThreadPoolاستثناء داخل lambda من نوع asyncالخروج إلى الخارج بمعاملة تعادل async voidهل ثمّة SynchronizationContext؟يصير استثناءً غير معالَج على ThreadPoolيسقط العمليّة كلّها افتراضيّاًلا يُمنع إلا بـ try / catch خاصّ بكم

الشكل 15: استثناء lambda من نوع async ذات المظهر النظيف يسقط العمليّة دون موضع يلتقطه.

إن كان جسم المعالجة async، فالنظر أوّلاً في PeriodicTimer أسهل قراءة.

5.2. إدخال معالجة ثقيلة في Tick لـ DispatcherTimer

DispatcherTimer يلمس الواجهة كما هي، فيصير مغرياً كتابة أيّ شيء. لكنّ الموضع هو UI thread.

إدخال:

  • معالجة متزامنة طويلة
  • حساب CPU ثقيل
  • I/O حاجب
  • معالجة قد تُشغَّل مزدوجة تتضمّن await طويلاً

يصطدم وجهاً لوجه مع إدخال الواجهة ورسمها.

الإبقاء على محتوى Tick خفيفاً، وإبعاد العمل الثقيل إلى الخلفيّة، وإعادة النتائج اللازمة فقط إلى الواجهة، أستقرّ.

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)); // この間、誰も待っていない

// 1 回目の待機は、溜まっていた tick によって即完了する
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);

// 2 回目の待機は即完了しない(複数回ぶんの tick は残っていない)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);

قد تُطوى tick أثناء عدم الانتظار في مرّة واحدة، لذا يلزم تقرير التصميم:

  • التخطّي عند التأخّر
  • النظر إلى الأحدث فقط
  • معالجة كلّ المرّات حتماً
طيّ الـ tick أثناء عدم الانتظارإن حدث tick عدّة مرّات لـ PeriodicTimer أثناء عدم انتظار أحد تُطوى في مرّة واحدة، لذا يلزم تقرير التصميم: التخطّي عند التأخّر، أم النظر إلى الأحدث فقط، أم معالجة كلّ المرّات.tick عدّة مرّات أثناء عدم انتظار أحدتُطوى في مرّة واحدةكيف تُعامل التأخّر؟التخطّيالنظر إلى الأحدث فقطمعالجة كلّ المرّات

الشكل 16: الـ tick المتراكم يُطوى في مرّة واحدة. طريقة اللحاق ليست تلقائيّة بل تُقرَّر بالتصميم.

5.4. تأجيل الإيقاف وإدارة العمر

المؤقّت يقع فيه الحادث عند الإيقاف أكثر ممّا عند التشغيل.

ما يسهل إغفاله هذا النطاق.

  • صنع System.Threading.Timer كمتغيّر محلّيّ دون الاحتفاظ بالمرجع
  • إبهام ما حول Dispose() دون إيقاف System.Threading.Timer
  • عدم Stop() لـ DispatcherTimer وعدم نزع اشتراك Tick
  • بعد إغلاق الشاشة، يستمرّ المؤقّت في سحب عمر الكائن

خصوصاً DispatcherTimer قد يُبقي الكائن المربوط بالميثود حيّاً. إن ظهر شعور غريب «هذه Window كان يُفترض أن تُغلق لكنّها باقية»، يصير هذا موضع شكّ.

فخاخ الإيقاف وإدارة العمرTimer بلا مرجع محتفظ به و Dispose المبهم يبقيان طريقة التوقّف مبهمة، و DispatcherTimer بلا Stop ولا إلغاء اشتراك Tick يُبقي الكائن المربوط حيّاً فيظهر كـ Window كان يُفترض أن تُغلق لكنّها باقية.عدم الاحتفاظ بمرجع Timerطريقة التوقّف تبقى مبهمةإبهام ما حول Disposeلا Stop ولا إلغاء اشتراك Tickالإبقاء على المربوط حيّاًWindow كان يُفترض أن تُغلق باقية

الشكل 17: المؤقّت يقع فيه الحادث عند الإيقاف أكثر من التشغيل. اكتبوا ترتيب العمر من البداية.

6. قائمة فحص عند المراجعة

  • هل يمكن شرح هل ينبغي كتابة تلك المعالجة الدوريّة كتحديث واجهة / callback على ThreadPool / حلقة async
  • هل لم تُدفَع بعنف إلى مؤقّت من نوع callback رغم أنّ جسم المعالجة async
  • إن استُخدم System.Threading.Timer، هل يُحتمل تداخل الـ callback، أو هل ثمّة حراسة
  • هل لم تُدخل معالجة ثقيلة أو I/O حاجب أو معالجة متزامنة طويلة في Tick لـ DispatcherTimer
  • إن استُخدم PeriodicTimer، هل تقرّرت سياسة التأخّر
  • هل طريقة الإيقاف (Change / Dispose / Stop) وتدفّق إنهاء التطبيق واضحان
  • هل يُحتفظ بمرجع System.Threading.Timer كما ينبغي
  • هل ثمّة إلغاء اشتراك DispatcherTimer وترتيب عند إغلاق الشاشة
  • هل فُصلت المشكلة أوّلاً: «تنفيذ دوريّ للتطبيق» أم «دقّة الانتظار»

7. دليل اختيار تقريبيّ

معايير للعمل اليوميّ.

  • تحديث الإعدادات باستدعاء API كلّ 30 ثانية ← PeriodicTimer

  • إرسال heartbeat أو مقاييس خفيفة كلّ 5 ثوانٍ ← System.Threading.Timer

  • عرض ساعة أو تحديث حالة خفيف في WPF ← DispatcherTimer

  • لمس الواجهة مباشرةً عند كلّ Tick ← DispatcherTimer

  • جسم المعالجة الدوريّة مليء بـ await، وتريدون معاملة الإيقاف والاستثناءات طبيعيّاً ← PeriodicTimer

  • إدخال ركلة صغيرة بأسلوب callback بتكلفة منخفضة ← System.Threading.Timer

  • دقّة دورة بمستوى 1 إلى 5ms أو إدارة الاهتزاز هي الجسم ← قبل هذه الثلاثة، انظروا إلى طريقة الانتظار في المقالة السابقة

بجملة واحدة فجّة إلى حدّ بعيد:

  • PeriodicTimer مؤقّت من أجل async
  • System.Threading.Timer مؤقّت من أجل callback على ThreadPool
  • DispatcherTimer مؤقّت من أجل الواجهة

بهذه الذاكرة يصعب الانحراف كثيراً.

8. الخلاصة

ما يهمّ حقّاً في اختيار مؤقّت .NET ليس فرق الأسماء، بل هذه النقاط الثلاث.

  1. أين يعمل
  2. بأيّ تدفّق تريدون الكتابة
  3. كيف تعاملون التداخل والتأخّر

كاتّجاه يكفي القتال بهذا وحده.

  1. للعمل الدوريّ غير المتزامن PeriodicTimer
  2. لتشغيل callback خفيف على ThreadPool System.Threading.Timer
  3. لتحديث واجهة WPF DispatcherTimer
  4. إن كانت الدقّة البطلة، فانظروا إلى طريقة انتظار أخرى

المؤقّتات تختلط لأنّ الأسماء متشابهة. لكنّ الأدوار ليست متشابهة إلى ذلك الحدّ.

  • PeriodicTimer أداة لترتيب تدفّق async
  • System.Threading.Timer أداة لركل callback دوريّاً
  • DispatcherTimer أداة للتحديث الدوريّ على UI thread

تمييز هذه الثلاثة في التفكير وحده يهدّئ الشيفرة كثيراً.

خلاصة أدوار المؤقّتات الثلاثةPeriodicTimer أداة لترتيب تدفّق async، و System.Threading.Timer أداة لركل callback دوريّاً، و DispatcherTimer أداة للتحديث الدوريّ على UI thread.دور المؤقّتPeriodicTimer: asyncTimer: callbackDispatcherTimer: الواجهةللدقّة إلى تصميم الانتظار

الشكل 18: الأسماء متشابهة والأدوار ليست كذلك. الحفظ بهذا التصنيف الثلاثيّ يمنع الانحراف الكبير.

بالمقابل، إن اختلط هذا يحدث أمر مزعج شائع نسبيّاً:

  • ما يُفترض أن يكون async يصير شبيهاً بـ async void
  • لمس الواجهة مباشرةً والسقوط
  • تداخل الـ callback وتعكّر الحالة
  • اختلاط حديث دقّة الدورة أيضاً في كومة واحدة

انظروا أوّلاً من «أين تريدون التشغيل». بهذا وحده يصير اختيار المؤقّت أهدأ بكثير.

9. مراجع

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

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

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

ما الفرق بين 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؟
عند الرغبة في تحديث الواجهة دوريّاً في WPF، مثل عرض ساعة أو حالة خفيفة. قوّته أن Tick يُعالَج على Dispatcher في WPF (UI thread)، فيمكن لمس الواجهة كما هي داخل المعالج. غير أنّه يعمل على UI thread، فإدخال معالجة ثقيلة أو I/O حاجب في Tick يبطئ الإدخال والرسم أيضاً. كذلك لا يُضمن الإطلاق في اللحظة المحدّدة تماماً. الإبقاء على محتوى Tick خفيفاً، وإبعاد العمل الثقيل إلى الخلفيّة، والإعلان عن العمر بـ Stop() وإلغاء الاشتراك عند إغلاق الشاشة، يجعل الأمر مستقرّاً.

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

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

غو كومورا

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

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

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