مستوى المقال: متوسط. بيفترض إنك بتكتب دوال جافاسكريبت وبتستخدم setTimeout وPromise، بس محدش شرحلك ليه ترتيب تنفيذهم بيطلع عكس اللي كتبته.
هتخرج من هنا عارف بالظبط ليه Promise.then بيشتغل قبل setTimeout(fn, 0)، وليه سطر واحد ثقيل بيقدر يجمّد سيرفر Node بالكامل، وإزاي تتجنب ده. الشرح كله مبني على فرضية إنك بتشتغل على Node.js أو متصفح حديث بمحرك V8.
الـ Event Loop في جافاسكريبت: القلب اللي بيدير كل شيء غير متزامن
جافاسكريبت لغة بخيط واحد. يعني بتنفّذ أمر واحد في اللحظة الواحدة. ومع كده بتقدر تخدم آلاف الاتصالات في نفس الوقت من غير ما تتجمّد. الحاجة اللي بتخلّي ده ممكن اسمها الـ Event Loop.
مثال قبل التشريح: الطباخ الواحد في المطبخ
تخيّل مطعم فيه طباخ واحد بس. الطلبات بتيجي على ورقة. الطباخ بيمسك طلب، يشتغل فيه لحد ما يخلص، وبعدين يمسك اللي بعده. ده الخيط الواحد.
دلوقتي طلب فيه طبق محتاج يقعد 10 دقايق في الفرن. هل الطباخ هيقف قدام الفرن 10 دقايق يتفرّج؟ طبعًا لأ. هيحط الطبق في الفرن، يظبّت مؤقّت، ويرجع يمسك الطلب اللي بعده. أول ما المؤقّت يرنّ، بيحط الورقة دي في طابور "جاهز للاستكمال". الطباخ بيخلّص اللي في إيده الأول، وبعدين بيبص للطابور.
في المطبخ ده فيه نوعين ملاحظات. ملاحظة عادية بتتحط في آخر الطابور الكبير (زي رنّة الفرن). وملاحظة عاجلة من نفس الزبون اللي بيكلّمه دلوقتي، دي لازم تتعمل قبل ما يمسك أي طاولة جديدة. النوع العاجل ده هو الـ microtask. والنوع العادي هو الـ macrotask.
التشريح العلمي: مكدّس نداءات وطابورين وحلقة بتفحص باستمرار
خلّينا نحطّ الأسماء الحقيقية مكان المثال:
- Call Stack (مكدّس النداءات): مكان تنفيذ الكود المتزامن. أي دالة بتشتغل بتتحط فوقه، وبتتشال أول ما ترجع.
- Web APIs / libuv: الجهة اللي بتتولى المؤقّتات و I/O والشبكة بره الخيط الرئيسي. دي الفرن في المثال.
- Macrotask Queue (طابور المهام الكبيرة): بيستقبل ردود
setTimeoutوsetIntervalو I/O callbacks. - Microtask Queue (طابور المهام الدقيقة): بيستقبل ردود
Promise.thenوqueueMicrotaskوasync/await.
الـ Event Loop نفسه قاعدة بسيطة بتتكرّر: نفّذ كل الكود المتزامن لحد ما الـ Call Stack يفضى، بعدها فرّغ طابور المهام الدقيقة بالكامل، وبعد كده خُد مهمة واحدة بس من طابور المهام الكبيرة، وكرّر.
القاعدة اللي بتفسّر كل حاجة: بعد كل مهمة كبيرة، الـ Event Loop بيفرّغ كل المهام الدقيقة الموجودة قبل ما يلمس المهمة الكبيرة اللي بعدها.
ليه Promise بيسبق setTimeout(0)؟
جرّب الكود ده بنفسك في المتصفح أو Node:
console.log("1 sync");
setTimeout(() => console.log("2 timeout"), 0);
Promise.resolve().then(() => console.log("3 promise"));
console.log("4 sync");المخرج مش بترتيب السطور، بيطلع كده:
1 sync
4 sync
3 promise
2 timeoutالسبب بالتفاصيل: أول console.log والأخير متزامنين، فبيتنفّذوا فورًا على الـ Call Stack. الـ setTimeout بيروح لطابور المهام الكبيرة حتى لو التأخير صفر. الـ Promise.then بيروح لطابور المهام الدقيقة. لما الكود المتزامن يخلص، الـ Event Loop بيفرّغ المهام الدقيقة الأول، فيطلع "3 promise". بعدها بس بياخد مهمة كبيرة، فيطلع "2 timeout".
يعني setTimeout(fn, 0) مش معناه "نفّذ حالًا". معناه "نفّذ في أقرب دورة مهمة كبيرة، بعد كل المهام الدقيقة". دي نقطة بتوقّع ناس كتير في bugs بترتيب التحديثات.
الخطر الحقيقي: سطر واحد بيجمّد السيرفر كله
بما إن كل ده بيحصل على خيط واحد، أي كود متزامن ثقيل بيوقف الـ Event Loop نفسه. ووقتها مفيش طلب تاني بيتخدم.
سيناريو واقعي: عندك سيرفر Node بيخدم 4800 طلب/ثانية، متوسط زمن الاستجابة (p95) حوالي 12 مللي ثانية. أضفت مسار واحد بيعمل عملية متزامنة ثقيلة (تحليل JSON ضخم أو حلقة حسابية) بتاخد 300 مللي ثانية CPU:
app.get("/report", (req, res) => {
// عملية متزامنة ثقيلة تحبس الخيط 300ms
const rows = buildHugeReportSync(); // CPU-bound
res.json(rows);
});النتيجة اللي بتحصل فعلًا: طول الـ 300 مللي ثانية دي، الـ Event Loop واقف. كل الطلبات التانية — حتى المسارات الخفيفة — بتستنى في الطابور. الـ p95 قفز من 12 مللي ثانية لأكتر من 310 مللي ثانية، والـ throughput الكلي وقع لأن كل طلب تقيل بيسدّ الطريق قدّام العشرات.
الحل مش تعقيد: نقّل الشغل التقيل بره الخيط الرئيسي. استخدم worker_threads للحسابات، أو قسّم الشغل على دفعات وارجّع التحكم للـ Event Loop بينها:
const { Worker } = require("node:worker_threads");
app.get("/report", (req, res) => {
const worker = new Worker("./report-worker.js");
worker.once("message", (rows) => res.json(rows));
});بعد النقل للـ worker، الخيط الرئيسي فضل يخدم الطلبات الخفيفة على 12 مللي ثانية زي ما هو، والتقرير التقيل اتحسب في خيط تاني من غير ما يجمّد حد.
المقايضات (trade-offs)
- worker_threads: بتكسب إنك محبستش الـ Event Loop. بتخسر إن كل worker بياخد ذاكرة إضافية (عادةً 5–10MB لكل واحد) وفيه تكلفة نقل بيانات بينه وبين الخيط الرئيسي. مناسب للشغل الـ CPU-bound، مش لكل طلب.
- تقسيم الشغل على دفعات: أبسط من الـ workers ومفيهوش تكلفة ذاكرة، لكن بيطوّل الزمن الكلي للعملية شوية لأنك بتوقفها وترجعها.
- الاعتماد على ترتيب المهام الدقيقة: بيديك تحكم دقيق، لكن لو حطّيت microtasks بتولّد microtasks في حلقة، ممكن تجوّع طابور المهام الكبيرة وتمنع الـ I/O من الاشتغال أصلًا.
متى لا تشغل بالك بده
لو التطبيق بتاعك سكربت صغير بيتنفّذ ويخلص، أو موقع فيه تفاعلات خفيفة، الموضوع ده مش أولوية. المشكلة بتظهر بس لما يكون عندك عمل متزامن تقيل جوّه بيئة بتخدم كذا طلب في نفس الوقت (سيرفر) أو واجهة لازم تفضل سلسة (60 إطار/ثانية). غير كده، اكتب كودك عادي ومتقلقش من ترتيب الـ microtasks.
الخطوة التالية
افتح أثقل مسار في سيرفرك، وحُط قبله وبعده const t = process.hrtime.bigint() واطبع الفرق. لو لقيت دالة متزامنة واحدة بتاخد أكتر من 50 مللي ثانية على الخيط الرئيسي، دي مرشّحة تتنقل لـ worker_threads. جرّب وقارن الـ p95 قبل وبعد.
المصادر
- MDN Web Docs — The event loop (شرح Call Stack والطوابير).
- Node.js Docs — The Node.js Event Loop, Timers, and process.nextTick() (مراحل libuv وترتيب التنفيذ).
- Jake Archibald — Tasks, microtasks, queues and schedules (الفرق بين المهام الدقيقة والكبيرة).
- WHATWG HTML Standard — Event loops (التعريف المعياري الرسمي).
- Node.js Docs — worker_threads (نقل الشغل التقيل بره الخيط الرئيسي).