بررسی Event Loop و Call Stack در جاوااسکریپت

جاوااسکریپت با وجود آنکه یک زبان single-threaded است، توانایی انجام عملیات‌های asynchronous مانند درخواست‌های شبکه یا اجرای تایمرها را دارد؛ بدون آنکه باعث توقف یا فریز شدن برنامه شود. این قابلیت، نتیجه معماری زمان اجرای جاوااسکریپت و به ویژه نقش کلیدی event loop در جاوااسکریپت است؛ سازوکاری که با همکاری اجزایی همچون Call Stack، وب APIها و Task Queueها، اجرای روان و بدون وقفه‌ی کدها را ممکن می‌سازد.

در این مقاله، بررسی می‌کنیم که چطور جاوااسکریپت با وجود single-threaded بودن، می‌تواند چنین عملکرد پیچیده‌ای داشته باشد. همچنین به بررسی نحوه تعامل بین Call Stack، Event Loop و صف‌های مختلف نیز خواهیم پرداخت.

Call Stack: هماهنگ‌کننده اجرای جاوااسکریپت

از آنجایی که جاوااسکریپت تنها می‌تواند یک کار را در یک زمان اجرا کند، باید راهی برای پیگیری این که چه چیزی در حال اجراست، کار بعدی چیست و برنامه پس از هر توقف از کجا باید ادامه یابد، وجود داشته باشد. این وظیفه را Call Stack بر عهده دارد.

Call Stack چیست؟

Call Stack، یک ساختار داده‌ای است که وضعیت اجرای فعلی برنامه را دنبال می‌کند. می‌توان آن را به عنوان یک فهرست کارها برای موتور جاوااسکریپت در نظر گرفت.

عملکرد آن به این شکل است:

  • آخرین ورودی، اولین خروجی (LIFO): توابع از بالا به پشته اضافه می‌شوند و پس از اتمام، از بالا نیز حذف می‌گردند.
  • Context اجرا: هر بار که یک تابع فراخوانی می‌شود، یک Context جدید (شامل متغیرها، آرگومان‌ها و…) ایجاد شده و به پشته افزوده می‌شود.

بیایید با بررسی یک مثال ساده، نحوه عملکرد Call Stack را بهتر درک کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function logThree() {
console.log(‘Three’);
}
Function logThreeAndFour() {
logThree(); // step 3
console.log(‘Four’); // step 4
}
console.log(‘One’); // step 1
console.log(‘Two’); // step 2
logThreeAndFour(); // step 3-4
>
function logThree() { console.log(‘Three’); } Function logThreeAndFour() { logThree(); // step 3 console.log(‘Four’); // step 4 } console.log(‘One’); // step 1 console.log(‘Two’); // step 2 logThreeAndFour(); // step 3-4 >
function logThree() {
  console.log(‘Three’);
}

Function logThreeAndFour() {
  logThree(); // step 3
  console.log(‘Four’); // step 4
}

console.log(‘One’); // step 1
console.log(‘Two’); // step 2
logThreeAndFour(); // step 3-4
>

تحلیل مرحله‌به‌مرحله:

در این تحلیل:

  • Stack: نمایانگر توابعی است که در انتظار اجرا هستند، با آخرین تابع در بالاترین موقعیت.
  • Action: توصیف می‌کند که موتور جاوااسکریپت در هر مرحله چه کاری انجام می‌دهد؛ از جمله اجرای توابع و حذف آن‌ها از پشته.

مرحله ۱:

console.log('One')
console.log('One') وارد پشته می‌شود:

  • Stack:
    [main(), console.log('One')]
    [main(), console.log('One')]
  • Action: اجرا می‌شود —
    'One'
    'One' چاپ می‌شود، از پشته خارج می‌گردد.

مرحله ۲:

console.log('Two')
console.log('Two') وارد پشته می‌شود:

  • Stack:
    [main(), console.log('Two')]
    [main(), console.log('Two')]
  • Action: این بار
    'Two'
    'Two' اجرا شده، از پشته حذف می‌شود.

و مرحله ۳:

logThreeAndFour()
logThreeAndFour() فراخوانی می‌شود:

  • Stack:
    [main(), logThreeAndFour()]
    [main(), logThreeAndFour()]
  • Action: در داخل
    logThreeAndFour()
    logThreeAndFour()، تابع
    logThree()
    logThree() فراخوانی می‌شود.
    • Stack:
      [main(), logThreeAndFour(), logThree()]
      [main(), logThreeAndFour(), logThree()]
    • Action: سپس
      logThree()
      logThree()، تابع
      console.log('Three')
      console.log('Three') را فراخوانی می‌کند.
      • Stack:
        [main(), logThreeAndFour(), logThree(), console.log('Three')]
        [main(), logThreeAndFour(), logThree(), console.log('Three')]
      • Action: اکنون
        'Three'
        'Three' اجرا می‌شود، از پشته خارج می‌گردد.
    • Stack:
      [main(), logThreeAndFour(), logThree()]
      [main(), logThreeAndFour(), logThree()]، سپس
      logThree()
      logThree() از پشته خارج می‌شود.

در نهایت، مرحله ۴: 

console.log('Four')
console.log('Four') وارد پشته می‌شود:

  • Stack:
    [main(), logThreeAndFour(), console.log('Four')]
    [main(), logThreeAndFour(), console.log('Four')]
  • Action: سپس
    'Four'
    'Four' اجرا شده، از پشته حذف می‌شود.
  • Stack :
    [main(), logThreeAndFour()]
    [main(), logThreeAndFour()]، سپس
    logThreeAndFour()
    logThreeAndFour() از پشته خارج می‌شود.

در پایان، پشته خالی می‌شود و برنامه خاتمه می‌یابد.

معضل single-threaded بودن جاوااسکریپت

از آنجایی که جاوااسکریپت تنها یک stack فراخوانی دارد، انجام عملیات‌هایی که زمان‌بر هستند (مثل حلقه‌هایی که پردازش سنگینی دارند) می‌تواند کل برنامه را متوقف کند:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function longRunningTask() {
// Simulate a 3-second delay
const start = Date.now();
while (Date.now() - start < 3000) {} // Blocks the stack
console.log('Task done!');
}
longRunningTask(); // Freezes the UI for 3 seconds
console.log('This waits...'); // Executes after the loop
function longRunningTask() { // Simulate a 3-second delay const start = Date.now(); while (Date.now() - start < 3000) {} // Blocks the stack console.log('Task done!'); } longRunningTask(); // Freezes the UI for 3 seconds console.log('This waits...'); // Executes after the loop
function longRunningTask() {
  // Simulate a 3-second delay
  const start = Date.now();
  while (Date.now() - start < 3000) {} // Blocks the stack
  console.log('Task done!');
}

longRunningTask(); // Freezes the UI for 3 seconds
console.log('This waits...'); // Executes after the loop

این محدودیت باعث شده است تا جاوااسکریپت برای جلوگیری از قفل شدن برنامه، به عملیات‌های asynchronous مانند

setTimeout
setTimeout یا
fetch
fetch تکیه کند. این عملیات‌ها توسط APIهای مرورگر، خارج از call stack مدیریت می‌شوند.

Web APIها و Task Queue: گسترش توانایی‌های جاوااسکریپت

در حالی که call stack تنها اجرای synchronous را مدیریت می‌کند، قدرت واقعی جاوااسکریپت در قابلیت اجرای asynchronous آن نهفته است؛ بدون اینکه thread اصلی برنامه مسدود شود. این موضوع با کمک Web APIها و Task Queue، در تعامل با event loop در جاوااسکریپت امکان‌پذیر می‌شود.

نقش Web APIها

Web APIها رابط‌هایی هستند که توسط مرورگر ارائه می‌شوند و خارج از محیط اصلی جاوااسکریپت فعالیت می‌کنند. برخی از مهم‌ترین آن‌ها عبارت‌اند از:

  • تایمرها:
    setTimeout
    setTimeout,
    setInterval
    setInterval
  • درخواست‌های شبکه‌ای:
    fetch
    fetch,
    XMLHttpRequest
    XMLHttpRequest
  • تعامل با DOM:
    addEventListener
    addEventListener,
    click
    click,
    scroll
    scroll
  • دستگاه‌ها و سنسورها: Geolocation, دوربین، نوتیفیکیشن‌ها

این APIها به جاوااسکریپت این امکان را می‌دهند که وظایف زمان‌بر را به محیط multi-threaded مرورگر واگذار کند تا call stack بتواند آزادانه به اجرای سایر کدها ادامه دهد.

تعامل Web API و Task Queue

بیایید عملکرد آن‌ها را با مثالی از

setTimeout بررسی کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, ۱۰۰۰);
console.log('End');
console.log('Start'); setTimeout(() => { console.log('Timeout callback'); }, ۱۰۰۰); console.log('End');
console.log('Start');

setTimeout(() => {  
  console.log('Timeout callback');  
}, ۱۰۰۰);  

console.log('End');

جریان اجرای کد بالا:

۱- call stack:

  • console.log('Start')
    console.log('Start') اجرا و حذف می‌شود.
  • setTimeout()
    setTimeout() اجرا شده و در مرورگر ثبت می‌شود، سپس از پشته خارج می‌شود.
  • console.log('End')
    console.log('End') اجرا و حذف می‌شود.

۲- پس‌زمینه مرورگر:

  • تایمر به مدت ۱۰۰۰ میلی‌ثانیه شمارش می‌کند.

۳- Task Queue:

  • پس از پایان تایمر، تابع
    () => { console.log(...) }
    () => { console.log(...) } در صف قرار می‌گیرد.

۴- Event Loop:

  • زمانی که پشته خالی شد، event loop تابع callback را به پشته منتقل می‌کند
  • console.log('Timeout callback')
    console.log('Timeout callback') اجرا می‌شود:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Start
End
Timeout callback
Start End Timeout callback
Start
End
Timeout callback

نکته مهمی که باید به آن توجه داشته باشیم این است که تأخیر

setTimeout(callback, 1000)
setTimeout(callback, 1000) همیشه به عنوان «حداقل زمان» در نظر گرفته می‌شود. به عبارت دیگر، callback ممکن است بعد از ۱۰۰۰ میلی‌ثانیه اجرا شود، اما نه قبل از آن. اگر پشته درگیر باشد (مثلاً با یک حلقه سنگین)، اجرای callback تا آزاد شدن پشته به تعویق می‌افتد.

مثال دیگری را با استفاده از Geolocation API بررسی می‌کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
console.log('Requesting location...');
navigator.geolocation.getCurrentPosition(
(position) => { console.log(position); }, // Success callback
(error) => { console.error(error); } // Error callback
);
console.log('Waiting for user permission...');
console.log('Requesting location...'); navigator.geolocation.getCurrentPosition( (position) => { console.log(position); }, // Success callback (error) => { console.error(error); } // Error callback ); console.log('Waiting for user permission...');
console.log('Requesting location...');

navigator.geolocation.getCurrentPosition(  
  (position) => { console.log(position); }, // Success callback  
  (error) => { console.error(error); }     // Error callback  
);

console.log('Waiting for user permission...');

در این مثال،

getCurrentPosition
getCurrentPosition دو callback را در API مکان‌یاب مرورگر ثبت می‌کند. مرورگر فرآیند گرفتن اجازه کاربر و دریافت موقعیت را انجام می‌دهد. پس از دریافت پاسخ کاربر، callback مربوطه به task queue اضافه می‌شود. سپس event loop آن را به پشته منتقل کرده و اجرا می‌کند:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Requesting location...
Waiting for user permission...
{ coords: ... } // After user grants permission
Requesting location... Waiting for user permission... { coords: ... } // After user grants permission
Requesting location...  
Waiting for user permission...  
{ coords: ... } // After user grants permission

بدون Web APIها و task queue، جاوااسکریپت قادر نبود در طول عملیات‌هایی مانند درخواست شبکه، تایمرها یا تعامل با کاربر، پشته را آزاد نگه دارد. این معماری asynchronous، اساس روان بودن تجربه کار با جاوااسکریپت است.

Microtask queue و event loop: اولویت‌بندی Promiseها

در حالی که task queue برای مدیریت callbackهای مبتنی بر APIهایی مثل

[setTimeout]
[setTimeout] به کار می‌رود، ویژگی‌های مدرن‌تر جاوااسکریپت مثل Promiseها و
async/await
async/await از microtask queue استفاده می‌کنند.
درک نحوه اولویت‌بندی این صف توسط event loop، برای تسلط بر ترتیب اجرای کد در جاوااسکریپت بسیار مهم است.

microtask queue چیست؟

microtask queue، یک صف ویژه است برای:

  • واکنش‌های Promise: توابع
    .then()
    .then(),
    .catch()
    .catch(),
    .finally()
    .finally()
  • تابع
    queueMicrotask()
    queueMicrotask(): برای افزودن مستقیم microtaskها
  • async/await
    async/await: عملیات بعد از
    await
    await در توابع async
  • callbackهای MutationObserver : برای ردیابی تغییرات DOM

برخلافtask queue، صف microtask اولویت بالاتری دارد. event loop در جاوااسکریپت همیشه تمام microtaskها را قبل از سر زدن به task queue اجرا می‌کند.

ترتیب دقیق اجرای event loop

event loop در جاوااسکریپت همیشه طبق این ترتیب پیش می‌رود:

  1. اجرای تمام کدهای داخل پشته (call stack)
  2. تخلیه کامل صف microtask (تا زمانی که خالی شود)
  3. اعمال آپدیت‌های UI (در صورت وجود)
  4. اجرای یک تسک از task queue

سپس این فرآیند به‌صورت پیوسته تکرار می‌شود.
این طراحی باعث می‌شود که کدهای Promise محور هر چه سریع‌تر اجرا شوند، حتی اگر تسک‌هایی زودتر در task queue ثبت شده باشند.

مقایسه عملی microtask و task

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
console.log('Start');
// Task (setTimeout)
setTimeout(() => console.log('Timeout'), 0);
// Microtask (Promise)
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
console.log('Start'); // Task (setTimeout) setTimeout(() => console.log('Timeout'), 0); // Microtask (Promise) Promise.resolve().then(() => console.log('Promise')); console.log('End');
console.log('Start');

// Task (setTimeout)
setTimeout(() => console.log('Timeout'), 0);

// Microtask (Promise)
Promise.resolve().then(() => console.log('Promise'));

console.log('End');

خروجی کد به صورت زیر خواهد بود:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Start
End
Promise
Timeout
Start End Promise Timeout
Start  
End  
Promise  
Timeout

تحلیل ترتیب اجرا:

  1. console.log('Start')
    console.log('Start') اجرا می‌شود.
  2. setTimeout
    setTimeout callback خود را در task queue ثبت می‌کند.
  3. Promise.resolve().then()
    Promise.resolve().then() callback خود را در microtask queue ثبت می‌کند.
  4. console.log('End')
    console.log('End') اجرا می‌شود.
  5. event loop متوجه خالی بودن پشته می‌شود:
    • اول microtaskها اجرا می‌شوند ->
      Promise
      Promise چاپ می‌شود
    • سپس task بعدی از task queue اجرا می‌شود ->
      Timeout
      Timeout چاپ می‌شود

یک نکته مهم درباره microtaskها

قبل از ادامه، بهتر است به یک دام رایج اشاره کنیم:
microtaskها می‌توانند microtaskهای جدیدی ثبت کنند. اگر این کار به صورت بازگشتی انجام شود، ممکن است event loop هیچ‌وقت به تسک‌های دیگر نرسد و برنامه عملاً قفل شود. به عنوان مثال:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('Microtask!');
recursiveMicrotask(); // Infinite loop
});
}
recursiveMicrotask();
function recursiveMicrotask() { Promise.resolve().then(() => { console.log('Microtask!'); recursiveMicrotask(); // Infinite loop }); } recursiveMicrotask();
function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('Microtask!');
    recursiveMicrotask(); // Infinite loop
  });
}

recursiveMicrotask();

کد بالا باعث می‌شود صف microtaskها هیچ‌وقت خالی نشود. در نتیجه، event loop به هیچ‌کدام از تسک‌ها یا آپدیت‌های UI نخواهد رسید.
برای حل این مشکل، باید از

setTimeout
setTimeout استفاده کنیم تا تسک را به task queue منتقل کنیم و جلوی قفل شدن را بگیریم.

async/await و Microtaskها

ساختار

async/await در واقع شکلی ساده شده از Promiseها است. کدی که پس از
await
await اجرا می‌شود، به صورت خودکار در صف microtaskها قرار می‌گیرد:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
async function fetchData() {
console.log('Fetching...');
const response = await fetch('/data'); // Pauses here
console.log('Data received'); // Queued as microtask
}
fetchData();
console.log('Script continues');
async function fetchData() { console.log('Fetching...'); const response = await fetch('/data'); // Pauses here console.log('Data received'); // Queued as microtask } fetchData(); console.log('Script continues');
async function fetchData() {
  console.log('Fetching...');
  const response = await fetch('/data'); // Pauses here
  console.log('Data received'); // Queued as microtask
}

fetchData();
console.log('Script continues');

خروجی به شکل زیر خواهد بود:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Fetching...
Script continues
Data received
Fetching... Script continues Data received
Fetching...  
Script continues  
Data received

در این مثال، عبارت

console.log('Data received')
console.log('Data received') تا زمانی که Promise بازگشتی از fetch حل نشود و صف microtaskها اجرا نگردد، به اجرا درنمی‌آید. این ویژگی سبب می‌شود تا کدهای promise-based در سریع‌ترین زمان ممکن اجرا شوند، حتی اگر taskهایی از پیش در صف وجود داشته باشند.

Web Workerها: برون‌سپاری تسک‌های پردازشی سنگین

مدل single-threaded جاوااسکریپت با وجود سادگی، در مواجهه با عملیات سنگین پردازشی، مانند پردازش تصویر یا محاسبات پیچیده ریاضی، ممکن است باعث کندی یا قفل شدن رابط کاربری (UI) شود. این امر می‌تواند تجربه کاربری را تحت‌تأثیر قرار دهد. Web Workerها به منظور حل این مسئله طراحی شده‌اند و امکان اجرای کد در threadهای جداگانه و در پس‌زمینه را فراهم می‌کنند، در حالی که thread اصلی آزاد است تا به تعامل با کاربر و مدیریت DOM ادامه دهد.

نحوه عملکرد Web Workerها

Workerها در محیطی ایزوله با فضای حافظه جداگانه اجرا می‌شوند و دسترسی مستقیم به window یا DOM ندارند. این معماری تضمین‌کننده ایمنی هم‌زمانی است. ارتباط میان thread اصلی و Workerها صرفاً از طریق ارسال و دریافت پیام انجام می‌شود. داده‌ها یا به‌صورت کپی‌شده (از طریق structured cloning) یا به‌صورت منتقل‌شده (با استفاده از  آبجکت‌های

Transferable
Transferable) رد و بدل می‌شوند تا از مشکلات مربوط به حافظه مشترک جلوگیری شود.

نمونه کد: واگذاری یک عملیات سنگین به Web Worker

در قطعه کد زیر، نمونه‌ای از واگذاری یک تسک پردازشی پیچیده به یک Web Worker نمایش داده شده است. در این مثال، فرض بر این است که پردازش تصویر حجم بالایی از منابع محاسباتی را مصرف می‌کند. متد

worker.postMessage
worker.postMessage وظیفه دارد پیامی حاوی داده‌های لازم را برای پردازش به Worker ارسال کند. سپس با استفاده از دو handler
worker.onmessage
worker.onmessage و
worker.onerror
worker.onerror، به ترتیب، نتایج موفق یا خطاهای احتمالی اجرای پردازش در پس‌زمینه مدیریت می‌شوند.

در thread اصلی:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Create a worker and send data
const worker = new Worker('worker.js');
worker.postMessage({ task: 'processImage', imageData: rawPixels });
// Listen for results or errors
worker.onmessage = (event) => {
displayProcessedImage(event.data); // Handle result
};
worker.onerror = (error) => {
console.error('Worker error:', error); // Handle failures
};
// Create a worker and send data const worker = new Worker('worker.js'); worker.postMessage({ task: 'processImage', imageData: rawPixels }); // Listen for results or errors worker.onmessage = (event) => { displayProcessedImage(event.data); // Handle result }; worker.onerror = (error) => { console.error('Worker error:', error); // Handle failures };
// Create a worker and send data
const worker = new Worker('worker.js');
worker.postMessage({ task: 'processImage', imageData: rawPixels }); 

// Listen for results or errors
worker.onmessage = (event) => {
  displayProcessedImage(event.data); // Handle result
};

worker.onerror = (error) => {
  console.error('Worker error:', error); // Handle failures
};

در این کد، متد

onmessage
onmessage زمانی فراخوانی می‌شود که Worker پیامی را پس از اتمام پردازش ارسال کند. داده‌ای که در ابتدای کار با
postMessage
postMessage ارسال شده بود (در اینجا
rawPixel
rawPixel)، در سمت Worker از طریق ویژگی
data
data آبجکت
event
event قابل دسترسی است.

درون فایل worker.js:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Receive and process data
self.onmessage = (event) => {
const processedData = heavyComputation(event.data.imageData);
self.postMessage(processedData); // Return result
};
// Receive and process data self.onmessage = (event) => { const processedData = heavyComputation(event.data.imageData); self.postMessage(processedData); // Return result };
// Receive and process data
self.onmessage = (event) => {
  const processedData = heavyComputation(event.data.imageData); 
  self.postMessage(processedData); // Return result
};

باید به توجه داشته باشیم که در محیط Worker، به جای window از

self
self استفاده می‌شود، چرا که Workerها در یک scope سراسری مستقل اجرا می‌گردند و به DOM یا آبجکت‌های مرتبط با رابط کاربری دسترسی ندارند.

برای ارسال داده‌های حجیم مانند تصاویر، بهتر است از آبجکت‌های

Transferable
Transferable (مثل
ArrayBuffer
ArrayBuffer) استفاده شود تا از هزینه‌های بالای کپی‌برداری جلوگیری گردد. همچنین، ایجاد تعداد زیادی Worker به‌صورت هم‌زمان می‌تواند باعث مصرف زیاد حافظه شود؛ بنابراین پیشنهاد می‌شود برای وظایف تکرارشونده از Workerهای موجود، مجدداً استفاده کنیم.

جمع‌بندی

توانمندی‌های asynchronous جاوااسکریپت، از طریق معماری دقیق آن شامل:

  1. Call Stack
  2. Web APIها
  3. و Event Loop

امکان اجرای غیرمسدود کننده را، حتی در یک محیط single-threaded فراهم کرده است.

با بهره‌گیری از task queue برای عملیات‌های مبتنی بر callback و اولویت‌بخشی به صف microtask برای Promiseها، جاوااسکریپت چارچوبی بهینه برای مدیریت فرآیندهای غیرهمزمان در اختیار توسعه‌دهندگان قرار می‌دهد.

دیدگاه‌ها:

افزودن دیدگاه جدید