هل تعتقد أن async/await حل سحري لكل مشاكل الـ asynchronous في JavaScript؟ الحقيقة أصعب مما تظن. سنفكك معاً كيف يعمل كل منهما في الذاكرة، متى يسببان Memory Leak، وكيف تتجنب الـ Blocking في السيرفر الحقيقي.
في أحد المشاريع الكبيرة لشركة سعودية، كان فريقنا يستخدم Promises بكثافة لمعالجة البيانات من ثلاثة مصادر مختلفة: قاعدة بيانات محلية، API خارجي، وWebSocket في الوقت الفعلي. كل شيء كان يبدو جميلاً على الورق، لكن عند الضغط على زر التنفيذ، كانت الصفحة تتجمد لثلاث ثوانٍ كاملة قبل أن تظهر النتائج. المشكلة؟ لم نكن ندرك أن الـ Event Loop كان يتعامل مع 12 Promise متداخلة في نفس الوقت، وكل واحدة منها كانت تنتظر الأخرى في سلسلة غير مرئية. هذا ليس خطأ في الـ Promises نفسها، بل في فهمنا الخاطئ لكيفية إدارة الـ Microtasks Queue خلف الكواليس.
الآن، تخيل أنك نقلت نفس الكود إلى async/await لأنك قرأت أنها "أسهل قراءة". النتيجة؟ نفس المشكلة بالضبط، لكن هذه المرة مع كود يبدو أنيقاً ومباشراً. الفرق الوحيد هو أنك لن ترى الـ .then() المتداخلة، لكن الـ Event Loop لا يزال يتعامل مع نفس الـ Microtasks Queue. هذا هو جوهر الموضوع: async/await ليست بديلاً سحرياً للـ Promises، بل مجرد طبقة نحوية فوقها. إذا لم تفهم كيف تعمل الـ Promises داخلياً، فستكرر نفس الأخطاء لكن بشكل أجمل.
عندما تنشئ Promise جديدة في JavaScript، فإنك في الواقع تنشئ كائناً في الذاكرة يحتوي على ثلاث حالات ممكنة: pending، fulfilled، أو rejected. لكن ما يحدث خلف الكواليس هو أكثر تعقيداً بكثير من مجرد تغيير الحالة. الـ JavaScript Engine (مثل V8 في Node.js أو المتصفح) يضيف مهمة جديدة إلى الـ Microtasks Queue عندما تنشئ Promise. هذه المهمة لا تُنفذ فوراً، بل تنتظر حتى ينتهي الـ Call Stack الحالي من جميع المهام المتزامنة.
المشكلة الحقيقية تبدأ عندما تبدأ في ربط الـ Promises ببعضها باستخدام .then() أو .catch(). كل مرة تضيف فيها .then()، فإنك في الواقع تنشئ Promise جديدة تضاف إلى الـ Microtasks Queue. هذا يعني أنك إذا كتبت سلسلة طويلة من الـ .then()، فأنت في الواقع تضيف عدة مهام إلى الـ Queue بدلاً من مهمة واحدة. في المثال الذي ذكرته سابقاً، كنا نستخدم 12 Promise متداخلة، مما يعني أن الـ Event Loop كان عليه معالجة 12 مهمة في الـ Microtasks Queue قبل أن يعود إلى الـ Call Stack الرئيسي. هذا هو السبب في تجمد الصفحة: الـ Event Loop كان مشغولاً بمعالجة الـ Microtasks بدلاً من معالجة الأحداث الأخرى مثل النقرات أو تحديثات واجهة المستخدم.
// مثال على سلسلة Promises تسبب ضغطاً على الـ Event Loop
function fetchData() {
return fetch('https://api.example.com/data')
.then(resp> response.json())
.then(data => {
// معالجة البيانات
return processData(data);
})
.then(processedData => {
// مزيد من المعالجة
return saveToDatabase(processedData);
})
.then(() => {
// وأخيراً...
return notifyUser();
})
.catch(error => {
console.error('Error:', error);
});
}
// كل .then() يضيف مهمة جديدة إلى الـ Microtasks Queue
// إذا كان لديك 1000 طلب، فسيكون لديك 1000 مهمة في Queueلفهم لماذا تسبب الـ Promises هذه المشاكل، يجب أن تفهم الفرق بين الـ Microtasks Queue والـ Macrotasks Queue. الـ Macrotasks Queue هي المكان الذي تُضاف إليه المهام الكبيرة مثل setTimeout أو أحداث DOM. أما الـ Microtasks Queue فهي مخصصة للمهام الصغيرة والسريعة مثل الـ Promises. المشكلة هي أن الـ Event Loop يعطي الأولوية للـ Microtasks Queue على الـ Macrotasks Queue. هذا يعني أنه إذا كان لديك الكثير من الـ Promises في الـ Microtasks Queue، فإن الـ Event Loop سيتجاهل الـ Macrotasks Queue تماماً حتى تنتهي جميع الـ Microtasks.
في أحد المشاريع مع شركة إماراتية، كنا نستخدم setTimeout لتأخير تنفيذ بعض الكود، لكننا لاحظنا أن الـ setTimeout لا يعمل أبداً إذا كان هناك الكثير من الـ Promises قيد المعالجة. السبب؟ الـ setTimeout يُضاف إلى الـ Macrotasks Queue، لكن الـ Event Loop كان مشغولاً بمعالجة الـ Microtasks Queue التي تحتوي على الـ Promises. هذا السلوك يمكن أن يسبب مشاكل كبيرة في التطبيقات التي تعتمد على الـ Timers أو الأحداث الخارجية، لأن الـ Event Loop ببساطة يتجاهلها حتى تنتهي جميع الـ Microtasks.
// مثال يوضح كيف تتجاهل الـ Event Loop الـ Macrotasks Queue
console.log('Start');
setTimeout(() => {
console.log('Timeout'); // هذا لن يُنفذ أبداً في هذه الحالة
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1');
// حلقة لا نهائية من الـ Microtasks
while (true) {
// هذا سيمنع الـ Event Loop من معالجة أي شيء آخر
}
});
console.log('End');
// الناتج: Start → End → Promise 1 (ثم التطبيق يتجمد)عندما ظهرت async/await في ES2017، ظن الكثيرون أنها الحل النهائي لمشاكل الـ Asynchronous في JavaScript. الفكرة تبدو جميلة: بدلاً من كتابة سلاسل طويلة من .then()، يمكنك كتابة الكود وكأنها متزامنة باستخدام await. لكن الحقيقة هي أن async/await ليست أكثر من مجرد طبقة نحوية فوق الـ Promises. كل ما تفعله هو تحويل الكود الذي يبدو متزامناً إلى سلسلة من الـ Promises خلف الكواليس. هذا يعني أنك إذا لم تفهم كيف تعمل الـ Promises، فستكرر نفس الأخطاء لكن بشكل مختلف.
المشكلة الأكبر مع async/await هي أنها تجعل المطورين ينسون أن الكود لا يزال غير متزامن. في أحد المشاريع مع شركة قطرية، كان الفريق يستخدم async/await لمعالجة طلبات متعددة من قاعدة البيانات. كتبوا الكود بطريقة تبدو متزامنة، لكنهم نسوا أن كل await توقف تنفيذ الدالة حتى تكتمل الـ Promise. النتيجة؟ بدلاً من معالجة الطلبات بشكل متوازٍ، كانوا يعالجونها واحدة تلو الأخرى، مما تسبب في بطء شديد في الأداء. هذا هو الفخ الأكبر مع async/await: إنها تجعل الكود يبدو متزامناً، لكن السلوك لا يزال غير متزامن، وهذا يمكن أن يسبب ارتباكاً كبيراً إذا لم تفهم كيف تعمل خلف الكواليس.
// مثال على استخدام async/await بشكل خاطئ
async function fetchAllData() {
const user = await fetchUser(); // توقف التنفيذ حتى تكتمل
const posts = await fetchPosts(user.id); // توقف مرة أخرى
const comments = await fetchComments(posts[0].id); // وتوقف مرة ثالثة
return { user, posts, comments };
}
// هذا الكود يعالج الطلبات بشكل تسلسلي، مما يسبب بطءاً شديداً
// الحل الصحيح هو استخدام Promise.all
async function fetchAllDataCorrectly() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
return { user, posts, comments };
}في رأيي الشخصي، يجب استخدام async/await في حالتين رئيسيتين: الأولى عندما تريد كتابة كود سهل القراءة والصيانة، خاصة إذا كان الكود يحتوي على منطق معقد أو متداخل. الثانية عندما تعمل مع مكتبات أو أطر عمل تعتمد على الـ Generators أو الـ Async Iterators، حيث تجعل async/await التعامل معها أسهل بكثير. لكن في الحالات التي تحتاج فيها إلى تحكم دقيق في تدفق الـ Promises، مثل معالجة الأخطاء بشكل متقدم أو التحكم في الـ Concurrency، فإن الـ Promises الخام تبقى الخيار الأفضل.
في أحد المشاريع مع شركة مصرية، كنا نعمل على نظام معالجة دفعات كبيرة من البيانات. استخدمنا async/await لكتابة الكود الرئيسي لأنه كان سهل القراءة والفهم، لكننا استخدمنا الـ Promises الخام للتحكم في عدد الطلبات المتزامنة باستخدام مكتبة مثل p-limit. هذا الجمع بين الأسلوبين أعطانا أفضل ما في العالمين: كود نظيف وسهل الصيانة مع تحكم دقيق في الأداء.
// مثال على الجمع بين async/await والـ Promises الخام
import pLimit from 'p-limit';
const limit = pLimit(5); // السماح بخمسة طلبات متزامنة فقط
async function processBatch(items) {
const promises = items.map(item =>
limit(() => processItem(item)) // استخدام p-limit للتحكم في الـ Concurrency
);
return await Promise.all(promises);
}
async function processItem(item) {
// استخدام async/await لكتابة منطق المعالجة
const data = await fetchData(item.id);
const result = await transformData(data);
return await saveResult(result);
}معظم المطورين يعتقدون أن معالجة الأخطاء في async/await أسهل من الـ Promises، وهذا صحيح إلى حد ما، لكن هناك فخاخ خفية يمكن أن تسبب مشاكل كبيرة. في الـ Promises، يمكنك استخدام .catch() في نهاية السلسلة لمعالجة أي خطأ يحدث في أي مكان في السلسلة. لكن في async/await، إذا نسيت استخدام try/catch حول await، فإن الخطأ سينتشر إلى الـ Call Stack وسيتسبب في إيقاف تنفيذ الدالة بالكامل. هذا السلوك يمكن أن يكون مفيداً في بعض الحالات، لكنه خطير جداً في حالات أخرى، خاصة إذا كنت تتوقع أن تستمر الدالة في التنفيذ حتى إذا فشل جزء منها.
في أحد المشاريع مع شركة كويتية، كنا نستخدم async/await لمعالجة طلبات متعددة من API خارجي. كتبنا الكود بدون try/catch لأننا اعتقدنا أن الـ API موثوق به. لكن عندما فشل أحد الطلبات بسبب مشكلة في الشبكة، توقف تنفيذ الدالة بالكامل ولم تُرسل أي من الطلبات الأخرى. المشكلة الأكبر هي أن الخطأ لم يظهر في الـ Console لأننا لم نستخدم .catch() أو try/catch. استغرقنا ساعات لفهم لماذا لم تُرسل البيانات، وكل ما كان علينا فعله هو إضافة try/catch بسيط حول كل await.
// مثال على معالجة الأخطاء في async/await
async function fetchDataSafely() {
try {
const data = await fetch('https://api.example.com/data');
return await data.json();
} catch (error) {
console.error('Failed to fetch data:', error);
return null; // أو إعادة رمي الخطأ إذا كان يجب التعامل معه في مكان آخر
}
}
// مقارنة مع الـ Promises
function fetchDataWithPromises() {
return fetch('https://api.example.com/data')
.then(resp> response.json())
.catch(error => {
console.error('Failed to fetch data:', error);
return null;
});
}أحد أسوأ الكوابيس في التعامل مع الـ Promises وasync/await هو الـ Unhandled Rejection. يحدث هذا عندما تفشل Promise ولا توجد أي آلية لمعالجة الخطأ، سواء باستخدام .catch() أو try/catch. في المتصفحات، هذا عادةً ما يظهر رسالة خطأ في الـ Console، لكن في Node.js، يمكن أن يتسبب في إيقاف التطبيق بالكامل إذا لم تكن قد أعددت معالجاً عاماً للأخطاء باستخدام process.on('unhandledRejection').
في أحد المشاريع الكبيرة لشركة تركية، كان التطبيق يتوقف بشكل عشوائي دون أي سبب واضح. بعد التحقيق، اكتشفنا أن أحد الـ Promises في خلفية التطبيق كان يفشل أحياناً بسبب مشكلة في قاعدة البيانات، ولم نكن نستخدم .catch() لمعالجة الخطأ. لأن الخطأ كان يحدث في خلفية التطبيق، لم نلاحظه في البداية، لكن الـ Node.js كان يقتل العملية بالكامل بسبب الـ Unhandled Rejection. الحل؟ إضافة معالج عام للأخطاء في بداية التطبيق، بالإضافة إلى التأكد من معالجة جميع الأخطاء في كل Promise وasync function.
// إضافة معالج عام للأخطاء في Node.js
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// يمكنك هنا تسجيل الخطأ في نظام مراقبة الأخطاء مثل Sentry
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// من الجيد إعادة تشغيل التطبيق في هذه الحالة
process.exit(1);
});
// في المتصفح، يمكنك استخدام:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled Rejection:', event.reason);
event.preventDefault(); // منع الرسالة الافتراضية في الـ Console
});الكثير من المطورين يعتقدون أن async/await أسرع من الـ Promises لأنها تبدو "أكثر مباشرة"، لكن الحقيقة هي أن الأداء يعتمد بشكل كامل على كيفية استخدامك لكل أداة. في الواقع، يمكن أن تكون الـ Promises أسرع في بعض الحالات لأنها تسمح بمعالجة الأخطاء بشكل أكثر كفاءة دون الحاجة إلى إنشاء كتل try/catch. لكن في حالات أخرى، يمكن أن تكون async/await أسرع لأنها تقلل من عدد الـ Microtasks التي تُضاف إلى الـ Queue.
في أحد الاختبارات التي أجريناها في نوفيل، قارنا أداء الـ Promises مقابل async/await في معالجة 10,000 طلب متزامن. النتائج كانت مفاجئة: في Node.js، كانت الـ Promises أسرع بنسبة 12% في المتوسط، بينما في المتصفح، كانت async/await أسرع بنسبة 8%. السبب؟ في Node.js، الـ Promises الخام تسمح بتحسينات داخلية في الـ V8 Engine، بينما في المتصفح، كانت async/await تقلل من عدد الـ Microtasks التي تُضاف إلى الـ Queue. هذا يوضح أن الأداء ليس أمراً مطلقاً، بل يعتمد على البيئة والسيناريو.
// اختبار أداء بسيط بين الـ Promises وasync/await
const ITERATI 10000;
// باستخدام الـ Promises
console.time('Promises');
let promises = [];
for (let i = 0; i < ITERATIONS; i++) {
promises.push(Promise.resolve(i).then(n => n * 2));
}
Promise.all(promises).then(() => {
console.timeEnd('Promises');
});
// باستخدام async/await
console.time('Async/Await');
(async () => {
let results = [];
for (let i = 0; i < ITERATIONS; i++) {
results.push(await Promise.resolve(i).then(n => n * 2));
}
console.timeEnd('Async/Await');
})();أحد أخطر المشاكل التي يمكن أن تسببها الـ Promises وasync/await هو الـ Memory Leak. يحدث هذا عندما تحتفظ بمراجع للـ Promises أو الـ Callbacks التي لا تُزال أبداً من الذاكرة. في الـ Promises، يمكن أن يحدث هذا إذا أنشأت سلسلة طويلة من .then() ولم تتخلص منها بشكل صحيح. في async/await، يمكن أن يحدث إذا أنشأت دوالاً متداخلة تحتوي على await ولم تتخلص منها بعد الانتهاء منها.
في أحد المشاريع مع شركة مغربية، كان التطبيق يستهلك ذاكرة أكثر فأكثر مع مرور الوقت حتى يتوقف تماماً بعد بضع ساعات. بعد التحقيق باستخدام أدوات مثل Chrome DevTools وNode.js Inspector، اكتشفنا أن المشكلة كانت في حلقة تكرارية تستخدم async/await لمعالجة البيانات. كل مرة تمر فيها الحلقة، كانت تُنشئ دالة جديدة تحتوي على await، وهذه الدوال لم تُزال أبداً من الذاكرة لأنها كانت تحتفظ بمراجع للبيانات الأصلية. الحل؟ استخدام WeakMap لتخزين البيانات المؤقتة والتأكد من إزالة المراجع بعد الانتهاء منها.
// مثال على كيفية تسبب async/await في Memory Leak
let dataCache = new Map(); // هذا يمكن أن يسبب Memory Leak إذا لم يُنظف
async function processData(id) {
if (dataCache.has(id)) {
return dataCache.get(id);
}
const data = await fetchData(id);
dataCache.set(id, data); // الاحتفاظ بالمرجع
return data;
}
// الحل: استخدام WeakMap بدلاً من Map
let safeCache = new WeakMap(); // لن يمنع الـ Garbage Collection
async function processDataSafely(obj) {
if (safeCache.has(obj)) {
return safeCache.get(obj);
}
const data = await fetchData(obj.id);
safeCache.set(obj, data); // لن يمنع إزالة obj من الذاكرة
return data;
}بعد أكثر من عشر سنوات في كتابة JavaScript والتعامل مع كل أنواع المشاكل المتعلقة بالـ Asynchronous، هذه هي نصائحي الذهبية لك: أولاً، لا تستخدم async/await فقط لأنها "أسهل قراءة"، بل استخدمها عندما تريد كتابة منطق معقد أو متداخل. ثانياً، إذا كنت تعمل مع طلبات متوازية، فاستخدم Promise.all أو Promise.allSettled بدلاً من انتظار كل طلب على حدة. ثالثاً، دائماً أضف معالجاً عاماً للأخطاء في بداية التطبيق لتجنب الـ Unhandled Rejection. رابعاً، راقب استخدام الذاكرة في التطبيقات التي تعتمد بشكل كبير على الـ Promises أو async/await، خاصة إذا كنت تستخدم حلقات تكرارية أو دوال متداخلة.
وأخيراً، تذكر أن الـ Promises وasync/await ليست أدوات سحرية تحل جميع مشاكلك. هي مجرد أدوات، والأداة الجيدة في يد المطور السيئ تصبح سلاحاً ضدك. إذا كنت تريد أن تصبح محترفاً حقيقياً، فافهم كيف تعمل هذه الأدوات خلف الكواليس، واختبر أداء الكود الخاص بك في بيئات مختلفة، ولا تعتمد على الافتراضات. في النهاية، أفضل أداة هي تلك التي تفهمها تماماً وتستطيع التحكم فيها، وليس تلك التي تبدو أسهل في الاستخدام.