در قسمت قبلی با مفاهیم و اصطلاحات مرتبط با برنامهنویسی async آشنا شدیم و روشهای مختلف پیادهسازی multitasking در نرمافزار را بررسی کردیم.
همچنین دیدیم که در جاوااسکریپت تسکهای مختلف به صورت نوبتی و اشتراکی از یک thread استفاده میکنند. به این ترتیب با اینکه انجین جاوااسکریپت در هر لحظه فقط یک تسک را پردازش میکند، میتوانیم در یک بازهی زمانی چندین کار انجام دهیم (cooperative concurrency).
حالا در این قسمت میخواهیم ببینیم این مفاهیم به چه صورتی در جاوااسکریپت پیادهسازی شدهاند و با مفهوم مهم Event Loop آشنا شویم:
عملیات asyncی که در جاوااسکریپت استفاده میکنیم در واقع حاصل همکاری انجین جاوااسکریپت و محیطی است که جاوااسکریپت در آن اجرا میشود (مثل Node یا مرورگر):
در ادامه این مکانیزمها و همکاری بین آنها را بررسی میکنیم:
جاوااسکریپت هم مثل خیلی از زبانهای دیگر، برای مشخص کردن اینکه در هر لحظه چه تابعی در حال اجرا است، از ساختمان دادهی Stack استفاده میکند.
به این صورت که با صدا شدن هر تابع، یک frame جدید به بالای call stack اضافه میشود (push). و با بازگشت از هر تابع، یک frame از بالای call stack حذف میشود (pop) (تصویر ۱).
برای مثال اجرای این کد را از نظر call stack در نظر بگیرید:
با اجرای این کد ابتدا تابع foo به call stack اضافه میشود و thread اصلی جاوااسکریپت شروع به پردازش این تابع میکند.
داخل تابع foo یک timeout با مقدار ۲ثانیه تعریف میکنیم و بلافاصله تابع bar را صدا میکنیم (Non-Blocking IO).
سپس تابع bar به بالای call stack اضافه میشود و با اجرای کامل تابع bar، این تابع هم از بالای call stack حذف میشود.
در نهایت ادامهی تابع foo هم اجرا میشود و call stack خالی میشود.
اما بعد از حدود ۲ثانیه، تابع callbackی که برای timeout تعریف کردیم، داخل call stack ظاهر و اجرا میشود (تصویر ۲)!
حالا سوال اینجاست که این تابع callback از کجا وارد call stack شد؟!
وقتی از یکی از APIهای async مثل timeout استفاده میکنیم، در واقع عملیات مورد نظر داخل hosting environment اتفاق میافتد (و نه داخل انجین جاوااسکریپت).
به این صورت که داخل کد یک timeout با زمان ۲ثانیه و به همراه یک callback تعریف میکنیم. سپس این timer داخل مرورگر (یا node) ایجاد میشود و کد جاوااسکریپت میتواند به صورت 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داخل HTML specification تعریف شدهاند و نه ECMAScript specification!)
در واقع event loop یک پروسه است که دائما اجرا میشود و هر بار چک میکند که اگر call stack خالی باشد، قدیمیترین تسک داخل task queue را اجرا میکند (به call stack منتقل میکند).
همچنین به هر iteration از Event Loop یک tick گفته میشود.
کد زیر را در نظر بگیرید:
در این کد با استفاده از setTimeout یک تسک به انتهای task queue اضافه میکنیم. اما به محض اجرا شدن این تسک، دوباره با استفاده از setTimeout یک تسک دیگر به انتهای task queue اضافه میکنیم و این روند به صورت مداوم ادامه پیدا میکند.
اما طبق مفاهیمی که تا اینجا یاد گرفتیم، این کد باعث freeze شدن مرورگر نمیشود!
چرا که هر بار، مرورگر این تسکها را به انتهای صف اضافه میکند. به این ترتیب تسکهای دیگر (مثل رندر شدن صفحه، هندل کردن input کاربر و ...) هم فرصت اجرا شدن پیدا میکنند.
اما گاهی میخواهیم این تسکهای async، به انتهای صف اضافه نشوند و دقیقا بعد از تسکی که الان در حال اجراست، اجرا شوند. در این مواقع از Microtaskها استفاده میکنیم.
بیشترین کاربرد microtaskها، در Promiseهای جاوااسکریپت است (که در قسمتهای بعدی بیشتر بررسی میکنیم) :
در واقع هر تسک، زمانی که در حال اجرا شدن است، میتواند چندین microtask ایجاد کند.
این microtaskها در یک صف دیگر به نام microtask queue قرار میگیرند. و دقیقا بعد از تمام شدن تسکی که در حال اجراست (و قبل از اجرای تسک بعدی)، همهی این microtaskها به ترتیب اجرا میشوند. یعنی microtaskها عملیات asyncی هستند که بعد از هر تسک اجرا میشوند (و به انتهای صف تسکها منتقل نمیشوند).
مثل اینکه یک نفر جلوی صف نان باشد، و زمانی که در حال گرفتن نان است، یک یا چند دیگر را هم پشت خودش (یعنی جلوی بقیه) قرار دهد!
طبیعتا اگر خود این افراد جدید هم این کار را تکرار کنند، به هیچ کدام از افراد دیگر داخل صف نان نمیرسد.
این موضوع برای microtaskها هم صادق است. یعنی خود microtaskها هم میتوانند microtaskهای جدیدی به انتهای Microtask Queue اضافه کنند (مثل یک زنجیره از promiseها).
و اگر این کار به صورت مداوم اتفاق بیافتد، تسکهای معمولی فرصت اجرا شدن پیدا نمیکنند و مرورگر freeze میشود.
برای اثبات این موضوع، این کد را داخل مرورگر اجرا کنید:
به دلیل اهمیت بالای این مفهوم در پرامیسهای ES6، مفهوم microtask queue در ECMAScript Specification هم تحت عنوان “Job Queue” تعریف شده است. و در بعضی از مقالات و ... با این اسم شناخته میشود.
میدانیم که تسکهایی که داخل task queue قرار میگیرند، میتوانند به دلایل مختلفی ایجاد شوند.
مثلا تسکهایی که در اثر تعامل کاربر با صفحه، یا توسط timerها یا ... ایجاد میشوند.
مرورگرهای مدرن، برای بعضی از این Task Sourceها اولویت بالاتری در نظر میگیرند.
به عنوان مثال اگر تعداد زیادی تسک در task queue داشته باشیم و کاربر بر روی یک دکمه کلیک کند، ممکن است به دلیل اولویت پاسخگویی به درخواست کاربر، مرورگر تصمیم بگیرد که تسک مرتبط با کلیک شدن دکمه را زودتر اجرا کند.
در واقع هر event loop میتواند بیش از یک task queue داشته باشد. یکی برای تسکهای مرتبط با user interaction، یکی برای تسکهای مرتبط با networking و ...
به این ترتیب مرورگر میتواند به تسکهایی که از نظر performance مهمتر هستند اولویت بیشتری بدهد.
در نتیجه تسکهای مختلف ممکن است به ترتیب اجرا نشوند. و حتی اگر بدانیم تسکها به چه ترتیبی وارد task queue میشوند، باز هم نمیتوانیم مطمئن باشیم که به چه ترتیبی اجرا میشوند.
پس باید تا جای ممکن بر روی ترتیب اجرای تسکهای async حساب باز نکنیم!
میدانیم که جاوااسکریپت فقط بر روی یک thread اجرا میشود و در هر لحظه فقط یک تسک را میتواند پردازش کند.
همچنین هر تسکی که وارد call stack میشود باید تا انتها اجرا شود تا تسک بعدی بتواند اجرا شود.
یعنی حتی اگر پردازش یک تسک زمان زیادی هم طول بکشد نمیتوانیم آن را pre-empt کنیم. در نتیجه تسکهای بعدی باید صبر کنند تا این تسک تا انتها اجرا شود. به این ویژگی جاوااسکریپت run to completion گفته میشود.
بنابراین اگر میخواهیم یک پردازش طولانی انجام دهیم، مثلا اگر بخواهیم روی یک آرایهی خیلی طولانی map بزنیم، باید این کار را به چند تسک کوچکتر تبدیل کنیم و در چند مرحله انجام دهیم. یا اینکه تسک مورد نظر را به یک web worker منتقل کنیم.
در واقع این مفهوم، مشابه مقایسهی cooperative و pre-emptive multitasking است که در قسمت قبلی بررسی کردیم!
و مفاهیمی مثل "همکاری تسکها" یا "استفادهی اشتراکی چند تسک از یک thread" که در قسمت قبلی بررسی کردیم را به صورت عملی دیدیم.
حالا آمادهی بررسی بیشتر مفاهیم معروف مثل callback و promise و ... در قسمتهای بعدی هستیم. اما قبل از آن، در قسمت بعدی نگاه کوتاهی به کارکرد Timerهای معروف جاوااسکریپت خواهیم داشت...