لماذا يجب أن يفضّل كود Windows انتظار الأحداث على الـ polling بـ timer

· · Windows Development, Synchronization, Events, Timers, Architecture

في المقال السابق حول العمل العمليّ مع soft real-time على Windows، كتبت عن تجنّب الـ loops التي تعتمد على Sleep كما لو كان ضماناً للتوقيت.
هذه المرّة أريد تضييق ذلك إلى قاعدة تصميم محدّدة:

لماذا يجب على كود Windows عادةً أن يفضّل انتظار الأحداث على الـ polling القصير بـ timer.

على Windows، التصاميم التي تستمرّ في فعل أشياء مثل Sleep(1) أو الانتظار بـ timeout قصير لـ «التحقّق مرّة أخرى قريباً» تُدفع من قِبَل أمرين:

  • granularity ساعة النظام
  • تأخير الـ scheduler بعد انتهاء الـ timeout بالفعل

على كثير من الأنظمة الاعتياديّة، يعني ذلك أنّ الانتظار المؤقّت القصير أكثر خشونةً بكثير ممّا يبدو لأوّل وهلة.
إن كان ما تريد انتظاره فعلاً ليس «مرور الوقت» بل «وصول العمل» أو «اكتمال الـ I/O» أو «إصدار إشارة طلب الإيقاف» أو «حدوث تغيير في الحالة»، فإنّ الـ polling الدوريّ عادةً ما يكون الشكل الخاطئ.

النموذج الأنظف هو:

  • الجانب الذي يحدث فيه الحدث يُصدر signal
  • الجانب المنتظر ينتظر على كائن شبيه بالحدث

ذلك عادةً ما يكون أفضل للـ latency واستهلاك الـ CPU واستهلاك الطاقة.

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

  • إن كنت تنتظر وصول العمل أو اكتمال I/O، فضّل انتظاراً مدفوعاً بالأحداث على الـ polling بـ timer.
  • الانتظار المؤقّت في Windows يتأثّر بـ granularity ساعة النظام.
  • Sleep(1) لا يعني «استيقظ بدقّة بعد 1 ms».
  • حتى بعد انتهاء الـ timeout، يصبح الـ thread فقط ready؛ التنفيذ الفوريّ غير مضمون.
  • يعني ذلك أنّ التصاميم التي تنتظر فعلاً حدثاً لكنّها تستمرّ في التحقّق بـ timers عادةً ما تكون سيّئة لكلّ من الـ latency والطاقة.
  • من الأفضل الاحتفاظ بالـ timers للحالات التي يكون فيها الوقت نفسه هو الشرط الحقيقيّ.

من الناحية العمليّة:

  • «أرسل metrics كلّ 5 ثوانٍ»
    -> عمل timer
  • «ابدأ المعالجة عند وصول عنصر إلى الـ queue»
    -> event / semaphore / condition variable / WaitOnAddress
  • «استمرّ عند اكتمال الـ I/O»
    -> عمل completion أو event
  • «توقّف عندما يُطلب التوقّف»
    -> stop event أو cancellation

الخطوة الرئيسيّة هي الفصل بين:

  • انتظار الوقت
  • انتظار حدث

2. ما المشكلة الحقيقيّة

2.1 الانتظار المؤقّت مقيّد بـ granularity ساعة النظام

دقّة timeout الخاصّة بدوال wait في Windows تعتمد على system clock resolution.
Sleep من النوع نفسه من القصّة: تمرير قيمة بالميلّي ثانية لا يعني أنّ نظام التشغيل سيحترم تلك المدّة بالضبط كنقطة استيقاظ دقيقة.

النتيجة العمليّة بسيطة:

طلب 1 ms لا يضمن الاستيقاظ في 1 ms.

2.2 حتى عند انتهاء الـ timeout، التنفيذ ليس فوريّاً

هناك مصدر ثانٍ للتأخير بعد الـ timeout نفسه.

بمجرّد انتهاء انتظار مؤقّت، يصبح الـ thread عموماً ready، لكنّ ذلك ليس مماثلاً لـ:

«الـ CPU لك الآن.»

threads أخرى قابلة للتشغيل، والـ priorities، وحالات الطاقة، وتنازع الـ locks، ونشاط DPC / ISR، وضغط الـ scheduling العامّ يمكن أن تؤخّر التنفيذ الفعليّ.

لذا فإنّ الانتظارات القصيرة بـ timer معرّضة لطبقتين من عدم اليقين:

  1. الـ timeout نفسه يُقرّب أو يتأخّر بسبب granularity الـ timer
  2. بعد ذلك، الـ scheduler لا يزال يقرّر متى يعمل الـ thread فعليّاً

2.3 Sleep(1) لا يعني loop بـ 1 ms

هذا يهمّ كثيراً لأنّ كوداً مثل هذا غالباً ما يبدو أكثر دقّة ممّا هو عليه:

while (!g_stop)
{
    Step();
    Sleep(1);
}

ذلك الـ loop يتضمّن في الواقع:

  • الوقت الذي يستغرقه Step()
  • سلوك الانتظار غير الدقيق لـ Sleep(1)
  • تأخير الـ scheduler بعد الاستيقاظ

لذا فإنّ فترة الـ loop تنحرف فوراً.
من الأفضل قراءة Sleep(1) على أنّه «توقّف لفترة قصيرة على الأقلّ» بدلاً من «شغّل كلّ 1 ms».

تنطبق الحقيقة العمليّة نفسها على أشكال .NET مثل Thread.Sleep(1).

3. لماذا انتظار الأحداث أفضل للعمل المدفوع بالأحداث

3.1 شرط الاكتمال يصبح «signal» وليس «انتهاء الوقت»

هذا هو جوهر تحسين التصميم.

مع الـ polling بـ timer:

  • قد لا يكون شيء قد حدث
  • لا يزال الـ timeout يوقظ الـ thread
  • يستيقظ الكود فقط ليسأل، «هل حدث شيء بعد؟»

مع انتظار الأحداث:

  • الـ producer أو جانب الإكمال يُصدر signal
  • يكتمل الانتظار لأنّ هناك سبباً
  • عندما يستيقظ الـ thread، يكون الشرط الذي يهتمّ به موجوداً بالفعل
flowchart LR
    start["Waiting thread"] --> q{"What are we really waiting for?"}
    q -- "Time passing" --> timer["timer / waitable timer"]
    q -- "Work arrival" --> event["event / semaphore / condition variable"]
    q -- "Value change" --> addr["WaitOnAddress"]
    q -- "I/O completion" --> io["completion / event"]
    q -- "Stop request" --> stop["stop event / cancellation"]

ذلك الفرق هائل في الممارسة.

الـ polling يعني:

الاستيقاظ بدون سبب حقيقيّ، ثمّ التحقّق.

الانتظار المدفوع بالأحداث يعني:

الاستيقاظ لأنّ السبب موجود الآن.

3.2 اختر الـ primitive بناءً على ما تنتظره فعلاً

هذا الجدول البسيط عادةً ما يكون كافياً:

ما تنتظره تصميم ضعيف الخيار الأوّل الأفضل
وصول عمل إلى queue Sleep(1) مع TryPop event أو semaphore
اكتمال I/O فحص الحالة المعتمد على timer completion أو event
طلب إيقاف polling لـ flag كلّ 100 ms stop event أو cancellation
تغيير قيمة مشتركة داخل العمليّة while (flag == 0) Sleep(1) WaitOnAddress
مرور وقت فعليّ إجبار كلّ شيء عبر الأحداث timer أو waitable timer

المفتاح هو تسمية سبب الانتظار بشكل صحيح قبل تسمية الـ API.

3.3 انتظار الأحداث ليس سحراً أيضاً

انتظار الأحداث أفضل لأنّه لا يحتاج إلى انتظار الـ timer tick التالي.
هو ليس سحراً لأنّه لا يزال يعتمد على:

  • scheduler latency
  • thread priority
  • CPU power state
  • تنازع الـ lock
  • page faults
  • نشاط DPC / ISR

لذا فإنّ event signal ليس «تنفيذاً بصفر latency».
لكنّه يُزيل التأخير الإضافيّ وغير الضروريّ لـ «النوم حتى التحقّق التالي للـ polling».

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

4.1 الـ polling لـ queue بـ Sleep(1)

هذا أحد أكثر الأمثلة شيوعاً:

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

لهذا ثلاث مشاكل عمليّة:

  1. لا يزال الـ thread يستيقظ حتى عندما يكون الـ queue فارغاً
  2. يتأثّر latency الاستجابة بـ granularity الـ timer
  3. يصبح سلوك الطاقة أسوأ لأنّ الـ CPU يُوقظ دوريّاً بدون سبب

إن زدت الـ sleep، يسوء الـ latency.
إن قصّرته، يسوء سلوك الـ CPU والطاقة.
ذلك إشارة إلى شكل التصميم الخاطئ.

4.2 الـ polling للحالة بـ Thread.Sleep(1) أو Task.Delay(1)

تظهر الرائحة نفسها في الكود الـ managed:

while (!stoppingToken.IsCancellationRequested)
{
    if (_queue.TryDequeue(out WorkItem? item))
    {
        await ProcessAsync(item, stoppingToken);
        continue;
    }

    await Task.Delay(1, stoppingToken);
}

على الرغم من أنّه يستخدم بنية async، فإنّ التصميم لا يزال polling.

5. ماذا تفعل بدلاً من ذلك

5.1 دع الـ producer يُصدر signal عند وصول العمل

لوصول الـ queue، الشكل الأنظف هو:

  • يضع الـ producer العمل في الـ queue
  • يُصدر الـ producer signal فوراً بعد الـ enqueue
  • ينتظر الـ consumer الـ signal
  • يُفرغ الـ consumer الـ queue عند استيقاظه

يعطيك ذلك:

  • لا استيقاظ دوريّ عندما لا يوجد شيء
  • استيقاظ مرتبط بوصول عمل حقيقيّ

5.2 انتظر العمل والإيقاف في الانتظار نفسه

للـ workers البسيطة، WaitForMultipleObjects غالباً ما يجعل التصميم واضحاً جدّاً:

HANDLE waits[2] = { _stopEvent, _workEvent };

for (;;)
{
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    if (rc == WAIT_OBJECT_0)
    {
        return;
    }

    if (rc != WAIT_OBJECT_0 + 1)
    {
        throw std::runtime_error("WaitForMultipleObjects failed.");
    }

    DrainQueue();
}

النقاط المهمّة هي:

  • لا Sleep(1)
  • يُصدر الـ producer signal عند ظهور العمل
  • يمكن للـ worker أن ينتظر كلاًّ من stop و work في مكان واحد

5.3 WaitOnAddress خيار قويّ داخل عمليّة واحدة

إن كانت المشكلة ببساطة «الانتظار حتى تتغيّر قيمة مشتركة صغيرة»، يمكن أن يكون WaitOnAddress أنظف بكثير من loop الـ spin-plus-sleep.

التقسيم التقريبيّ هو:

  • عبر العمليّات أو كائنات قابلة للانتظار العامّة
    -> event / semaphore / waitables قياسيّة
  • تغييرات قيم صغيرة داخل العمليّة
    -> WaitOnAddress

6. الحالات التي لا تزال فيها الـ timers صحيحة

6.1 عندما يكون الوقت نفسه هو الشرط

لا يزال للـ timers دور حقيقيّ بكلّ تأكيد.

أمثلة:

  • إرسال metrics كلّ 5 ثوانٍ
  • إعادة المحاولة بعد 200 ms
  • تنظيف الـ cache مرّة في الدقيقة
  • الانتظار حتى موعد timeout

في تلك الحالات، الشيء الذي تنتظره فعلاً هو الوقت.

6.2 فضّل waitable timer على تكديس Sleep

إن كنت تحتاج فعلاً إلى انتظار الوقت على Windows، فإنّ waitable timer عادةً ما يكون تجريداً أوضح من ربط sleeps مخصّصة معاً.

6.3 لا تلجأ إلى timeBeginPeriod كأوّل إجابة

عندما تصبح دقّة الـ timer مزعجة، يكون الإغراء هو اللجوء إلى timeBeginPeriod(1).

عادةً يجب ألّا يكون ذلك أوّل إجابة.

لماذا:

  1. له تكلفة حقيقيّة في الطاقة والكفاءة
  2. سلوك Windows الحديث حول دقّة الـ timer أكثر دقّةً ممّا كان عليه
  3. غالباً ما يخفي تصميماً كان يجب أن يكون مدفوعاً بالأحداث في المقام الأوّل

إن كان ما تحتاج إليه فعلاً هو استيقاظ مدفوع بالأحداث، فإنّ زيادة دقّة الـ timer غالباً ما تحلّ المشكلة الخاطئة.

7. checklist عمليّة للمراجعة

عند مراجعة الكود، الأسئلة الأولى التي أطرحها هي:

  • هل هناك loop يعتمد على Sleep(1) أو Thread.Sleep(1) أو Task.Delay(1)؟
  • هل ينتظر الكود فعلاً وصول العمل أو اكتمال I/O أو طلب إيقاف بينما يتظاهر بأنّه ينتظر الوقت؟
  • هل يمكن للـ producer أو جانب الإكمال إصدار signal مباشرةً؟
  • هل يمكن انتظار الإيقاف والعمل معاً؟
  • إن كان الانتظار داخل عمليّة واحدة، هل سيكون WaitOnAddress ملاءمةً أفضل؟
  • حيث تُستخدم الـ timers، هل الوقت هو الشرط فعلاً؟

حتى تلك الـ checklist الصغيرة تلتقط كمّاً مفاجئاً من الـ polling العَرَضيّ.

8. الختام

على Windows، الانتظارات المؤقّتة القصيرة تُدفع من قِبَل granularity الـ timer وتأخير الـ scheduling.
يجعل ذلك التصاميم القائمة على «الاستيقاظ قريباً والتحقّق مرّة أخرى» أكثر خشونةً بكثير ممّا تبدو عليه لأوّل وهلة.

إن كان ما تنتظره فعلاً هو:

  • وصول عمل
  • اكتمال I/O
  • طلب إيقاف
  • تغيير حالة

فإنّ الانتظار المدفوع بالأحداث عادةً ما يكون النموذج الأفضل.

الملخّص النظيف هو:

انتظر الوقت بـ timers؛ انتظر الأحداث بأحداث.

ذلك الفصل الواحد يحسّن:

  • وضوح الـ latency
  • كفاءة الـ CPU
  • سلوك الطاقة
  • قابليّة قراءة النيّة

على Windows، عادةً ما يكون ذلك الإعداد الافتراضيّ الصحيح.

9. مراجع

مقالات ذات صلة

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

أين يتصل هذا الموضوع

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

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