عندما يتعلق الأمر بالتعامل مع العمليات غير المتزامنة في JavaScript، يظل الجدل بين Promises وasync/await محتدماً. لكن أي منهما يسبب Blocking حقيقي؟ وأيهما يؤدي إلى Memory Leak خفي؟ هذا المقال يكشف التفاصيل خلف الكواليس.
في أحد المشاريع الكبيرة الذي عملت عليه مع فريق في شركة ناشئة في دبي، كنا نتعامل مع واجهة برمجة تطبيقات خارجية تستغرق بين ٥٠٠ و١٢٠٠ مللي ثانية للاستجابة. استخدمنا Promises في البداية، لكن بعد شهرين بدأنا نلاحظ أن السيرفر يعلق بشكل عشوائي دون أي خطأ ظاهر في السجلات. المشكلة؟ لم نكن ندرك أن تداخل الـ then والـ catch في حلقات تكرارية كبيرة كان يولد مئات الـ Microtasks في الـ Event Loop، مما أدى إلى تأخير معالجة الأحداث الأخرى. هذا السيناريو ليس نادراً، بل هو فخ شائع يقع فيه حتى المطورون ذوو الخبرة عندما يتعاملون مع العمليات غير المتزامنة دون فهم عميق لكيفية عمل الـ Engine خلف الكواليس.
الـ Promises وasync/await ليسا مجرد بديلين لنفس الوظيفة، بل هما آليتان مختلفتان تماماً في كيفية إدارة الذاكرة والمعالج. الفرق الأساسي ليس في Syntax فقط، بل في كيفية تأثير كل منهما على أداء التطبيق واستهلاك الموارد. في هذا المقال، سنفكك كل منهما على مستوى الـ Call Stack والـ Heap، ونرى أيهما يسبب Blocking حقيقي وأيهما يؤدي إلى Memory Leak خفي، مع أمثلة واقعية من مشاريع حقيقية.
عندما نتحدث عن Promises، فإننا نتحدث عن كائنات تمثل نتيجة عملية غير متزامنة قد تكتمل في المستقبل. لكن ما لا يدركه الكثيرون هو أن الـ Promise نفسها لا تقوم بأي عمل غير متزامن، بل هي مجرد غلاف حول القيمة التي ستعود لاحقاً. المشكلة الحقيقية تبدأ عندما نبدأ في ربط الـ then والـ catch معاً في سلاسل طويلة، خاصة داخل حلقات تكرارية أو عند التعامل مع تدفقات بيانات متتالية.
لنأخذ مثالاً واقعياً: تخيل أنك تقوم بجلب بيانات من ثلاثة مصادر مختلفة، كل مصدر يعتمد على نتيجة السابق. باستخدام Promises، قد تكتب الكود بهذه الطريقة:
// مثال على سلسلة Promises معقدة
function fetchUserData(userId) {
return fetch(`/api/users/${userId}`)
.then(resp> response.json())
.then(user => {
if (!user.companyId) throw new Error('User has no company');
return fetch(`/api/companies/${user.companyId}`);
})
.then(response => response.json())
.then(company => {
return fetch(`/api/orders?companyId=${company.id}`);
})
.then(response => response.json())
.catch(error => {
console.error('Error in chain:', error);
throw error; // إعادة رمي الخطأ للمستوى الأعلى
});
}
// استخدام داخل حلقة تكرارية - كارثة محتملة
async function processUsers(userIds) {
const results = [];
for (const id of userIds) {
results.push(await fetchUserData(id));
}
return results;
}هذا الكود يبدو نظيفاً، لكنه يخفي مشكلة كبيرة: كل استدعاء لـ fetchUserData يولد سلسلة جديدة من الـ Microtasks في الـ Event Loop. إذا كان لدينا ١٠٠٠ مستخدم، فسنحصل على ٣٠٠٠ مهمة صغيرة تنتظر المعالجة، وكل منها قد تأخذ وقتاً أطول من المتوقع بسبب الشبكة أو الـ I/O Bound. المشكلة الحقيقية هنا ليست في الـ Promises نفسها، بل في كيفية استخدامها دون فهم تأثيرها على الـ Event Loop.
الـ Promises أيضاً تعاني من مشكلة الـ Memory Leak الخفي. عندما تقوم بإنشاء سلسلة طويلة من الـ then، فإن كل مستوى في السلسلة يحتفظ بمرجع للـ Promise السابق، مما يمنع الـ Garbage Collector من تنظيف الذاكرة حتى تكتمل السلسلة بأكملها. في التطبيقات الكبيرة التي تعمل لفترات طويلة، مثل السيرفرات، يمكن أن يؤدي هذا إلى زيادة تدريجية في استهلاك الذاكرة دون أي تسرب واضح في الكود.
async/await هو مجرد Syntactic Sugar فوق الـ Promises، لكنه يغير بشكل جذري كيفية تعامل المطور مع الكود غير المتزامن. الميزة الأساسية هنا هي أن الكود يبدو متزامناً، مما يجعله أسهل في القراءة والفهم. لكن هذه البساطة تخفي فخاً كبيراً: الكثير من المطورين يعتقدون أن async/await يجعل الكود غير المتزامن متزامناً بالفعل، وهذا خطأ فادح.
لنعد إلى نفس المثال السابق، لكن هذه المرة باستخدام async/await:
async function fetchUserData(userId) {
try {
const userResp await fetch(`/api/users/${userId}`);
const user = await userResponse.json();
if (!user.companyId) throw new Error('User has no company');
const companyResponse = await fetch(`/api/companies/${user.companyId}`);
const company = await companyResponse.json();
const ordersResponse = await fetch(`/api/orders?companyId=${company.id}`);
return await ordersResponse.json();
} catch (error) {
console.error('Error fetching data:', error);
throw error;
}
}
// استخدام داخل حلقة تكرارية - هل هو أفضل؟
async function processUsers(userIds) {
const results = [];
for (const id of userIds) {
results.push(await fetchUserData(id));
}
return results;
}هذا الكود يبدو أكثر نظافة وأقل تعقيداً، لكن هناك مشكلة كبيرة هنا: الحلقة التكرارية تنتظر كل عملية بشكل متسلسل، مما يعني أن الـ I/O Bound العمليات ستتم واحدة تلو الأخرى بدلاً من التوازي. في المثال السابق مع ١٠٠٠ مستخدم، قد يستغرق الأمر دقائق بدلاً من ثوانٍ إذا كانت العمليات تتم بشكل متوازي. هذا هو الفخ الحقيقي لـ async/await: الوهم بأن الكود يعمل بشكل متوازي بينما هو في الحقيقة متسلسل تماماً.
لكن هذا لا يعني أن async/await سيء، بل العكس تماماً. الميزة الحقيقية لـ async/await تظهر عندما نستخدمه مع التوازي الحقيقي، مثل Promise.all أو Promise.allSettled. على سبيل المثال:
async function processUsers(userIds) {
const promises = userIds.map(id => fetchUserData(id));
return await Promise.all(promises);
}هذا الكود يعمل بشكل متوازي حقيقي، حيث يتم إطلاق جميع الطلبات في نفس الوقت، مما يقلل الوقت الإجمالي بشكل كبير. الفرق هنا هو أن المطور يفهم أن async/await ليس سحراً، بل أداة يجب استخدامها بعناية مع فهم عميق لكيفية عمل العمليات غير المتزامنة.
لفهم أي من الأسلوبين يسبب Blocking حقيقي، يجب أن نفهم كيف يعمل الـ Event Loop في JavaScript. الـ Event Loop هو المسؤول عن معالجة المهام في الـ Call Stack، وإدارة الـ Microtasks و الـ Macrotasks. عندما نقول أن الكود يسبب Blocking، فإننا نعني أنه يمنع الـ Event Loop من معالجة المهام الأخرى لفترة طويلة.
في حالة الـ Promises، كل استدعاء لـ then يولد مهمة جديدة في قائمة الـ Microtasks. إذا كان لدينا سلسلة طويلة من الـ then، فإن الـ Event Loop سيقوم بمعالجة جميع الـ Microtasks قبل العودة إلى الـ Macrotasks الأخرى، مثل معالجة الأحداث أو الـ Timers. هذا يمكن أن يؤدي إلى تأخير في استجابة التطبيق، خاصة إذا كانت الـ Microtasks تأخذ وقتاً طويلاً.
أما في حالة async/await، فإن الـ Engine يعامل كل await كنقطة توقف، حيث يتم تعليق تنفيذ الدالة حتى تكتمل العملية غير المتزامنة. إذا كان لدينا عدة await داخل حلقة تكرارية، فإن الـ Event Loop سيعلق معالجة المهام الأخرى حتى تكتمل جميع العمليات المتسلسلة. هذا هو السبب في أن الكود الذي يستخدم async/await بشكل متسلسل يمكن أن يكون أبطأ بكثير من الكود الذي يستخدم Promises مع التوازي الحقيقي.
في أحد المشاريع التي عملت عليها، كان لدينا سيرفر Node.js يتعامل مع طلبات من واجهة مستخدم في الوقت الحقيقي. استخدمنا async/await في البداية، لكن لاحظنا أن السيرفر يبدأ في التأخر عندما يزيد عدد الطلبات عن ٥٠٠ طلب في الثانية. بعد تحليل الأداء، اكتشفنا أن المشكلة كانت في استخدام await داخل حلقة تكرارية لمعالجة قائمة من المهام. كل await كان يسبب توقف الـ Event Loop لمدة تتراوح بين ٥٠ و١٠٠ مللي ثانية، مما أدى إلى تراكم المهام في قائمة الانتظار.
الحل كان بسيطاً: استبدلنا الحلقة التكرارية بـ Promise.all، مما سمح بتنفيذ جميع المهام بشكل متوازي. النتيجة؟ انخفض وقت الاستجابة من ٥٠٠ مللي ثانية إلى أقل من ١٠٠ مللي ثانية، وزاد عدد الطلبات التي يمكن للسيرفر التعامل معها إلى أكثر من ٢٠٠٠ طلب في الثانية. هذا يوضح أن المشكلة ليست في async/await نفسه، بل في كيفية استخدامه دون فهم تأثيره على الـ Event Loop.
الـ Memory Leak هو كابوس كل مطور، خاصة في التطبيقات التي تعمل لفترات طويلة مثل السيرفرات أو التطبيقات التي تعمل في الخلفية. في الكود غير المتزامن، يمكن أن يحدث الـ Memory Leak بطرق غير متوقعة، خاصة عندما لا نفهم كيف يحتفظ الـ Engine بالمراجع للبيانات غير المستخدمة.
في حالة الـ Promises، المشكلة تحدث عندما نقوم بإنشاء سلاسل طويلة من الـ then دون أن ننهي السلسلة بشكل صحيح. على سبيل المثال، إذا كان لدينا سلسلة من الـ then تنتهي بـ catch، ولكننا لا نتعامل مع الخطأ بشكل صحيح، فقد يبقى الـ Promise معلقاً دون أن يتم تنظيفه بواسطة الـ Garbage Collector. هذا يمكن أن يؤدي إلى تراكم الـ Promises في الذاكرة، مما يزيد من استهلاك الموارد تدريجياً.
أما في حالة async/await، فإن المشكلة تحدث عندما نحتفظ بمراجع للبيانات داخل الدوال غير المتزامنة دون أن ندرك ذلك. على سبيل المثال، إذا كان لدينا دالة async تقوم بتحميل بيانات كبيرة وتخزينها في متغير خارجي، فقد يبقى هذا المتغير في الذاكرة حتى بعد انتهاء الدالة. هذا يمكن أن يؤدي إلى زيادة تدريجية في استهلاك الذاكرة، خاصة إذا كانت الدالة تستدعى بشكل متكرر.
في النهاية، لا يوجد جواب واحد صحيح للسؤال: أيهما أفضل، Promises أم async/await؟ الإجابة تعتمد على السياق والاحتياجات الخاصة بالمشروع. إذا كنت تعمل على كود يتطلب توازي حقيقي وتحكم دقيق في تدفق البيانات، فقد تكون الـ Promises هي الخيار الأفضل. أما إذا كنت تريد كوداً سهل القراءة والفهم، خاصة في المشاريع الصغيرة أو المتوسطة، فإن async/await هو الخيار الأمثل.
لكن الحقيقة هي أن معظم المشاريع الحديثة تستخدم مزيجاً من الاثنين. على سبيل المثال، يمكنك استخدام async/await لكتابة الكود الرئيسي، مع استخدام الـ Promises لتحقيق التوازي الحقيقي في الأجزاء التي تتطلب أداء عالي. المفتاح هنا هو فهم كيفية عمل كل منهما خلف الكواليس، وكيف يؤثر على أداء التطبيق واستهلاك الموارد.
في تجربتي الشخصية، أجد أن async/await يجعل الكود أكثر قابلية للصيانة ويقلل من الأخطاء الناتجة عن التداخل في سلاسل الـ then. لكن يجب أن نكون حذرين عند استخدامه داخل الحلقات التكرارية، حيث يمكن أن يؤدي إلى Blocking غير متوقع. أما الـ Promises، فهي لا تزال ضرورية في المواقف التي تتطلب تحكم دقيق في تدفق البيانات، مثل التعامل مع الـ Streams أو الـ Event Emitters.
إذا كنت تريد كتابة كود غير متزامن فعال، فاستخدم async/await لكتابة الكود الرئيسي، ولكن تذكر دائماً أن تضع await فقط أمام العمليات التي تعتمد على بعضها البعض. بالنسبة للعمليات المستقلة، استخدم Promise.all أو Promise.allSettled لتحقيق التوازي الحقيقي. هذا النهج يجمع بين سهولة القراءة وأداء عالي، ويجنبك المشاكل الشائعة مثل الـ Blocking والـ Memory Leak.