چطور جاوا اسکریپت با وجود single-thread بودن میتواند "هم زمان" با ارسال request هم برای شما یک انیمیشن لودینگ نمایش دهد و هم کلی کار دیگه انجام بدهد؟
برای یافتن جواب این سوال اول باید کمی به عقب برگردیم و ببینیم IO چیست ؟
کلمه IO مخفف شده input/output است و اگر میخواهید بدانید برنامه شما I/O-bound یا CPU-bound هست میتوانید با این سوال شروع کنید که با افزایش کدام دسته از منابع عملکرد برنامه شما بهتر میشود، بالا بردن قدرت CPU یا بیشتر کردن حافظه رم و سرعت هارد دیسک، کارت شبکه
به طور مثال در Node.js برنامه از نوع I/O-bound است و با افزایش سرعت هارد دیسک عملکرد کلی برنامه بهتر میشود و در مقابل سرویس رمزنگاری SHA-1 (تابع درهم سازی در مقوله رمزنگاری است که یک ورودی می گیرد و یک مقدار درهم ۱۶۰ بیتی ( ۲۰ بایت ) تولید می کند) از نوع CPU-bound هست و با بالا بردن قدرت CPU زمان رمزنگاری کاهش می یابد.
تقریبا تمام برنامههایی که با جاوا اسکریپت نوشته میشوند به نحوی با عملیات IO مثل فرستادن request به سرور، هندل کردن input کاربر و … سروکار دارند و همچنین میدانیم که عملیات IO معمولا نسبت به سرعت پردازش CPU، بسیار کند هستند و به زمان بیشتری برای کامل شدن احتیاج دارند.
دو روش کلی برای انجام دادن IO وجود دارد:
1. Blocking IO
در این روش وقتی یک request به سرور میفرستیم (یا هر عملیات IO دیگر) اجرای برنامه متوقف میشود و تا زمانی که سرور به request پاسخ ندهد thread که کد روی آن اجرا میشود block میشود.
در بعضی زبانها برای اینکه بتوانیم از blocking IO استفاده کنیم و درعین حال کل برنامه freeze نشود، میتوانیم برای هر عملیات IO یک thread جدید ایجاد کنیم تا بقیه پردازشهای برنامه در یک thread مجزا ادامه پیدا کنند. اما با توجه به اینکه جاوا اسکریپت فقط بر روی یک thread اجرا میشود امکان استفاده از این روش داخل جاوا اسکریپت را نداریم.
2. Non-Blocking IO
در این روش بعد از فرستادن request به سرور صبر نمیکنیم تا request تکمیل شود و CPU بلافاصله میتواند بقیه پردازشهای برنامه را انجام دهد ( non-blocking IO فقط یک مثال از asynchrony است)
حالا که مشخص شد جاوا اسکریپت جزء برنامههای I/O-bound هست و از سیستم پاسخدهی Non-Blocking استفاده میکند برویم سراغ دو برادر ناتنی به نامهای Parallelism و Concurrency ?????
در parallelism همانطور که از اسم آن مشخص است چند تسک به صورت موازی و دقیقا در یک لحظه پردازش میشوند. برای پردازش parallel معمولا از چند thread (یا process) برای اجرای عملیات مختلف استفاده میشود. بنابراین با توجه به single-thread بودن جاوا اسکریپت عملا نمیتوانیم دو کار را دقیقا در یک لحظه انجام دهیم.
همچنین Concurrency به این معنی است که چند تسک مختلف در یک بازه زمانی انجام شوند (اما نه لزوما هم زمان و در یک لحظه) در واقع concurrency مفهوم کلیتری نسبت به parallelism است و میتوانیم بدون پردازش parallel هم، concurrency داشته باشیم.
به عنوان مثال فرض کنید یک پروژه داریم که بخشهای مختلفی دارد و اگر چند تیم بر روی بخشهای مختلف این پروژه کار کنند بخشهای مختلف پروژه به صورت هم زمان و parallel پیشرفت میکند اما اگر فقط یک نفر روی این پروژه کار کند در یک لحظه فقط بر روی یک بخش از پروژه کار میشود و بخشهای مختلف به صورت هم زمان پیشرفت نمیکنند. اما همچنان پروژه به صورت concurrent انجام میشود. چرا که بخشهای مختلف پروژه در یک بازه زمانی با هم پیشرفت میکنند هر چند دقیقا در یک لحظه بر روی همه بخشها کار نمیشود.
حالا که متوجه شدیم فقط با استفاده از یک thread هم میتوانیم بیش از یک کار را در یک بازه زمانی انجام دهیم بیایید نگاهی به دو روش کلی multitasking در نرمافزار داشته باشیم و در نهایت ببینیم کارکرد جاوا اسکریپت به کدام یکی از این دو روش نزدیکتر است.
1. Preemptive Multitasking
در این روش معمولا برای پردازش تسکهای مختلف، چند thread ایجاد میشود و اگر اجرای یک thread بیش از حد زمان بگیرد سیستم عامل میتواند اجرای آن را متوقف کند در کل این threadها توسط سیستم عامل مدیریت میشوند و حتی ممکن است به صورت موازی (parallel) اجرا شوند.
2. Cooperative Multitasking
در این روش کل برنامه از دید سیستم عامل فقط بر روی یک thread اجرا میشود اما تسکهای مختلف در داخل خود برنامه مدیریت میشوند به این صورت که در هر لحظه یک تسک اجرا میشود و مقداری پردازش روی CPU انجام میدهد سپس CPU را آزاد میکند (اصطلاحا yield میکند) تا تسک بعدی اجرا شود و به این ترتیب تسکهای مختلف از یک thread به صورت اشتراکی استفاده میکنند.
به این روش cooperative گفته میشود چون برای کارکرد درست کل سیستم همه تسکها باید با هم همکاری کنند و اگر یک تسک پردازش طولانی روی CPU انجام دهد کل برنامه کند میشود که در این روش مدیریت تسکهای مختلف بر عهده برنامه نویس است و عملکرد جاوا اسکریپت به شدت شبیه به همین مدل است و تسکهای concurrent به صورت اشتراکی از یک thread استفاده میکنند و ما به عنوان برنامه نویس باید این تسکها را به درستی مدیریت کنیم و اگر یک تابع، پردازش طولانی روی CPU انجام میدهد، باید آن را به چند تسک کوچکتر تقسیم کنیم تا کل برنامه کند نشود.
تا اینجای کار متوجه شدیم که جاوا اسکریپت چندین تسک را در یک بازه زمانی بر روی یک thread پردازش میکند و در هر لحظه فقط یک تسک را انجام میدهد.
برنامه ما برای اجرا شدن نیاز به host environment دارد که می تواند مرورگر، Node.js ، Deno ، electron.js و ... باشد تا با کمک آن موارد زیر فراهم بشود
1. Call Stack (V8)
2. Heap (V8)
3. Task queue (macrotask queue and microtask queue)
4. Event loop
5. Web API and Web DOM
جاوا اسکریپت هم مثل خیلی از زبانهای دیگر برای مشخص کردن اینکه در هر لحظه چه تابعی در حال اجرا است از ساختمان داده Stack استفاده میکند به این صورت که با صدا شدن هر تابع، یک frame جدید به بالای call stack اضافه میشود (push) و با بازگشت از هر تابع، یک frame از بالای call stack حذف میشود (pop) و نکته مهم اینکه هر تسکی که وارد call stack میشود باید تا انتها اجرا شود تا تسک بعدی بتواند اجرا شود بنابراین اگر میخواهیم یک پردازش طولانی انجام دهیم، مثلا اگر بخواهیم روی یک آرایه خیلی طولانی map بزنیم باید این کار را به چند تسک کوچک تر تبدیل کنیم و در چند مرحله انجام دهیم یا اینکه تسک مورد نظر را به یک web worker منتقل کنیم.
وقتی از یکی از APIهای async مثل timeout استفاده میکنیم در واقع عملیات مورد نظر داخل hosting environment اتفاق میافتد و نه داخل انجین جاوا اسکریپت، مثلا اگر که داخل کد یک timeout با زمان ۲ثانیه و به همراه یک callback تعریف کنیم سپس این timer داخل مرورگر یا Node.js ایجاد میشود و کد جاوا اسکریپت میتواند به صورت Non-Blocking به اجرا شدن ادامه دهد اما بعد از حدود ۲ثانیه، مرورگر callback را به عنوان یک تسک داخل لیستی به نام Task Queue قرار میدهد تا بعدتر این تسکها داخل انجین جاوا اسکریپت اجرا شوند.
به این ترتیب APIهای مختلف بعد از اتمام کارشان یک تسک به این صف از تسکها اضافه میکنند تا بعدتر، این تسکها به نوبت داخل call stack اجرا شوند اما چه مکانیزمی این تسکها را از task queue به انجین جاوااسکریپت و call stack منتقل میکند ?
احتمالا تا الان متوجه شدهاید که Event Loop پل ارتباطی بین hosting environment و انجین جاوا اسکریپت است و مشاهده کردیم که عملیات async (مثل ارسال درخواست به سرور و استفاده از تایمر) داخل hosting environment انجام میشوند و بعد از اتمام عملیات، یک تسک به task queue اضافه میکند.
پس event loop بین این تسکها و انجین جاوا اسکریپت ارتباط برقرار میکند. در واقع event loop یک پروسه است که دائما اجرا میشود و هر بار چک میکند که اگر call stack خالی باشد، قدیمیترین تسک داخل task queue را اجرا میکند (به call stack منتقل میکند)، همچنین به هر iteration از Event Loop یک tick گفته میشود.
قسمت call stack به یک فضای پیوسته (پشت سرهم) تو حافظه رم نیاز داره که این موضوع پردازش رو خیلی سریع میکنه اما این مدل فضا رو خیلی کم و سخت میتونی تو حافظه پیدا کنی برای همین مقادیر references و primitive data types در آن ذخیره میشن ولی مقادیر (objects, functions, array) non-primitive data types در heap قرار می گیرند که تمام این تصمیم گیری ها مربوط به نحوه مدیریت حافظه در جاوا اسکریپت هست.
کدهایی که به صورت sync هستن وارد task queue میشوند و فقط کدهای setTimeout , setInterval و event handler ها به داخل macrotask queue ریخته میشوند اما پرامیس ها یا همون کال بک هایی که داخل then مینویسیم به محض مشخص شدن وضعیت پرامیس از web api به داخل یه صف جدا و خاص به اسم microtask queue ریخته میشوند.
اگر در کد با استفاده از setTimeout یک تسک به انتهای task queue اضافه کنیم و به محض اجرا شدن این تسک دوباره با استفاده از setTimeout یک تسک دیگر به انتهای task queue اضافه کنیم و این روند به صورت مداوم ادامه دهیم این کد باعث freeze شدن مرورگر نمیشود چرا که هر بار مرورگر این تسکها را به انتهای صف اضافه میکند و به این ترتیب تسکهای دیگر هم فرصت اجرا شدن پیدا میکنند اما اگر دوست دارید freeze شدن مرورگرتان را ببینید میتوانید این کد را بعد از خواندن مقاله در تب console اجرا کنید?
function applyBrowserFreeze() { Promise.resolve().then(() => { applyBrowserFreeze(); console.log("apply browser Freeze"); }); } applyBrowserFreeze();
اما گاهی میخواهیم این تسکها async باشن و به انتهای صف اضافه نشوند و دقیقا بعد از تسکی که الان در حال اجراست، اجرا شوند پس سراغ Microtask می رویم، بیشترین کاربرد microtaskها، در Promiseهای جاوا اسکریپت است.
در واقع هر تسک زمانی که در حال اجرا شدن است میتواند چندین microtask ایجاد کند و این microtaskها در یک صف دیگر به نام microtask queue قرار میگیرند و دقیقا بعد از تمام شدن تسکی که در حال اجراست (و قبل از اجرای تسک بعدی)، همهی این microtaskها به ترتیب اجرا میشوند. یعنی microtaskها عملیات async هستند که بعد از هر تسک اجرا میشوند (و به انتهای صف تسکها منتقل نمیشوند) و اولویت با microtaskها است و بعد macrotaskها پس حالا اولویت event loop برای انجام دقیقا به این ترتیب هست:
اول call stack هستش که کد های sync داخلش ریخته میشوند
دوم microtask queue هستش که کال بک های پرامیس داخل آن ریخته میشوند
سوم macrotask queue یا callback queue هستش که کال بک های زمان بندی شده در آن وجود دارند
?? برای درک بهتر کل فرایند میتوانید وارد این لینک شده و کد بنویسید و اجرای آن را توسط event loop مشاهده کنید و همچنین برای دقیق تر شدن در تفاوت و اولویت بندی های Microtask و Macrotask ها می توانید از این لینک استفاده کنید و با کد نوشتن به صورت عملی آنها را تجربه کنید.