Event Loop در جاوااسکریپت چیه و چرا اینقدر مهمه؟
اگه یه مدت باشه که با جاوااسکریپت کار میکنی، حتماً به یه سوال عجیب برخوردی: چطور جاوااسکریپت که
تکرشتهای (Single-Threaded) هست، میتونه همزمان چندتا کار انجام بده؟ مثلاً هم منتظر جواب یه
fetch باشه، هم به کلیک کاربر جواب بده، هم یه تایمر رو اجرا کنه؟
جواب این سوال یه چیزیه به اسم Event Loop یا همون «حلقهی رویداد».
تو این مقاله میخوایم قدمبهقدم، خیلی ساده و خودمونی، ببینیم این مکانیزم دقیقاً چیکار میکنه.
اول ببینیم تکرشتهای بودن یعنی چی
جاوااسکریپت فقط یه Call Stack (پشته اجرا) داره. یعنی در هر لحظه فقط یه دستور داره اجرا میشه، نه بیشتر. برخلاف زبانهایی مثل جاوا یا ++C که میتونن چندتا Thread واقعی داشته باشن، جاوااسکریپت اینجوری نیست. اگه یه تابع سنگین و طولانی بنویسی که مثلاً ۵ ثانیه طول بکشه، کل صفحه (یا کل برنامهی Node) قفل میشه و هیچ کار دیگهای انجام نمیشه.
پس اگه تکرشتهایه، چطوری async کار میکنه؟! اینجاست که پای Event Loop وسط میاد. جاوااسکریپت خودش تنها Call Stack رو مدیریت میکنه، ولی کارهای «معطلکننده» (مثل تایمر، درخواست شبکه، خوندن فایل) رو میده به محیط اجرا (مرورگر یا Node.js) و اونا رو حل کنن. وقتی کارشون تموم شد، نتیجه رو برمیگردونن تو صف، و Event Loop اونا رو یکییکی میذاره تو Call Stack تا اجرا بشن.
اجزای اصلی ماجرا: با هم آشنا بشیم
برای فهمیدن Event Loop باید چهارتا بازیگر اصلی رو بشناسی:
- Call Stack: جایی که کد سینک (همزمان) اجرا میشه.
- Web APIs / Node APIs: کارهای async مثل
setTimeout،fetch، رویدادهای DOM اینجا انجام میشن (این بخش داخل خود موتور V8 نیست، بلکه مرورگر یا Node فراهمش میکنه). - Callback Queue (به اون Macrotask Queue هم میگن): صف کارهایی مثل
setTimeout، رویدادهای کلیک و... . - Microtask Queue: صف کارهایی مثل
Promise.thenوqueueMicrotask، که اولویتش از Callback Queue بالاتره.
حالا بریم این چهارتا رو تصویری ببینیم:
قانون طلایی: کِی نوبت کیه؟
اینجا یه قانون خیلی مهم هست که خیلیا گیج میشن: Event Loop فقط وقتی سراغ صفها میره که Call Stack کاملاً خالی باشه. یعنی تا وقتی حتی یه خط کد سینک داره اجرا میشه، هیچ کالبک یا Promise ای اجرا نمیشه، هر چقدرم که آماده باشه.
و ترتیب اولویت هم اینجوریه:
- اول همهی کدهای سینک تو Call Stack اجرا میشن.
- بعد Event Loop میره سراغ Microtask Queue و همهشو، تا آخرین نفر، خالی میکنه (حتی اگه توی اجرا شدن یکیشون، میکروتسک جدید هم اضافه بشه، بازم همونجا پردازش میشه).
- بعد از خالی شدن کامل Microtask Queue، فقط یه تسک از Callback Queue (Macrotask) برمیداره و اجرا میکنه.
- دوباره از اول: چک میکنه Microtask Queue خالیه یا نه، و همینطور تکرار.
حالا بریم سراغ کد؛ یه مثال کلاسیک
این کد رو نگاه کن و قبل از خوندن جواب، حدس بزن ترتیب چاپش چیه:
console.log('1: شروع اسکریپت');
setTimeout(() => {
console.log('2: داخل setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: داخل Promise.then');
});
console.log('4: پایان اسکریپت');
خروجی این کد به این ترتیبه:
1: شروع اسکریپت
4: پایان اسکریپت
3: داخل Promise.then
2: داخل setTimeout
چرا؟ چون console.log های ۱ و ۴ سینک هستن و بلافاصله تو Call Stack اجرا میشن.
اما setTimeout کالبکش میره تو Callback Queue (Macrotask) و Promise.then میره تو
Microtask Queue. حالا که Call Stack خالی شد، اول نوبت Microtask Queue هست (پس ۳ چاپ میشه)،
و بعدش نوبت Callback Queue میرسه (پس ۲ چاپ میشه) — حتی اگه delay رو صفر گذاشته باشیم!
Microtask vs Macrotask، فرقشون دقیقاً چیه؟
خیلیا این دوتا رو قاطی میکنن. بذار یه جدول ذهنی بسازیم:
- Macrotask (Callback Queue): شامل
setTimeout،setInterval، رویدادهای I/O، رندر کردن UI. - Microtask: شامل
Promise.then/catch/finally،queueMicrotask، و در Node.js چیزی به اسمprocess.nextTick(که حتی از Microtask های معمولی هم اولویت بالاتری داره).
نکتهی جالب و کاربردی: بعد از هر Macrotask، مرورگر همهی Microtask ها رو خالی میکنه، قبل از اینکه بره سراغ رندر بعدی یا Macrotask بعدی. برای همینه که اگه بیرویه تو یه حلقه، Promise جدید صف کنی، ممکنه رندر صفحه عقب بیفته و برنامه یهو "هنگ" به نظر برسه.
یه مثال پیچیدهتر برای تمرین ذهن
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
setTimeout(() => console.log('E'), 0);
Promise.resolve().then(() => console.log('F'));
console.log('G');
خروجی درستش:
A
G
C
F
D
B
E
دلیلش: A و G سینک هستن. بعدش، Microtask Queue پر میشه از C و F (که هردو
از دو تا Promise جدا میان). این دوتا اول اجرا میشن. اما then(() => console.log('D')) که روی نتیجهی
then اول چیده شده، تا وقتی C اجرا نشه، به صف اضافه نمیشه — پس D بعد از
F میاد. در آخر، وقتی همهی Microtask ها تموم شدن، نوبت Macrotask ها میرسه: B و بعدش
E، دقیقاً به همون ترتیبی که ثبت شدن.
یه نکتهی خیلی مهم دربارهی async/await
شاید فکر کنی async/await یه چیز کاملاً جداست، ولی در واقع فقط یه لایهی نحوی (syntactic sugar) روی Promise هست.
هر await، دقیقاً مثل یه .then() رفتار میکنه و کد بعد از خودش رو به Microtask Queue میفرسته.
async function run() {
console.log('1: شروع تابع');
await null; // اینجا تابع مکث میکنه و بقیه کد میره تو میکروتسک
console.log('2: بعد از await');
}
console.log('شروع');
run();
console.log('پایان');
خروجی:
شروع
1: شروع تابع
پایان
2: بعد از await
یعنی تا خط await، همهچی سینک اجرا میشه. اما به محض رسیدن به await، بقیهی تابع
(هر چقدرم زیاد باشه) عملاً به یه Microtask تبدیل میشه و منتظر خالی شدن Call Stack میمونه.
یه مقایسهی سریع؛ توی یه نگاه
چند تا اشتباه رایج که باید مراقبشون باشی
- فکر کردن اینکه
setTimeout(fn, 0)بلافاصله اجرا میشه: اصلاً اینطور نیست. حداقل باید صبر کنه تا Call Stack و Microtask Queue خالی بشن. - نوشتن حلقهی بینهایت Promise: اگه توی هر Microtask یه Microtask جدید صف کنی، ممکنه هیچوقت نوبت به Macrotask ها و رندر UI نرسه.
- فراموش کردن اینکه Node.js هم فرقهای کوچیکی داره: مثلاً
process.nextTickحتی از Promise هم زودتر اجرا میشه، و فازهای Event Loop توی Node (مثل timers، I/O callbacks، check) دقیقتر و پیچیدهتر از مرورگرن.
جمعبندی
Event Loop چیزی نیست جز یه ناظر ساده که مدام چک میکنه: «Call Stack خالیه؟ پس بذار اول Microtask ها رو خالی کنم، بعد یه Macrotask بردارم».
همین مکانیزم ساده، دلیل اصلیه که جاوااسکریپت با وجود تکرشتهای بودن، میتونه async و responsive باشه.
یه بار که این ترتیب اولویت (سینک → میکروتسک → ماکروتسک) رو خوب بفهمی، دیگه هیچوقت از رفتار عجیب
setTimeout و Promise کنار هم غافلگیر نمیشی.
