جاوااسکریپت با وجود آنکه یک زبان single-threaded است، توانایی انجام عملیاتهای asynchronous مانند درخواستهای شبکه یا اجرای تایمرها را دارد؛ بدون آنکه باعث توقف یا فریز شدن برنامه شود. این قابلیت، نتیجه معماری زمان اجرای جاوااسکریپت و به ویژه نقش کلیدی event loop در جاوااسکریپت است؛ سازوکاری که با همکاری اجزایی همچون Call Stack، وب APIها و Task Queueها، اجرای روان و بدون وقفهی کدها را ممکن میسازد.
در این مقاله، بررسی میکنیم که چطور جاوااسکریپت با وجود single-threaded بودن، میتواند چنین عملکرد پیچیدهای داشته باشد. همچنین به بررسی نحوه تعامل بین Call Stack، Event Loop و صفهای مختلف نیز خواهیم پرداخت.
از آنجایی که جاوااسکریپت تنها میتواند یک کار را در یک زمان اجرا کند، باید راهی برای پیگیری این که چه چیزی در حال اجراست، کار بعدی چیست و برنامه پس از هر توقف از کجا باید ادامه یابد، وجود داشته باشد. این وظیفه را 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')
وارد پشته میشود:
[main(), console.log('One')]
'One'
چاپ میشود، از پشته خارج میگردد.مرحله ۲: console.log('Two')
وارد پشته میشود:
[main(), console.log('Two')]
'Two'
اجرا شده، از پشته حذف میشود.و مرحله ۳:
logThreeAndFour()
فراخوانی میشود:
[main(), logThreeAndFour()]
logThreeAndFour()
، تابع logThree()
فراخوانی میشود.
[main(), logThreeAndFour(), logThree()]
logThree()
، تابع console.log('Three')
را فراخوانی میکند.
[main(), logThreeAndFour(), logThree(), console.log('Three')]
'Three'
اجرا میشود، از پشته خارج میگردد.[main(), logThreeAndFour(), logThree()]
، سپس logThree()
از پشته خارج میشود.در نهایت، مرحله ۴:
console.log('Four')
وارد پشته میشود:
[main(), logThreeAndFour(), console.log('Four')]
'Four'
اجرا شده، از پشته حذف میشود.[main(), logThreeAndFour()]
، سپس logThreeAndFour()
از پشته خارج میشود.در پایان، پشته خالی میشود و برنامه خاتمه مییابد.
از آنجایی که جاوااسکریپت تنها یک 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 مدیریت میشوند.
در حالی که call stack تنها اجرای synchronous را مدیریت میکند، قدرت واقعی جاوااسکریپت در قابلیت اجرای asynchronous آن نهفته است؛ بدون اینکه thread اصلی برنامه مسدود شود. این موضوع با کمک Web APIها و Task Queue، در تعامل با event loop در جاوااسکریپت امکانپذیر میشود.
Web APIها رابطهایی هستند که توسط مرورگر ارائه میشوند و خارج از محیط اصلی جاوااسکریپت فعالیت میکنند. برخی از مهمترین آنها عبارتاند از:
setTimeout
, setInterval
fetch
, XMLHttpRequest
addEventListener
, click
, scroll
این APIها به جاوااسکریپت این امکان را میدهند که وظایف زمانبر را به محیط multi-threaded مرورگر واگذار کند تا call stack بتواند آزادانه به اجرای سایر کدها ادامه دهد.
بیایید عملکرد آنها را با مثالی از setTimeout
بررسی کنیم:
console.log('Start'); setTimeout(() => { console.log('Timeout callback'); }, ۱۰۰۰); console.log('End');
۱- call stack:
console.log('Start')
اجرا و حذف میشود.setTimeout()
اجرا شده و در مرورگر ثبت میشود، سپس از پشته خارج میشود.console.log('End')
اجرا و حذف میشود.۲- پسزمینه مرورگر:
۳- Task Queue:
() => { console.log(...) }
در صف قرار میگیرد.۴- Event Loop:
console.log('Timeout callback')
اجرا میشود: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، اساس روان بودن تجربه کار با جاوااسکریپت است.
در حالی که task queue برای مدیریت callbackهای مبتنی بر APIهایی مثل
[setTimeout]
به کار میرود، ویژگیهای مدرنتر جاوااسکریپت مثل Promiseها و async/await
از microtask queue استفاده میکنند.microtask queue، یک صف ویژه است برای:
.then()
, .catch()
, .finally()
queueMicrotask()
: برای افزودن مستقیم microtaskهاasync/await
: عملیات بعد از await
در توابع asyncبرخلافtask queue، صف microtask اولویت بالاتری دارد. event loop در جاوااسکریپت همیشه تمام microtaskها را قبل از سر زدن به task queue اجرا میکند.
event loop در جاوااسکریپت همیشه طبق این ترتیب پیش میرود:
سپس این فرآیند بهصورت پیوسته تکرار میشود.
این طراحی باعث میشود که کدهای 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
console.log('Start')
اجرا میشود.setTimeout
callback خود را در task queue ثبت میکند.Promise.resolve().then()
callback خود را در microtask queue ثبت میکند.console.log('End')
اجرا میشود.Promise
چاپ میشودTimeout
چاپ میشودقبل از ادامه، بهتر است به یک دام رایج اشاره کنیم:
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
در واقع شکلی ساده شده از 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هایی از پیش در صف وجود داشته باشند.
مدل single-threaded جاوااسکریپت با وجود سادگی، در مواجهه با عملیات سنگین پردازشی، مانند پردازش تصویر یا محاسبات پیچیده ریاضی، ممکن است باعث کندی یا قفل شدن رابط کاربری (UI) شود. این امر میتواند تجربه کاربری را تحتتأثیر قرار دهد. Web Workerها به منظور حل این مسئله طراحی شدهاند و امکان اجرای کد در threadهای جداگانه و در پسزمینه را فراهم میکنند، در حالی که thread اصلی آزاد است تا به تعامل با کاربر و مدیریت DOM ادامه دهد.
Workerها در محیطی ایزوله با فضای حافظه جداگانه اجرا میشوند و دسترسی مستقیم به window یا DOM ندارند. این معماری تضمینکننده ایمنی همزمانی است. ارتباط میان thread اصلی و Workerها صرفاً از طریق ارسال و دریافت پیام انجام میشود. دادهها یا بهصورت کپیشده (از طریق structured cloning) یا بهصورت منتقلشده (با استفاده از آبجکتهای
Transferable
) رد و بدل میشوند تا از مشکلات مربوط به حافظه مشترک جلوگیری شود.
در قطعه کد زیر، نمونهای از واگذاری یک تسک پردازشی پیچیده به یک 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 جاوااسکریپت، از طریق معماری دقیق آن شامل:
امکان اجرای غیرمسدود کننده را، حتی در یک محیط single-threaded فراهم کرده است.
با بهرهگیری از task queue برای عملیاتهای مبتنی بر callback و اولویتبخشی به صف microtask برای Promiseها، جاوااسکریپت چارچوبی بهینه برای مدیریت فرآیندهای غیرهمزمان در اختیار توسعهدهندگان قرار میدهد.
دیدگاهها: