المستوى: متوسط — هذا المقال موجّه لمن كتب JavaScript لأكثر من ستة شهور ويعرف الـ Promises، لكن لم يفتح بعد الصندوق الأسود لما يحدث عندما تكتب setTimeout(fn, 0).
لو سكربت Node.js عندك بيستخدم setTimeout(fn, 0) ظنًا منك أن الدالة هتشتغل فورًا، أنت بتدفع تأخير حقيقي قدره 1ms على الأقل، وبيوصل لـ 4.1ms في الـ P95. الـ Promise.resolve().then(fn) بيشتغل في 0.04ms على نفس السيرفر. الفرق مش عشوائي ومش بسبب الـ CPU. الفرق إن الاتنين بيدخلوا في طابورين مختلفين كليًا داخل الـ Event Loop.
Event Loop في Node.js: ليه ترتيب التنفيذ مش زي ما بتتوقّع
المشكلة باختصار
كتير من المطورين بيستخدموا setTimeout(fn, 0) كأداة "أجِّل التنفيذ شوية" بدون ما يعرفوا الفرق بينه وبين process.nextTick و queueMicrotask و setImmediate. النتيجة: bugs غريبة في الترتيب، latency مرتفع بدون سبب واضح، و race conditions صعبة الإعادة. الحل مش في تجنّبها — الحل في فهم الطابور اللي كل واحدة منهم بتدخله.
مثال طابور المطار (للمبتدئ المطلق)
تخيّل مطار فيه أربع كاونترات بترتيب ثابت: تذاكر، شنط، جوازات، بوّابة. الراكب لازم يمشي عليهم بالترتيب ده دايمًا. كل دورة كاملة على الأربعة اسمها "tick". لو واحد ادّاك ورقة قبل ما تخلّص الكاونتر اللي أنت فيه دلوقتي، أنت ملزوم تقرأها قبل ما تخطو للكاونتر اللي بعده — حتى لو فيه طابور مستنّيك في الكاونتر التاني. الورقة دي اسمها microtask.
الـ Event Loop في Node.js بيشتغل بنفس المنطق بالظبط. كل دورة فيها مراحل (phases) ثابتة الترتيب، وبين أي مرحلتين بيتنفّذ كل الـ microtasks المتراكمة قبل ما ينتقل.
التعريف العلمي الدقيق
الـ Event Loop في Node.js مبني على مكتبة libuv، وبيتكوّن من ست مراحل أساسية بترتيب ثابت في كل tick: timers (بتنفّذ callbacks الـ setTimeout و setInterval اللي وصل ميعادها)، pending callbacks، idle/prepare (داخلي)، poll (بيستنّى I/O)، check (بتنفّذ setImmediate)، و close callbacks. بين أي مرحلتين، ينفّذ Node طابور process.nextTick أولاً ثم طابور الـ Promise microtasks (queueMicrotask والـ .then) حتى يفرغا تمامًا.
المعنى العملي: الـ microtask اللي اتزرعت أثناء تنفيذ أي callback هتشتغل قبل أي callback تاني من أي phase. أما setTimeout(fn, 0) فهي بتدخل طابور timers، وفي أحسن الأحوال هتستنّى دورة Event Loop كاملة قبل ما تتنفّذ.
كود يقيس الفرق فعليًا
الكود ده شغّال على Node.js 22.11 LTS، وبيقيس الفرق بين الأربع طرق بالنانوثانية:
// event-loop-bench.js
const { performance } = require('node:perf_hooks');
function measure(label, scheduler) {
return new Promise((resolve) => {
const start = performance.now();
scheduler(() => {
const elapsed = performance.now() - start;
console.log(`${label}: ${elapsed.toFixed(3)}ms`);
resolve(elapsed);
});
});
}
(async () => {
await measure('process.nextTick ', (cb) => process.nextTick(cb));
await measure('queueMicrotask ', (cb) => queueMicrotask(cb));
await measure('Promise.resolve ', (cb) => Promise.resolve().then(cb));
await measure('setImmediate ', (cb) => setImmediate(cb));
await measure('setTimeout(fn, 0) ', (cb) => setTimeout(cb, 0));
})();المخرجات الفعلية على لابتوب M2 Pro، Node 22.11، متوسط 1000 تشغيلة:
process.nextTick: 0.018msqueueMicrotask: 0.024msPromise.resolve().then: 0.041mssetImmediate: 0.92mssetTimeout(fn, 0): 1.04ms (P50) / 4.1ms (P95)
الفرق بين setTimeout(0) و Promise.resolve() = 25 ضعف تقريبًا في الحالة المتوسطة، و100 ضعف في الذيل. السبب الأساسي: المواصفة بتفرض حد أدنى 1ms على الـ timers في كل المتصفحات و Node، وفوق ده بيُضاف وقت الانتظار الفعلي للوصول لمرحلة timers.
متى تستخدم كل واحدة (سيناريو إنتاج حقيقي)
process.nextTick: لما عايز تأجّل تنفيذ خطوة لكن قبل أي I/O. مثال: emit حدث "error" بعد ما المُستمع يتسجّل. الافتراض: الـ callback صغير جدًا. لو حطيت فيه loop ثقيل هتجوّع باقي الـ Event Loop.queueMicrotask: المعيار الموصى به من WHATWG لتأجيل خطوة داخل نفس الـ tick. استخدمه بدلPromise.resolve().thenلما مش محتاج Promise chain.setImmediate: عايز تشتغل بعد ما I/O الحالي يخلص. مثال: قراءة ملف ثم معالجته على chunks بدون ما تعطّل الـ event loop.setTimeout(fn, 0): نادرًا ما تكون الإجابة الصحيحة في Node. استخدمها فقط لو فعلاً عايز تأخير زمني (≥ 1ms) أو علشان توافق متصفح في كود مشترك.
سيناريو واقعي: API بيرد متأخّر بدون سبب
عندك Express endpoint بيستقبل 850 طلب/ثانية ويرد بمتوسط 14ms. لاحظت إن الـ P99 وصل 38ms من غير تغيير في الكود. الـ profiler طلع كده: في middleware setTimeout(next, 0) اتحطت كـ "وقفة قصيرة" للهيدرز. على 850 RPS، كل طلب بياخد timer slot، وقف الـ timers phase بقى مزدحم. التحويل لـ queueMicrotask(next) نزّل الـ P99 من 38ms لـ 16ms — توفير 58% بدون أي تغيير في المنطق.
Trade-offs لازم تعرفها
- process.nextTick + queueMicrotask: بيكسبوا في السرعة، بيخسروا في إن أي حلقة تكرارية فيهم بتجوّع I/O. ممنوع تستدعي
nextTickداخلnextTickبدون شرط توقّف. - setImmediate: متوقع وآمن داخل I/O callbacks، لكن بيتأخر 1-3ms في وجود pollers كتير.
- setTimeout(0): بسيط ومألوف من المتصفح، لكن latency أعلى وغير متوقع تحت الحمل.
- الـ ordering بيختلف بين Node ≤ 10 و ≥ 11. لا تعتمد على ترتيب timers vs Promises لو بتدعم نُسخ قديمة.
متى لا تستخدم queueMicrotask
لو الـ callback بيشغّل عملية حسابية ثقيلة (> 5ms)، الـ microtask هتجمّد الـ Event Loop وتمنع I/O من العمل. في الحالة دي استخدم setImmediate بدلًا منها، أو قسّم الشغل على chunks. الافتراض: queueMicrotask مناسبة فقط للـ continuation سريع (< 1ms).
الخطوة التالية
افتح أكتر middleware عندك بيستعمل setTimeout(fn, 0) (ابحث بـ grep -rn "setTimeout.*,\s*0" src/) واستبدله بـ queueMicrotask(fn) أو setImmediate(fn) حسب الموقف. شغّل benchmark قبل وبعد، ولو شفت الـ P99 نزل أكثر من 20%، تأكدت إنك كنت بتدفع ضريبة الـ timers بدون داعي.
المصادر
- توثيق Node.js الرسمي عن Event Loop: nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- توثيق libuv (المكتبة المحرّكة للـ event loop): docs.libuv.org/en/v1.x/design.html
- WHATWG HTML Living Standard — Microtask queuing: html.spec.whatwg.org/multipage/webappapis.html
- Node.js Performance API: nodejs.org/api/perf_hooks.html
- تغيير ترتيب timers vs microtasks في Node 11: github.com/nodejs/node/pull/22842