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

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

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

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

Call Stack چیست؟

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

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

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

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
>

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

در این تحلیل:

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

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

و مرحله ۳: logThreeAndFour() فراخوانی می‌شود:

در نهایت، مرحله ۴: console.log('Four') وارد پشته می‌شود:

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

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

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

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 یا fetch تکیه کند. این عملیات‌ها توسط APIهای مرورگر، خارج از call stack مدیریت می‌شوند.

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

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

نقش Web APIها

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

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

تعامل Web API و Task Queue

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

console.log('Start');

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

console.log('End');

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

۱- call stack:

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

۳- Task Queue:

۴- Event Loop:

Start
End
Timeout callback

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

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

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

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] به کار می‌رود، ویژگی‌های مدرن‌تر جاوااسکریپت مثل Promiseها و async/await از microtask queue استفاده می‌کنند.
درک نحوه اولویت‌بندی این صف توسط event loop، برای تسلط بر ترتیب اجرای کد در جاوااسکریپت بسیار مهم است.

microtask queue چیست؟

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

برخلاف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

console.log('Start');

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

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

console.log('End');

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

Start  
End  
Promise  
Timeout

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

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

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

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

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('Microtask!');
    recursiveMicrotask(); // Infinite loop
  });
}

recursiveMicrotask();

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

async/await و Microtaskها

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

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');

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

Fetching...  
Script continues  
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) رد و بدل می‌شوند تا از مشکلات مربوط به حافظه مشترک جلوگیری شود.

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

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

در thread اصلی:

// 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 زمانی فراخوانی می‌شود که Worker پیامی را پس از اتمام پردازش ارسال کند. داده‌ای که در ابتدای کار با postMessage ارسال شده بود (در اینجا rawPixel)، در سمت Worker از طریق ویژگی data آبجکت event قابل دسترسی است.

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

// Receive and process data
self.onmessage = (event) => {
  const processedData = heavyComputation(event.data.imageData); 
  self.postMessage(processedData); // Return result
};

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

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

جمع‌بندی

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

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

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

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