اگر یه مدت با JavaScript و مخصوصاً Node.js کار کرده باشید، احتمالاً خیلی زود به Promise.all() رسیدید و همون بار اول هم ازش خوشتون اومده؛ چون با چند خط کد میشه چند عملیات Async رو همزمان اجرا کرد و به جای اینکه منتظر بمونیم هر درخواست یکی‌یکی تموم بشه، همه رو با هم راه بندازیم. این قابلیت واقعاً کاربردیه، ولی مشکل از جایی شروع میشه که فکر کنیم هرچقدر تعداد Promiseها بیشتر باشه، باز هم Promise.all() بهترین انتخابه.

فرض کنید از دیتابیس ۱۰ هزار کاربر گرفتید و میخواید برای هرکدوم یه درخواست API بفرستید:

const users = await getUsers(); await Promise.all( users.map(user => sendRequest(user)) );

کد تمیزه و در نگاه اول حتی خیلی حرفه‌ای به نظر میرسه، اما اگه users ده هزار عضو داشته باشه، شما عملاً به برنامه گفتید هزاران عملیات رو تقریباً همزمان شروع کن. همینجا چیزی که قرار بود Performance رو بهتر کنه، میتونه تبدیل بشه به دلیل اصلی کند شدن یا حتی Crash سرور.

مشکل Promise.all دقیقاً کجاست؟

خود Promise.all() مشکلی نداره. وقتی چند Promise محدود بهش بدید، همه عملیات‌ها شروع میشن و برنامه منتظر میمونه تا نتیجه همه آماده بشه. این رفتار برای سه، پنج یا ده درخواست مستقل عالیه، چون زمان انتظار کلی رو کم میکنه.

مشکل اینه که Promise.all() تعداد عملیات رو کنترل نمیکنه. اگر پنج Promise بهش بدید، پنج تا اجرا میشه و اگر پنجاه هزار Promise بدید، خودش تصمیم نمیگیره که «این تعداد زیادیه، بذار فعلاً فقط بیست‌تاش رو اجرا کنم».

اینجاست که فشار روی منابع سیستم شروع میشه.

اولین قربانی: RAM

هر Promise و هر عملیات Async یه مقدار حافظه مصرف میکنه. داده‌های ورودی، پاسخ API، Bufferها، Callbackها و اطلاعاتی که تا پایان عملیات باید نگه داشته بشن، همگی داخل RAM قرار میگیرن.

وقتی تعداد Taskها کمه این مصرف اصلاً به چشم نمیاد، ولی اگر هزاران عملیات رو همزمان شروع کنید، مصرف حافظه میتونه خیلی سریع بالا بره. سروری که همیشه با چند صد مگابایت RAM کار میکرده ممکنه هنگام اجرای یه Job بزرگ ناگهان چند گیگابایت حافظه مصرف کنه و در شرایط بدتر به خطای Out of Memory برسه.

دیتابیس هم ظرفیت نامحدود نداره

مشکل بعدی زمانی دیده میشه که داخل Promise.all() کوئری دیتابیس اجرا کنید. مثلاً پنج هزار Query دارید، ولی Connection Pool دیتابیس شما فقط ۲۰ اتصال همزمان رو پشتیبانی میکنه.

در این حالت قرار نیست هر پنج هزار Query واقعاً همزمان اجرا بشن. تعدادی از اون‌ها وارد صف میشن، بخشی منتظر Connection میمونن و اگر فشار زیاد بشه ممکنه Timeout بگیرید. جالب اینجاست که در بعضی شرایط اجرای کنترل‌شده حتی از اجرای هزاران عملیات همزمان سریع‌تر تموم میشه، چون دیتابیس و سرور زیر فشار له نمیشن.

یعنی همیشه «بیشتر همزمان» به معنی «سریع‌تر» نیست.

Rate Limit هم میتونه کل ماجرا رو خراب کنه

اگر به یه API خارجی درخواست میفرستید، باید حواستون به Rate Limit هم باشه. خیلی از سرویس‌ها فقط تعداد مشخصی Request در دقیقه قبول میکنن. اگر شما چند هزار درخواست رو با Promise.all() یکجا بفرستید، احتمالاً با خطای 429 Too Many Requests روبه‌رو میشید.

اگر سیستم Retry هم درست طراحی نشده باشه، اوضاع بدتر میشه؛ چون هزاران درخواست Fail میشن و دوباره تقریباً همزمان تکرار میشن. در واقع خودتون بدون اینکه بخواید فشار بیشتری روی سرویس مقصد ایجاد میکنید.

Async بودن یعنی منابع نامحدود؟ نه!

یکی از برداشت‌های اشتباه اینه که چون Node.js در کارهای Async خیلی خوبه، پس میشه هر تعداد عملیات رو همزمان اجرا کرد. در حالی که Node.js فقط منتظر I/O نمیمونه و از زمانش بهتر استفاده میکنه؛ این به معنی بی‌نهایت بودن منابع نیست.

RAM محدوده، تعداد Socketها محدوده، دیتابیس Connection محدود داره، API مقصد Rate Limit داره و CPU هم توان مشخصی داره. بنابراین باید تعداد عملیات همزمان رو متناسب با منابع واقعی سیستم کنترل کنید.

راه درست: Concurrency رو محدود کنید

معمولاً لازم نیست بین «همه کارها پشت سر هم» و «همه کارها همزمان» یکی رو انتخاب کنید. راه بهتر اینه که تعداد مشخصی Task رو در هر لحظه اجرا کنید.

یکی از ساده‌ترین روش‌ها Batch Processing هست:

async function processInBatches(items, batchSize) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);

    await Promise.all(
      batch.map(item => processItem(item))
    );
  }
}

await processInBatches(users, 50);

اینجا اگر ۱۰ هزار کاربر داشته باشید، به جای اینکه هر ۱۰ هزار عملیات یکجا شروع بشن، هر بار ۵۰ مورد اجرا میشه و بعد از پایان اون‌ها برنامه میره سراغ دسته بعدی. همین تغییر ساده میتونه مصرف RAM، فشار روی دیتابیس و تعداد Timeoutها رو به شکل محسوسی کم کنه.

برای پروژه‌های بزرگ‌تر هم میشه از Queue یا ابزارهای Concurrency Limiter استفاده کرد تا به محض خالی شدن یک Slot، Task بعدی اجرا بشه.

عدد مناسب Concurrency چقدره؟

جواب ثابتی وجود نداره. ممکنه برای یه API عدد ۵ مناسب باشه، برای دیتابیس ۲۰ و برای یه سیستم دیگه ۵۰. بهترین کار اینه که چند مقدار مختلف رو تست کنید و زمان اجرا، مصرف RAM، تعداد Error و فشار روی دیتابیس رو اندازه بگیرید.

اگر با افزایش Concurrency از ۱۰ به ۲۰ سرعت بهتر شد ولی با افزایش از ۲۰ به ۲۰۰ سیستم کندتر شد، یعنی از نقطه مناسب عبور کردید و دارید بیشتر از ظرفیت واقعی سیستم فشار وارد میکنید.

Promise.allSettled هم مشکل اصلی رو حل نمیکنه

Promise.allSettled() برای مدیریت خطا خوبه، چون اگر یکی از Promiseها Reject بشه نتیجه بقیه رو هم دریافت میکنید؛ ولی این متد تعداد عملیات همزمان رو محدود نمیکنه.

پس اگر صد هزار Task به Promise.allSettled() بدید، مشکل فشار روی RAM، دیتابیس یا API همچنان سر جاشه. Error Handling و Concurrency Control دو مسئله جدا هستن.

جمع بندی

Promise.all() نه ابزار بدیه و نه چیزی که باید از پروژه حذفش کنید. برای تعداد محدودی عملیات مستقل، یکی از بهترین و ساده‌ترین ابزارهای جاوا اسکریپته. مشکل زمانی شروع میشه که آرایه‌ای با هزاران عضو رو بدون هیچ محدودیتی داخلش بریزیم و انتظار داشته باشیم Node.js خودش همه‌چیز رو مدیریت کنه.

نکته اصلی اینه که Async بودن به معنی منابع نامحدود نیست. اگر با تعداد زیادی Request، Query یا Job سروکار دارید، بهتره Concurrency رو کنترل کنید و اجازه ندید همه عملیات‌ها یکجا شروع بشن.

پس دفعه بعد که کدی شبیه Promise.all(bigArray.map(...)) دیدید، فقط یه سؤال بپرسید: این آرایه حداکثر چند عضو میتونه داشته باشه؟ اگر جواب «نمیدونم» بود، احتمالاً وقتشه یه ترمز برای Concurrency بذارید.