مقالات › جاوااسکریپت

آموزش Event Loop در جاوااسکریپت؛ Call Stack، Task و Microtask

بفهمید جاوااسکریپت چطور کد ناهمگام را روی یک Thread اجرا می‌کند: Call Stack، Web APIها، صف Task و Microtask، رندرینگ و اینکه چرا setTimeout(fn, 0) فوری اجرا نمی‌شود.

جاوااسکریپت تک‌Thread است: در هر لحظه فقط یک تکه کد را اجرا می‌کند. با این حال یک صفحه‌ی وب هم‌زمان داده دریافت می‌کند، انیمیشن اجرا می‌کند، به کلیک‌ها پاسخ می‌دهد و منتظر تایمرها می‌ماند. سازوکاری که این را ممکن می‌کند Event Loop (حلقه‌ی رویداد) است. فهمیدن آن معماهای ترتیب اجرا، رابط‌های کاربری قفل‌شده و بسیاری از باگ‌های ظریف ناهمگام را توضیح می‌دهد.

اجزای اصلی

  1. Call Stack — جایی که جاوااسکریپت توابع را اجرا می‌کند. هر فراخوانی یک قاب (Frame) اضافه و هر بازگشت آن را حذف می‌کند.
  2. Web APIها / APIهای Runtime — تایمرها، شبکه (fetch)، رویدادهای DOM و I/O فایل در Node.js. این‌ها بیرون از Thread جاوااسکریپت اجرا می‌شوند.
  3. صف Task (Macrotask) — Callbackهای آماده‌ی اجرا: Callback تایمرها، رویدادهای رابط کاربری و پیام‌ها.
  4. صف Microtask — Callbackهای با اولویت بالاتر: واکنش‌های Promise (then و ادامه‌ی await)، queueMicrotask و MutationObserver.
  5. Event Loop — زمان‌بندی که کار را از صف‌ها به Call Stack منتقل می‌کند.

حلقه، قدم‌به‌قدم

در مرورگر، هر دور Event Loop تقریباً این کارها را انجام می‌دهد:

  1. یک Task از صف Task برمی‌دارد و آن را تا انتها اجرا می‌کند.
  2. همه‌ی Microtaskها را اجرا می‌کند تا صف Microtask خالی شود (حتی Microtaskهایی که در این میان اضافه می‌شوند).
  3. اگر زمان به‌روزرسانی صفحه رسیده باشد: Callbackهای requestAnimationFrame را اجرا، استایل و چیدمان را محاسبه و صفحه را نقاشی (Paint) می‌کند.
  4. تکرار.

قانون کلیدی: هیچ چیز اجرای جاوااسکریپت در حال اجرا را قطع نمی‌کند. یک Task همیشه قبل از هر چیز دیگری — Task دیگر، Microtask یا نقاشی دوباره — تمام می‌شود.

یک معمای کلاسیک ترتیب اجرا

console.log("1: script start");

setTimeout(() => console.log("5: timeout"), 0);

Promise.resolve()
  .then(() => console.log("3: promise 1"))
  .then(() => console.log("4: promise 2"));

console.log("2: script end");

خروجی:

1: script start
2: script end
3: promise 1
4: promise 2
5: timeout

چرا؟

  • کل اسکریپت Task فعلی است و ۱ و ۲ را چاپ می‌کند.
  • setTimeout Callback خود را به API تایمر می‌دهد که بعد از حداقل ۰ میلی‌ثانیه یک Task در صف می‌گذارد.
  • Callbackهای then Microtask هستند که درست بعد از Task فعلی و قبل از Task بعدی اجرا می‌شوند.
  • فقط بعد از آن Event Loop سراغ Task تایمر می‌رود.

async/await در Event Loop

await نحوی روی Promise است: هر چیزی بعد از یک await، یک ادامه‌ی Microtask است.

async function load() {
  console.log("B");
  await null;              // توقف؛ ادامه یک Microtask می‌شود
  console.log("D");
}

console.log("A");
load();
console.log("C");
// A, B, C, D

تابع تا اولین await به‌صورت همگام اجرا می‌شود و بعد کنترل را به فراخواننده برمی‌گرداند.

چرا setTimeout(fn, 0) فوری نیست

0 یعنی «در اولین فرصت بعد از Task فعلی و همه‌ی Microtaskها»؛ ضمن اینکه مرورگرها تایمرهای تودرتو را حداقل حدود ۴ میلی‌ثانیه تأخیر می‌دهند. وقتی Thread اصلی مشغول است، تایمرها عقب می‌افتند:

const start = Date.now();
setTimeout(() => console.log(`fired after ${Date.now() - start} ms`), 0);

while (Date.now() - start < 1000) {} // حلقه‌ی مشغول، Thread را قفل می‌کند
// "fired after 1000 ms"

قفل کردن Thread اصلی، رابط کاربری را منجمد می‌کند

چون رندر بین Taskها اتفاق می‌افتد، یک Task طولانی یعنی نه نقاشی دوباره و نه پاسخ به ورودی کاربر:

button.addEventListener("click", () => {
  spinner.hidden = false;      // مرورگر هنوز نمی‌تواند این را نقاشی کند…
  heavyCalculation();          // …چون این خط Thread را ۲ ثانیه قفل می‌کند
  spinner.hidden = true;       // تا وقت نقاشی برسد، اسپینر دوباره مخفی شده است
});

راه‌حل‌ها:

  • کار را تکه‌تکه کنید و بین تکه‌ها کنترل را آزاد کنید:
async function processInChunks(items, handle, chunkSize = 200) {
  for (let i = 0; i < items.length; i += chunkSize) {
    items.slice(i, i + chunkSize).forEach(handle);
    await new Promise((r) => setTimeout(r, 0)); // به مرورگر فرصت نقاشی و پاسخ به ورودی بدهید
  }
}
  • در صورت پشتیبانی از scheduler.yield() استفاده کنید که دقیقاً برای همین کار طراحی شده است.
  • محاسبات سنگین را به یک Web Worker منتقل کنید که Thread و Event Loop خودش را دارد.

گرسنگی Microtask

چون Event Loop قبل از رندر همه‌ی Microtaskها را خالی می‌کند، یک زنجیره‌ی بی‌پایان Microtask درست مثل یک حلقه‌ی بی‌نهایت صفحه را منجمد می‌کند:

function loop() {
  Promise.resolve().then(loop); // ❌ صفحه هرگز دوباره نقاشی نمی‌شود
}

تایمرها (Macrotask) این مشکل را ندارند، چون رندر می‌تواند بین Taskها انجام شود.

requestAnimationFrame؛ کار هماهنگ با نقاشی

برای انیمیشن، requestAnimationFrame Callback شما را درست قبل از نقاشی مرورگر اجرا می‌کند؛ یک بار در هر فریم (حدود ۶۰ یا ۱۲۰ بار در ثانیه):

function animate(time) {
  box.style.transform = `translateX(${Math.sin(time / 500) * 100}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

کتابخانه‌های انیمیشن مثل GSAP و کتابخانه‌های اسکرول نرم مثل Lenis دقیقاً از همین حلقه استفاده می‌کنند. برای انیمیشن از setInterval استفاده نکنید: با نرخ تازه‌سازی صفحه هماهنگ نیست و در تب‌های پس‌زمینه هم اجرا می‌شود.

Node.js هم فاز دارد

Node.js از Event Loop کتابخانه‌ی libuv با چند فاز (تایمرها، Callbackهای معوق، Poll، Check، Close) استفاده می‌کند. تفاوت‌های عملی:

  • process.nextTick قبل از Microtaskهای Promise اجرا می‌شود.
  • setImmediate در فاز Check، بعد از Callbackهای I/O اجرا می‌شود.
  • کار سنگین همگام روی CPU همه‌ی درخواست‌های سرور را قفل می‌کند؛ مشکلی بسیار بزرگ‌تر از یک تب مرورگر.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// nextTick, promise و بعد timeout/immediate (ترتیب این دو در سطح بالای فایل می‌تواند متفاوت باشد)

جدول خلاصه

سازوکار صف زمان اجرا
کد همگام Call Stack فوراً
Promise.then، ادامه‌ی await، queueMicrotask Microtask بعد از Task فعلی، قبل از رندر
setTimeout، setInterval، رویدادهای DOM، Callbackهای fetch Task یکی در هر دور حلقه
requestAnimationFrame مرحله‌ی رندر قبل از نقاشی بعدی
Web Worker Thread جداگانه به‌صورت موازی

جمع‌بندی

Event Loop در هر لحظه یک Task را تا انتها اجرا می‌کند، سپس همه‌ی Microtaskها را خالی می‌کند و بعد به مرورگر اجازه‌ی رندر می‌دهد. Promiseها و ادامه‌ی await Microtask هستند، تایمرها و رویدادها Task و requestAnimationFrame به نقاشی گره خورده است. Taskها را کوتاه نگه دارید، کارهای سنگین را تکه‌تکه کنید یا به Web Worker بسپارید تا جاوااسکریپت شما زیر بار هم پاسخ‌گو بماند.

پرسش‌های متداول

آیا جاوااسکریپت واقعاً تک‌Thread است؟

کد جاوااسکریپت شما روی یک Thread اصلی اجرا می‌شود، اما مرورگر یا Node.js برای شبکه، تایمرها، I/O و Web Workerها از Threadهای دیگر استفاده می‌کنند.

آیا Promise کد را موازی اجرا می‌کند؟

نه. Promise ادامه‌ها را زمان‌بندی می‌کند؛ موازی‌سازی واقعی از I/O خود Runtime می‌آید. جاوااسکریپت سنگین روی CPU همچنان Thread را قفل می‌کند.

چرا به‌روزرسانی State در React دیرتر دیده می‌شود؟

React به‌روزرسانی‌ها را دسته‌بندی و رندر را زمان‌بندی می‌کند؛ به‌روزرسانی در مرحله‌ی بعدی همان Task یا Task بعدی اعمال می‌شود، نه هم‌زمان با صدا زدن Setter.