Async/Await: الشرح الأبسط لمفهوم البرمجة غير المتزامنة
تعرف على Async و Await وكيف تسهّل كتابة الكود غير المتزامن، مع أمثلة عملية ونصائح لتحسين الأداء.
مقدمة
في عالم تطوير البرمجيات، أصبحت البرمجة غير المتزامنة ضرورة لا غنى عنها لواجهات سريعة وخوادم فعّالة. ومن بين أفضل الطرق لصياغة هذا النمط بوضوح تأتي ثنائية Async/Await: كتابة كود غير متزامن بأسلوب قريب من المتزامن، مع تقليل التعقيد الذهني مقارنةً بالسلاسل الطويلة من الـ Promises أو جحيم الـ Callbacks. في هذا الدليل العملي سنبني فهمًا عميقًا لـ Async/Await، ونستعرض أخطاء شائعة، وأنماط استخدام واقعية، وتحليل أداء، مع روابط لمراجع موثوقة.
الكلمة المفتاحية الرئيسية: Async/Await
كلمات ثانوية: Await، البرمجة غير المتزامنة، Promises، Event Loop.
خلفية تاريخية سريعة
- قبل سنوات، كان الاعتماد على Callbacks هو الأسلوب الشائع، لكنه قاد إلى ما يعرف بـ Callback Hell مع تعقيد في إدارة الأخطاء.
- ظهور Promises عالج جزءًا كبيرًا من المشكلة عبر تسلسل أو تركيب الوعود.
- في ES2017 (ECMAScript 2017) أُضيفت الدوال غير المتزامنة (
async function) والكلمة المفتاحيةawait، ما سهّل كتابة كود غير متزامن مقروء وقابل للصيانة. راجع: MDN: async function.
ما هو Async/Await؟
asyncتعرّف دالة غير متزامنة تعيد دائمًا Promise.awaitتوقف تنفيذ الدالة (من غير حجز الخيط thread) حتى يكتمل الوعد، ثم تُعيد قيمته أو ترمي خطأه.- النتيجة: تدفّق منطقي واضح (try/catch/finally) بدل سلاسل then/catch.
مثال تعريفي بسيط
async function fetchUser(userId) {
try {
const res = await fetch(`https://api.example.com/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('فشل جلب المستخدم:', err);
throw err;
}
}
آلية عمل الـ Event Loop مع Async/Await
لفهم سلوك Async/Await بدقة، نحتاج لصورة ذهنية عن Event Loop وقائمتي المهام:
- Macrotasks: تنفيذ النص البرمجي، مؤقتات مثل
setTimeout, أحداث DOM, عمليات I/O. - Microtasks: وعود Promises واستكمالاتها، وما ينتج عن
await.
لماذا يهمّ هذا؟
- عندما تستخدم
await، تُؤجَّل متابعة التنفيذ إلى microtask queue (أولوية أعلى) بعد اكتمال الـ Promise. - يتم تفريغ microtasks بالكامل قبل الانتقال لـ macrotasks التالية.
مراجع: MDN: Concurrency model and the event loop.
نقطة دقيقة
إذا استدعيت دالة تُعيد Promise بدون await، قد يستمر التنفيذ فورًا (fire-and-forget). أمّا مع await، فستؤخّر السطر التالي إلى دورة microtask القادمة، ما قد يغيّر الترتيب المرئي للسجلات (logs).
استخدامات واقعية (Use Cases)
1) جلب بيانات من API مع مهلة إلغاء (Timeout) باستخدام AbortController
async function fetchWithTimeout(url, { timeout = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === 'AbortError') throw new Error('انتهت المهلة (Timeout)');
throw e;
}
}
// استخدام:
await fetchWithTimeout('https://api.example.com/weather?city=Amman', { timeout: 3000 });
2) تشغيل عمليات مستقلة بالتوازي (Parallelism) عبر Promise.all
async function loadDashboard(userId) {
const [profile, notifications, recommendations] = await Promise.all([
fetch(`/api/profile/${userId}`).then(r => r.json()),
fetch(`/api/notifications?uid=${userId}`).then(r => r.json()),
fetch(`/api/reco?uid=${userId}`).then(r => r.json()),
]);
return { profile, notifications, recommendations };
}
3) ضمان استمرار معالجة بقية المهام حتى لو فشل بعضها باستخدام Promise.allSettled
async function processBatch(urls) {
const results = await Promise.allSettled(
urls.map(u => fetch(u).then(r => r.json()))
);
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.filter(r => r.status === 'rejected').map(r => r.reason);
return { ok, failed };
}
4) مثال Node.js: قراءة ملفات متعددة بالتوازي ثم دمج النتائج
import { promises as fs } from 'node:fs';
async function readMany(paths) {
const files = await Promise.all(paths.map(p => fs.readFile(p, 'utf8')));
return files.join('\n---\n');
}
أخطاء شائعة وكيفية تفاديها
الخطأ 1: await داخل forEach
// خطأ: forEach لا ينتظر
[1,2,3].forEach(async (id) => {
await doAsync(id);
});
الصحيح:
for (const id of [1,2,3]) {
await doAsync(id);
}
الخطأ 2: التسلسل غير الضروري بدلاً من التنفيذ المتوازي
// أبطأ
const a = await fetch('/a').then(r=>r.json());
const b = await fetch('/b').then(r=>r.json());
const c = await fetch('/c').then(r=>r.json());
الصحيح:
const [a,b,c] = await Promise.all([
fetch('/a').then(r=>r.json()),
fetch('/b').then(r=>r.json()),
fetch('/c').then(r=>r.json()),
]);
الخطأ 3: إهمال معالجة الأخطاء
- احرص على
try/catchحول كتل await الحرجة. - استخدم أنماطًا مثل نتيجة أو خطأ:
async function to(promise) {
try { return [await promise, null]; }
catch (e) { return [null, e]; }
}
// استخدام:
const [data, err] = await to(fetchJSON('/api'));
if (err) { /* تعامل مع الخطأ */ }
الخطأ 4: استخدام await بلا حاجة (blocking المنطقي)
- لا تنتظر نتيجة يمكن معالجتها لاحقًا.
- استخدم التجميع (batching) والإرسال المقيد (pooling) في الحالات كثيفة الطلبات.
أفضل الممارسات
- طبّق التوازي بحذر: استخدم
Promise.allللمهام المستقلة، وallSettledعندما تريد المتابعة حتى مع الفشل الجزئي. - حدّد أقصى درجة للتوازي (Concurrency limit) لتفادي الضغط على الخدمات:
async function mapPool(items, worker, pool = 5) {
const ret = [];
const q = [...items];
const running = [];
while (q.length || running.length) {
while (q.length && running.length < pool) {
const item = q.shift();
const p = worker(item).then(r => {
running.splice(running.indexOf(p), 1);
ret.push(r);
});
running.push(p);
}
if (running.length) await Promise.race(running);
}
return ret;
}
- أدِر المهلات وإعادة المحاولة (Retry) مع backoff أُسّي.
- سجّل الأخطاء واستخدم أدوات مراقبة (Observability) لتتبّع السلوك غير المتوقع.
- نظّف الموارد (
finally) ولا تنسَ إلغاء الطلبات الطويلة (AbortController). - اعتمد نوعية الشيفرة: دوال صغيرة، أسماء واضحة، اختبارات وحدات، و Linters.
تحليل أداء عملي (Benchmark)
يُظهر المثال التالي الفرق بين التنفيذ المتسلسل والمتوازي لمحاكاة I/O:
function delay(ms) { return new Promise(res => setTimeout(res, ms)); }
async function sequential() {
const t0 = Date.now();
await delay(300);
await delay(300);
await delay(300);
return Date.now() - t0; // ≈ 900ms
}
async function parallel() {
const t0 = Date.now();
await Promise.all([delay(300), delay(300), delay(300)]);
return Date.now() - t0; // ≈ 300ms
}
(async () => {
console.log('sequential ~', await sequential(), 'ms');
console.log('parallel ~', await parallel(), 'ms');
})();
النتيجة المتوقعة: التنفيذ المتسلسل ≈ مجموع الأزمنة، بينما المتوازي ≈ أكبر زمن منفرد.
ملاحظة: التوازي يفيد I/O-bound. أمّا في CPU-bound (تجزئة صور، ضغط ملفات) فلن تحصل على تسريع داخل خيط واحد؛ عندها فكّر في Web Workers أو Worker Threads.
التوافق والدعم (Compatibility)
- المتصفحات الحديثة و Node.js تدعم Async/Await بشكل افتراضي.
- Top-level await مدعوم في وحدات ESM الحديثة (مثال: Node 18+، والمتصفحات الحديثة). راجع: Node.js ESM.
- للبيئات الأقدم، استخدم Babel لتحويل async/await إلى Generators عبر regenerator-runtime. راجع: Babel Docs.
قسم الأسئلة الشائعة (FAQ)
هل Async/Await أسرع من Promises؟
لا؛ هو تصميم واجهة أوضح فوق Promises. السرعة تعتمد على طبيعة المهمة (I/O مقابل CPU) وطريقة تنظيمك للتوازي.
هل أستطيع استخدام await خارج دالة async؟
لا في السكربتات العادية. يمكن ذلك فقط داخل دوال async. يُستثنى top-level await داخل ESM الحديثة.
كيف أتعامل مع فشل جزء من المجموعة؟
استخدم Promise.allSettled أو نمط try-per-item للحفاظ على تدفّق العمل مع تسجيل الأخطاء.
مصادر موثوقة للقراءة المتقدمة
- MDN – async function
- MDN – Concurrency model & the event loop
- Node.js – ECMAScript Modules & Top-level await
- Babel – Async functions transform
الخاتمة
باستخدام Async/Await يمكنك كتابة كود غير متزامن واضح، قابل للاختبار والصيانة، ويُحسّن تجربة المستخدم دون تضحية بالأداء. ابدأ اليوم بمراجعة مواضع await في مشروعك:
- هل يوجد تسلسل يمكن تحويله إلى توازٍ؟
- هل عالجت كل الأخطاء؟
- هل لديك مهلات وإعادة محاولات؟
دعوة إلى الإجراء: جرّب الأنماط أعلاه على جزء صغير من مشروعك، وشارك النتائج والدروس التي خرجت بها.
تذكّر أن Async/Await ليست غاية بحد ذاتها، بل أداة لتبسيط البرمجة غير المتزامنة وجعلها في خدمة المنتج والمستخدم.