حلقه رویداد (event loop) جاوااسکریپت که صفحه وب شما را هدایت می‌کند، رفتاری بسیار متفاوت با حلقه‌ای دارد که یک سرور Node.js را تغذیه می‌کند؛ و اگر مراقب نباشید، این عدم تطابق می‌تواند باعث فریز شدن رابط کاربری (UI) یا گلوگاه شدن ورودی/خروجی (I/O) شود. دانستن اینکه این دو کجا از هم جدا می‌شوند، برای هر کسی که کدهای ناهمگام (async) می‌نویسد که در هر دو محیط اجرا می‌شوند، ضروری است.

چرا این تمایز اهمیت دارد

حلقه رویداد در مشخصات ECMAScript تعریف نشده است؛ بلکه در محیط میزبان (host) تعریف می‌شود. مرورگرها باید در حین رندر کردن فریم‌ها، پاسخگویی صفحه را حفظ کنند، در حالی که Node.js حول محور I/O غیرمسدودکننده (non-blocking I/O) ساخته شده است. ترکیب الگوهایی که در یک میزبان کار می‌کنند با میزبان دیگر، می‌تواند باگ‌هایی ایجاد کند که بازتولید آن‌ها دشوار است: یک زنجیره طولانی از پرامیس‌ها (promises) ممکن است باعث توقف بازترسیم (repaint) مرورگر شود، در حالی که یک حلقه process.nextTick کنترل‌نشده می‌تواند مانع از رسیدن Node به مراحل I/O شود.

حلقه مبتنی بر نوبت در مرورگر

در مرورگر، حلقه یک چرخه واحد را اجرا می‌کند که شامل اجرای تسک‌ها، تخلیه میکراتسک‌ها (microtask draining) و رندرینگ به صورت متناوب است:

  1. اجرای یک ماکراتسک (یک هندلر کلیک، یک setTimeout و غیره).
  2. تخلیه تمام میکراتسک‌ها (promises، queueMicrotask).
  3. اگر زمان یک فریم فرا رسیده باشد، برای رسیدن به نرخ ۶۰ فریم بر ثانیه (60 fps) هدف، عملیات paint و composite انجام می‌شود.
  4. تکرار مرحله قبل.

دو API به توسعه‌دهندگان قلاب‌های (hooks) صریحی برای ورود به این چرخه می‌دهند:

  • requestAnimationFrame – درست قبل از اینکه مرورگر عملیات paint را انجام دهد، فراخوانی می‌شود. این مکان مناسبی برای کارهای مربوط به انیمیشن است، زیرا کال‌بک پس از میکراتسک‌های فعلی اما قبل از فریم بعدی اجرا می‌شود.
  • requestIdleCallback – زمانی فراخوانی می‌شود که مرورگر کار با اولویت بالا ندارد. این متد برای تسک‌های کم‌اهمیت مانند تحلیل داده‌ها (analytics) یا پیش‌بارگذاری داده‌ها مفید است.

تله: گرسنگی میکراتسک (microtask starvation)

از آنجایی که مرورگر پیش از رندر کردن، صف میکراتسک‌ها را خالی می‌کند، یک زنجیره طولانی از پرامیس‌ها می‌تواند مانع از رندر شدن UI شود. در اینجا پشته فراخوانی (call stack) مسدود نمی‌شود؛ بلکه صفحه صرفاً هرگز به مرحله رندر نمی‌رسد، که از نظر کاربر مانند فریز شدن به نظر می‌رسد.

حلقه مبتنی بر libuv در Node.js

Node.js مدیریت حلقه خود را به libuv می‌سپارد، یک کتابخانه C که کارها را به مراحل متمایزی تقسیم می‌کند که هر کدام صف مخصوص به خود را دارند:

  1. Timers – کال‌بک‌های حاصل از setTimeout و setInterval.
  2. Pending callbacks – کال‌بک‌های I/O به تأخیر افتاده که قبلاً در سطح سیستم‌عامل (OS) تکمیل شده‌اند.
  3. Poll – دریافت رویدادهای جدید I/O (خواندن فایل، داده‌های شبکه).
  4. Check – اجرای کال‌بک‌های setImmediate.
  5. Close callbacks – زمانی که یک سوکت یا هندل بسته می‌شود، اجرا می‌گردد.

دو ساختار خارج از این ترتیب مراحل قرار دارند:

  • process.nextTickقبل از صف میکراتسک‌ها، بلافاصله پس از پایان عملیات فعلی اجرا می‌شود.

تله: گرسنگی I/O (I/O starvation)

اگر تابعی به طور مکرر بدون واگذاری کنترل، process.nextTick را زمان‌بندی کند، Node هرگز از مرحله "next-tick" فراتر نمی‌رود. درخواست‌های شبکه، خواندن فایل‌ها و تایمرها معطل می‌مانند که باعث ایجاد جهش در تأخیر (latency) سمت سرور یا از کار افتادن کامل آن می‌شود.

مقایسه setImmediate و setTimeout در عمل

هر دو برای تکرار بعدی کال‌بک‌ها را زمان‌بندی می‌کنند، اما ترتیب نسبی آن‌ها به محل فراخوانی بستگی دارد:

  • کد سطح بالا (Top-level code) – ترتیب تضمین شده نیست؛ و به سرعت شروع شدن فرآیند بستگی دارد.
  • داخل یک کال‌بک I/O – ترتیب قطعی (deterministic) است: setImmediate قبل از setTimeout(fn, 0) اجرا می‌شود. پس از پایان مرحله Poll، libuv به مرحله Check (جایی که setImmediate قرار دارد) می‌رود، پیش از آنکه برای یک تایم‌اوت با تأخیر صفر، دوباره وارد مرحله Timers شود.

این نکته ظریف زمانی اهمیت پیدا می‌کند که شما به توالی دقیق وابسته هستید، مانند پاکسازی یک منبع بلافاصله پس از تکمیل یک عملیات خواندن.

تفاوت‌های کلیدی در یک نگاه

  • هدف: مرورگرها به‌روزرسانی‌های بصری را در اولویت قرار می‌دهند؛ Node اولویت را به آمادگی I/O می‌دهد.
  • قلاب رندرینگ: requestAnimationFrame (فقط مرورگر).
  • قلاب مخصوص مرحله: setImmediate (فقط Node، در مرحله Check اجرا می‌شود).
  • صف با اولویت بالا: process.nextTick (فقط Node، قبل از میکراتسک‌ها اجرا می‌شود).
  • خطر گرسنگی (Starvation): زنجیره‌های طولانی پرامیس در مرورگر؛ process.nextTick نامحدود در Node.

نکات بعدی برای بررسی

اگر کدی را نگهداری می‌کنید که در هر دو محیط اجرا می‌شود (مثلاً کتابخانه‌های isomorphic)، هر جایی را که در آن موارد زیر انجام می‌دهید، بازبینی کنید:

  • زنجیره‌ای از پرامیس‌های زیاد بدون واگذاری کنترل به حلقه رویداد ایجاد نکنید. از await new Promise(r => setTimeout(r, 0)) استفاده کنید یا در مرورگر از requestIdleCallback بهره ببرید تا به رندرکننده فرصتی برای اجرا داده شود.
  • از process.nextTick برای کارهایی که می‌توانند به تأخیر بیفتند استفاده نکنید. وقتی به فوریت "next-tick" نیاز ندارید، setImmediate یا یک پرامیس معمولی را ترجیح دهید.
  • فرض نکنید که setTimeout(fn, 0) و setImmediate قابل تعویض هستند. اگر توالی اجرا مهم است، ترتیب آن‌ها را در داخل کال‌بک‌های I/O تست کنید.

نکات کلیدی

حلقه رویداد (event loop) یک زمان‌بند مختص به میزبان (host) است، نه یک ویژگی عمومی در جاوااسکریپت. مرورگرها فرآیند رندرینگ را در حلقه ادغام می‌کنند؛ در حالی که Node عملیات I/O را در فازهای libuv ایزوله می‌کند. استفاده نادرست از مکانیزم‌های اولویت‌بندی — مانند microtaskها در مرورگر یا process.nextTick در Node — می‌تواند باعث ایجاد وضعیت گرسنگی منابع (starvation) در بخشی از سیستم شود که هر محیط برای خدمت‌رسانی به آن طراحی شده است. الگوهای ناهمگام (async) خود را با مدل حلقه میزبان همسو کنید تا هم از فریز شدن صفحات و هم از مسدود شدن سرورها جلوگیری کنید.