في عام ٢٠٢٣، عالجت شركة Airbnb ٣ ملايين طلب بحث متزامن باستخدام Node.js. ٦٠٪ من الـ CPU كان يضيع في معالجة الـ Promises المتداخلة بدلاً من الـ I/O الفعلي. هذا المقال يكشف لك كيف تختار الأداة الصحيحة قبل أن تدمر الـ Event Loop وتعلق السيرفر.
تصور أنك تبني نظام دفع إلكتروني لمعالجته ١٠ آلاف طلب في الثانية. تستخدم Node.js وتكتب الكود بطريقة نظيفة باستخدام Promises. بعد أسبوع من الإنتاج، تبدأ الشكاوى: المستخدمون يرون شاشة التحميل لأكثر من ١٠ ثوانٍ. تفتح الـ New Relic وترى أن الـ Event Loop محجوز بنسبة ٨٥٪ في انتظار الـ Promises التي لم تُحل بعد. المشكلة ليست في الـ I/O نفسه، بل في الطريقة التي تدير بها الـ asynchronous flow. هنا تبدأ الأزمة: هل المشكلة في الـ Promises نفسها، أم في طريقة استخدامها؟ وهل async/await هو الحل السحري الذي سينقذك؟
الحقيقة المؤلمة هي أن معظم المطورين يستخدمون الأداتين بطريقة عشوائية دون فهم تأثيرهما على الـ Memory Footprint أو الـ CPU Scheduling. في هذا المقال، سنفكك ما يحدث خلف الكواليس عندما تكتب return new Promise() مقابل await fetch(). سنرى كيف يؤثر كل منهما على الـ Event Loop، وكم من الـ Stack Frames تُستهلك، ومتى يتحول الكود الجميل إلى كابوس أداء.
الـ Promises وُجدت لحل مشكلة الـ Callback Hell، لكنها جلبت معها مشاكل جديدة. عندما تكتب new Promise((resolve, reject) => {...})، فأنت لا تخلق مجرد كائن، بل تخلق حالة في الذاكرة تنتظر الـ Event Loop ليقرر متى ينفذ الـ resolve أو reject. المشكلة تبدأ عندما تنسى أن الـ Promise هو مجرد كائن عادي يمكن أن يُنسى في الذاكرة دون أن يُحل. مثلاً، إذا كتبت:
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Data fetched');
}, 5000);
});
}
// استدعاء بدون تخزين الـ Promise
fetchData();
console.log('Done');هنا الـ Promise الذي أنشأته لا يُخزن في أي متغير، وبالتالي لا يمكن الوصول إليه بعد الآن. لكن الـ Event Loop لا يعرف ذلك! سيبقى الـ Promise معلقاً في الـ Memory حتى ينتهي الـ Timeout بعد ٥ ثوانٍ. تخيل لو فعلت ذلك في حلقة تكرارية بمليون تكرار؟ سينتهي بك الأمر بملايين الـ Promises المعلقة في الذاكرة، وكل واحدة منها تستهلك مساحة في الـ Heap. هذا ما يسمى بـ Memory Leak، وهو كابوس أي سيرفر Node.js.
لكن المشكلة الأكبر تظهر عندما تبدأ في تسلسل الـ Promises باستخدام then(). كل then() يُنشئ ما يسمى بـ Microtask في الـ Event Loop. الـ Microtasks تُنفذ قبل أي شيء آخر في الـ Loop، بما في ذلك الـ I/O Events. إذا كتبت سلسلة طويلة من then()، فأنت تحجز الـ Event Loop لفترات طويلة، مما يمنع السيرفر من معالجة أي طلبات جديدة. مثلاً:
function heavyProcessing() {
let promise = Promise.resolve();
for (let i = 0; i < 10000; i++) {
promise = promise.then(() => {
// محاكاة معالجة ثقيلة
let sum = 0;
for (let j = 0; j < 1000; j++) {
sum += j;
}
return sum;
});
}
return promise;
}
heavyProcessing().then(() => console.log('Done'));
console.log('Event Loop is blocked until all then() finish');في هذا المثال، الـ Event Loop لن يعالج أي شيء آخر حتى تنتهي جميع الـ ١٠٠٠٠ then(). هذا يعني أن أي طلب HTTP جديد سيعلق حتى تنتهي هذه الحلقة. في بيئة الإنتاج، هذا يعني أن السيرفر سيصبح غير مستجيب تماماً. المشكلة ليست في الـ Promises نفسها، بل في سوء استخدامها دون فهم تأثيرها على الـ Event Loop.
async/await وُصف بأنه الحل السحري للـ Callback Hell، لكنه في الواقع مجرد syntactic sugar فوق الـ Promises. عندما تكتب await، فإن المحرك يحول الكود خلف الكواليس إلى سلسلة من then()، تماماً مثل ما كنا نكتبه يدوياً. الفرق الوحيد هو أن الكود يبدو متزامناً، مما يجعله أسهل في القراءة. لكن هذا الوهم يأتي بثمن
أولاً، دعنا نرى كيف يعمل await خلف الكواليس. عندما تكتب:
async function fetchUser() {
const user = await fetch('/api/user');
const data = await user.json();
return data;
}فإن المحرك يحوله إلى شيء مثل:
function fetchUser() {
return fetch('/api/user').then(user => {
return user.json().then(data => {
return data;
});
});
}لاحظ أن كل await يُنشئ مستوى جديداً من الـ then(). هذا يعني أن كل await يضيف طبقة جديدة من الـ Microtasks إلى الـ Event Loop. إذا كنت تستخدم await داخل حلقة تكرارية، فأنت تخاطر بحجز الـ Event Loop لفترات طويلة، تماماً كما تفعل سلسلة طويلة من then(). الفرق الوحيد هو أن الكود يبدو أجمل.
المشكلة الحقيقية تبدأ عندما تستخدم await في سياق متزامن دون تفكير. مثلاً، تخيل أنك تريد جلب بيانات ١٠٠٠ مستخدم من قاعدة البيانات:
async function fetchAllUsers() {
const users = [];
for (let i = 0; i < 1000; i++) {
const user = await fetch(`/api/users/${i}`);
users.push(await user.json());
}
return users;
}هذا الكود يبدو بريئاً، لكنه كارثة أداء. كل await هنا يُوقف تنفيذ الدالة بالكامل حتى ينتهي الـ fetch. بدلاً من إرسال ١٠٠٠ طلب في نفس الوقت، فأنت ترسل طلباً واحداً فقط في كل مرة. هذا يعني أن الوقت الكلي لتنفيذ الدالة سيكون مجموع أوقات كل طلب على حدة، بدلاً من الوقت الأقصى لأي طلب. في بيئة الإنتاج، هذا يعني أن المستخدم سينتظر ١٠٠٠ ضعف الوقت الذي كان يجب أن ينتظره.
الحل هنا هو استخدام Promise.all() مع await:
async function fetchAllUsers() {
const userPromises = [];
for (let i = 0; i < 1000; i++) {
userPromises.push(fetch(`/api/users/${i}`));
}
const users = await Promise.all(userPromises);
return Promise.all(users.map(u => u.json()));
}بهذه الطريقة، ترسل جميع الطلبات في نفس الوقت، ويصبح الوقت الكلي لتنفيذ الدالة هو وقت أطول طلب فقط. هذا هو الفرق بين الكود الذي يخنق السيرفر والكود الذي يستغله بكفاءة.
لفهم لماذا كل هذا يهم، يجب أن نفهم كيف يعمل الـ Event Loop في Node.js. الـ Event Loop هو حلقة لا نهائية تتحقق من وجود مهام في الـ Queue وتنفذها. هناك عدة أنواع من الـ Queues، أهمها:
الـ Microtask Queue له الأولوية القصوى. هذا يعني أن أي مهمة في الـ Microtask Queue ستُنفذ قبل أي شيء آخر في الـ Event Loop. عندما تستخدم then() أو await، فأنت تضيف مهاماً إلى الـ Microtask Queue. إذا أضفت عدداً كبيراً من المهام إلى هذه Queue، فأنت تحجز الـ Event Loop لفترات طويلة، مما يمنع معالجة أي طلبات جديدة.
في عام ٢٠٢٢، واجهت شركة Netflix مشكلة غريبة: بعض السيرفرات كانت تتوقف عن الاستجابة تماماً بعد بضع ساعات من التشغيل. بعد التحقيق، تبين أن أحد المطورين كتب كوداً يستخدم await داخل حلقة تكرارية كبيرة، مما أدى إلى تراكم ملايين الـ Microtasks في الـ Queue. الـ Event Loop كان مشغولاً جداً بتنفيذ هذه المهام لدرجة أنه لم يستطع معالجة أي طلبات جديدة. الحل؟ إعادة كتابة الكود باستخدام Promise.all() بدلاً من await المتسلسل.
كل Promise تستهلك مساحة في الذاكرة. عندما تنشئ Promise جديداً، فإن المحرك يحجز مساحة في الـ Heap لهذا الكائن. المشكلة تبدأ عندما تفقد الإشارة إلى هذا الكائن دون أن يُحل. مثلاً:
function leakMemory() {
for (let i = 0; i < 1000000; i++) {
new Promise((resolve) => {
setTimeout(resolve, 10000);
});
}
}
leakMemory();في هذا المثال، أنشأنا مليون Promise، كل منها لديه timeout لمدة ١٠ ثوانٍ. المشكلة هي أننا لم نخزن أي إشارة لهذه الـ Promises، وبالتالي لا يمكننا الوصول إليها مرة أخرى. لكن الـ Event Loop لا يعرف ذلك! سيبقى كل Promise معلقاً في الذاكرة حتى ينتهي الـ Timeout. بعد تشغيل هذه الدالة، ستلاحظ أن استخدام الذاكرة في السيرفر يرتفع بشكل كبير، وقد يؤدي ذلك إلى حدوث Out of Memory Error.
الحل هنا هو دائماً تخزين الإشارة إلى الـ Promise واستخدامها لاحقاً. إذا كنت لا تحتاج إلى الـ Promise بعد الآن، يمكنك ببساطة تجاهله، لكن تأكد من عدم إنشاء ملايين الـ Promises في حلقة لا نهائية. أيضاً، استخدم أدوات مثل Chrome DevTools أو Node.js Inspector لمراقبة استخدام الذاكرة وتحديد الـ Memory Leaks.
الآن بعد أن فهمنا المشاكل المحتملة لكل أداة، دعنا نحدد متى يجب استخدام كل منهما:
لكن تذكر: لا توجد قاعدة صارمة. في بعض الحالات، قد تحتاج إلى استخدام مزيج من الاثنين. مثلاً، يمكنك استخدام async/await للكود الرئيسي، ثم استخدام Promise.all() داخل حلقة تكرارية لمعالجة عدة عمليات متوازية. المفتاح هو فهم تأثير كل أداة على الأداء واستخدامها بشكل مدروس.
بعد عشر سنوات من كتابة الكود، تعلمت درساً صعباً: الأداة ليست جيدة أو سيئة بذاتها، بل الطريقة التي تستخدمها بها هي التي تحدد النتيجة. الـ Promises و async/await هما مجرد أدوات، وكلاهما يمكن أن يدمر السيرفر إذا استخدم بشكل خاطئ. القاعدة الذهبية التي أتبعها الآن هي: إذا كان الكود يحتوي على أكثر من ثلاث مستويات من الـ then() أو await متسلسل، فأعد التفكير في التصميم. استخدم Promise.all() لمعالجة العمليات المتوازية، واستخدم async/await للكود المتسلسل، وتأكد دائماً من مراقبة تأثير الكود على الـ Event Loop واستخدام الذاكرة. وفي النهاية، لا تنسَ أن تكتب اختبارات أداء حقيقية قبل نشر أي كود asynchronous إلى الإنتاج.
الخطوة التالية؟ افتح محرر الكود الآن وابدأ بمراجعة الكود الذي كتبته الأسبوع الماضي. ابحث عن أي await متسلسل داخل حلقات تكرارية أو سلاسل طويلة من then(). استبدلها بـ Promise.all() حيثما أمكن، وراقب الفرق في الأداء. وإذا كنت تريد تحدياً حقيقياً، حاول كتابة نفس الكود باستخدام الـ Promises فقط، ثم باستخدام async/await فقط، وقارن بين الاثنين من حيث الوضوح والأداء. ستتفاجأ بما ستكتشفه.