۱۱ سپتامبر ۲۰۲۳

Event loop: microtasks و macrotasks

اجرای Browser JavaScript execution flow، و همچنین Node.js، بر اساس یک event loop است.

درک نحوه عملکرد حلقه رویداد برای بهینه سازی ها و گاهی اوقات برای معماری مناسب مهم است.

در این فصل ابتدا جزئیات نظری در مورد چگونگی کارکرد چیزها را پوشش می دهیم و سپس کاربردهای عملی آن دانش را مشاهده می کنیم.

Event Loop

مفهوم event loop بسیار ساده است. یک حلقه بی پایان وجود دارد که در آن موتور جاوااسکریپت منتظر وظایف می ماند، آنها را اجرا می کند و سپس به خواب می رود و منتظر کارهای بیشتر است.

الگوریتم کلی موتور:

  1. در حالی که وظایف وجود دارد:
    • آنها را با قدیمی ترین کار شروع کنید.
  2. بخوابید تا زمانی که یک کار ظاهر شود، سپس به 1 بروید.

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

نمونه هایی از وظایف:

  • وقتی یک اسکریپت خارجی <script src="..."> بارگیری می شود، وظیفه اجرای آن است.
  • هنگامی که یک کاربر ماوس خود را حرکت می دهد، وظیفه ارسال رویداد mousemove و اجرای کنترل کننده ها است.
  • هنگامی که زمان تعیین شده برای setTimeout برنامه ریزی شده است، کار این است که تماس مجدد آن را اجرا کنید.
  • … و غیره.

وظایف تنظیم می شوند – موتور آنها را مدیریت می کند – سپس منتظر کارهای بیشتری می ماند (در حالت خواب و مصرف CPU نزدیک به صفر).

ممکن است زمانی اتفاق بیفتد که یک کار در حالی که موتور مشغول است بیاید، سپس در نوبت قرار گیرد.

وظایف یک صف تشکیل می دهند که اصطلاحاً به آن “macrotask queue” (v8 term) می گویند:

به عنوان مثال، در حالی که موتور مشغول اجرای یک script است، یک کاربر ممکن است ماوس خود را حرکت دهد و باعث mousemove شود، و setTimeout ممکن است به دلیل وجود داشته باشد و غیره، این وظایف یک صف تشکیل می دهند، همانطور که در تصویر بالا نشان داده شده است.

وظایف از صف بر اساس “first come – first served” پردازش می شود. وقتی مرورگر موتور با script تمام شد، رویداد mousemove و سپس setTimeoutو غیره را کنترل می‌کند.

تا اینجا، کاملا ساده، درست است؟

دو جزئیات دیگر:

  1. رندر هرگز اتفاق نمی افتد در حالی که موتور یک کار را اجرا می کند. مهم نیست که کار زمان زیادی ببرد. تغییرات در DOM فقط پس از تکمیل کار انجام می شود.
  2. اگر یک کار بیش از حد طولانی شود، مرورگر نمی تواند کارهای دیگری مانند پردازش رویدادهای کاربر را انجام دهد. بنابراین پس از مدتی، هشداری مانند “Page Unresponsive” را مطرح می‌کند که نشان می‌دهد کار با کل صفحه از بین می‌رود. این زمانی اتفاق می افتد که محاسبات پیچیده زیادی وجود داشته باشد یا یک خطای برنامه نویسی منجر به یک حلقه بی نهایت شود.

این نظریه بود. حال بیایید ببینیم چگونه می توانیم این دانش را به کار ببریم.

استفاده ۱: تقسیم کردن تسک های CPU-hungry

بیایید بگوییم که ما یک وظیفه تشنه CPU داریم.

برای مثال، برجسته کردن نحو (که برای رنگ آمیزی نمونه های کد در این صفحه استفاده می شود) کاملاً از نظر CPU سنگین است. برای برجسته کردن کد، تجزیه و تحلیل را انجام می دهد، عناصر رنگی زیادی ایجاد می کند، آنها را به سند اضافه می کند – برای مقدار زیادی متن که زمان زیادی می برد.

در حالی که موتور مشغول برجسته‌سازی نحو است، نمی‌تواند سایر کارهای مربوط به DOM، پردازش رویدادهای کاربر و غیره را انجام دهد. حتی ممکن است باعث شود مرورگر برای مدتی “hiccup” یا حتی “hang” کند، که غیرقابل قبول است.

ما می توانیم با تقسیم کار بزرگ به قطعات از مشکلات جلوگیری کنیم. ابتدا 100 خط را برجسته کنید، سپس setTimeout (با تاخیر صفر) را برای 100 خط بعدی برنامه ریزی کنید، و به همین ترتیب.

برای نشان دادن این رویکرد، به‌خاطر سادگی، به‌جای برجسته‌سازی متن، تابعی را انتخاب می‌کنیم که از 1 تا 1000000000 محاسبه می‌شود.

اگر کد زیر را اجرا کنید، موتور برای مدتی “hang” خواهد شد. برای JS سمت سرور که به وضوح قابل توجه است، و اگر آن را در مرورگر اجرا می کنید، سپس سعی کنید روی دکمه های دیگر صفحه کلیک کنید – خواهید دید که تا زمانی که شمارش به پایان برسد هیچ رویداد دیگری مدیریت نمی شود.

let i = 0;

let start = Date.now();

function count() {

  // do a heavy job
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

حتی ممکن است مرورگر اخطار «اسکریپت خیلی طولانی است» را نشان دهد.

بیایید کار را با استفاده از تماس‌های setTimeout تودرتو تقسیم کنیم:

let i = 0;

let start = Date.now();

function count() {

  // do a piece of the heavy job (*)
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // schedule the new call (**)
  }

}

count();

اکنون رابط مرورگر در طول فرآیند “شمارش” کاملاً کاربردی است.

یک اجرا از «شمارش» بخشی از کار (*) را انجام می دهد، و سپس در صورت نیاز، (**) را دوباره برنامه ریزی می کند:

  1. تعداد اجرای اول: i=1...1000000.
  2. تعداد اجرای دوم: i=1000001..2000000.
  3. … و غیره.

اکنون، اگر یک کار جانبی جدید (مثلاً رویداد onclick) در حالی که موتور مشغول اجرای قسمت 1 است ظاهر شود، در صف قرار می گیرد و پس از اتمام قسمت 1، قبل از قسمت بعدی، اجرا می شود. بازگشت‌های دوره‌ای به حلقه رویداد بین اجرای «شمارش»، هوای کافی را برای موتور جاوا اسکریپت فراهم می‌کند تا بتواند کار دیگری انجام دهد تا به سایر اقدامات کاربر واکنش نشان دهد.

نکته قابل توجه این است که هر دو نوع – با و بدون تقسیم کار با setTimeout – از نظر سرعت قابل مقایسه هستند. تفاوت زیادی در زمان شمارش کلی وجود ندارد.

برای نزدیک‌تر کردن آنها، بیایید بهبودی ایجاد کنیم.

زمان‌بندی را به ابتدای count() منتقل می‌کنیم:

let i = 0;

let start = Date.now();

function count() {

  // move the scheduling to the beginning
  if (i < 1e9 - 1e6) {
    setTimeout(count); // schedule the new call
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }

}

count();

حالا وقتی شروع به count() می کنیم و می بینیم که باید count() بیشتری انجام دهیم، بلافاصله قبل از انجام کار، آن را برنامه ریزی می کنیم.

اگر آن را اجرا کنید، به راحتی متوجه می شوید که زمان بسیار کمتری می برد.

چرا؟

این ساده است: همانطور که به یاد دارید، برای بسیاری از تماس‌های تودرتوی setTimeout، حداقل تاخیر 4 میلی‌ثانیه در مرورگر وجود دارد. حتی اگر 0 را تنظیم کنیم، 4 میلی‌ثانیه (یا کمی بیشتر) است. بنابراین هرچه زودتر آن را برنامه ریزی کنیم – سریعتر اجرا می شود.

در نهایت، ما یک کار تشنه CPU را به بخش‌هایی تقسیم کرده‌ایم – اکنون رابط کاربری را مسدود نمی‌کند. و زمان اجرای کلی آن خیلی بیشتر نیست.

استفاده ۲: نشانه پیشرفت

یکی دیگر از مزایای تقسیم وظایف سنگین برای اسکریپت های مرورگر این است که می توانیم نشانه پیشرفت را نشان دهیم.

همانطور که قبلا ذکر شد، تغییرات در DOM صرف نظر از مدت زمانی که طول می کشد، تنها پس از تکمیل کار در حال اجرا انجام می شود.

از یک طرف، این عالی است، زیرا عملکرد ما ممکن است عناصر زیادی ایجاد کند، آنها را یک به یک به سند اضافه کند و سبک آنها را تغییر دهد – بازدیدکننده هیچ حالت “intermediate” و ناتمام را نخواهد دید. یک چیز مهم، درست است؟

در اینجا نسخه آزمایشی است، تغییرات به i تا زمانی که عملکرد به پایان برسد نشان داده نمی شود، بنابراین ما فقط آخرین مقدار را خواهیم دید:

<div id="progress"></div>

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

…اما ما همچنین ممکن است بخواهیم چیزی را در طول کار نشان دهیم، به عنوان مثال. نوار پیشرفت

اگر کار سنگین را با استفاده از setTimeout به قطعات تقسیم کنیم، تغییرات در بین آنها ایجاد می شود.

این زیباتر به نظر می رسد:

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

اکنون <div> مقادیر افزایشی i را نشان می دهد که نوعی نوار پیشرفت است.

استفاده ۳: انجام دادن کاری بعد از event

در یک کنترل کننده رویداد، ممکن است تصمیم بگیریم برخی از اقدامات را تا زمانی که رویداد حبابی شود و در همه سطوح مدیریت شود به تعویق بیاندازیم. ما می‌توانیم این کار را با قرار دادن کد در setTimeout با تاخیر صفر انجام دهیم.

در فصل Dispatchکردن eventهای شخصی سازی شده مثالی دیدیم: رویداد سفارشی menu-open در setTimeout ارسال می‌شود، به طوری که پس از مدیریت کامل رویداد “click” اتفاق می‌افتد.

menu.onclick = function() {
  // ...

  // create a custom event with the clicked menu item data
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // dispatch the custom event asynchronously
  setTimeout(() => menu.dispatchEvent(customEvent));
};

Macrotasks و Microtasks

در کنار macrotasks که در این فصل توضیح داده شده است، microtask نیز وجود دارد که در فصل Microtasks ذکر شده است.

ریزکارها فقط از کد ما می آیند. آنها معمولاً با وعده‌ها ایجاد می‌شوند: اجرای کنترل‌کننده .then/catch/finally تبدیل به یک وظیفه کوچک می‌شود. وظایف ریز “under the cover” await نیز استفاده می‌شود، زیرا شکل دیگری از رسیدگی به وعده است.

همچنین یک تابع خاص queueMicrotask(func) وجود دارد که func را برای اجرا در صف microtask قرار می دهد.

بلافاصله بعد از هر macrotask، موتور تمام وظایف را از صف microtask اجرا می کند، قبل از اجرای هر ماکروتسک دیگر یا رندر یا هر چیز دیگری.

برای مثال نگاهی بیندازید:

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

دستور اینجا چه خواهد بود؟

  1. code ابتدا نشان داده می شود، زیرا یک تماس همزمان معمولی است.
  2. promise دوم را نشان می‌دهد، زیرا «.then» از صف microtask عبور می‌کند و بعد از کد فعلی اجرا می‌شود.
  3. timeout آخرین نمایش را نشان می‌دهد، زیرا این یک وظیفه بزرگ است.

تصویر حلقه رویداد غنی تر به این شکل است (ترتیب از بالا به پایین است، یعنی: ابتدا اسکریپت، سپس ریز وظایف، رندر و غیره):

همه ریزوظیفه ها قبل از انجام هر گونه مدیریت یا رندر رویداد یا هر وظیفه کلان دیگری تکمیل می شوند.

این مهم است، زیرا تضمین می‌کند که محیط برنامه اساساً یکسان است (بدون تغییر مختصات ماوس، بدون داده‌های شبکه جدید و غیره) بین ریزکارها.

اگر بخواهیم یک تابع را به صورت ناهمزمان (بعد از کد فعلی) اجرا کنیم، اما قبل از رندر شدن تغییرات یا مدیریت رویدادهای جدید، می‌توانیم آن را با queueMicrotask زمان‌بندی کنیم.

در اینجا یک مثال با “counting progress bar” وجود دارد، مشابه آنچه قبلا نشان داده شده است، اما از queueMicrotask به جای setTimeout استفاده شده است. می بینید که در انتها رندر می شود. درست مانند کد همزمان:

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e6) {
      queueMicrotask(count);
    }

  }

  count();
</script>

خلاصه

یک الگوریتم حلقه رویداد دقیق تر (اگرچه در مقایسه با specificationهنوز ساده شده است):

  1. قدیمی ترین کار را از صف macrotask (به عنوان مثال “اسکریپت”) در صف قرار دهید و اجرا کنید.
  2. همه Micro Taskها را اجرا کنید:
    • در حالی که صف microtask خالی نیست:
      • قدیمی ترین ریزتسک را Dequeue و اجرا کنید.
  3. در صورت وجود تغییرات رندر.
  4. اگر صف ماکروتسک خالی است، صبر کنید تا یک ماکروتسک ظاهر شود.
  5. به مرحله 1 بروید.

برای برنامه ریزی یک macrotask جدید:

  • از setTimeout(f) با تاخیر صفر استفاده کنید.

این ممکن است برای تقسیم یک کار سنگین محاسباتی به قطعات استفاده شود تا مرورگر بتواند به رویدادهای کاربر واکنش نشان دهد و پیشرفت بین آنها را نشان دهد.

همچنین در کنترل‌کننده‌های رویداد برای برنامه‌ریزی یک عمل پس از مدیریت کامل رویداد (حباب‌سازی انجام شد) استفاده می‌شود.

برای برنامه ریزی یک microtask جدید

  • از setTimeout(f) استفاده کنید.
  • همچنین به گردانندگان وعده داده می شود که از صف microtask عبور کنند.

هیچ رابط کاربری یا مدیریت رویداد شبکه بین ریزکارها وجود ندارد: آنها بلافاصله یکی پس از دیگری اجرا می شوند.

بنابراین ممکن است کسی بخواهد که queueMicrotask را برای اجرای یک تابع به صورت ناهمزمان، اما در حالت محیطی بخواهد.

Web Workers

برای محاسبات سنگین طولانی که نباید حلقه رویداد را مسدود کند، می‌توانیم از Web Workers استفاده کنیم.

این راهی برای اجرای کد در یک رشته موازی دیگر است.

Web Workers می‌توانند پیام‌ها را با فرآیند اصلی مبادله کنند، اما متغیرهای خود و حلقه رویداد خود را دارند.

Web Workers به DOM دسترسی ندارند، بنابراین آنها عمدتاً برای محاسبات مفید هستند تا از چندین هسته CPU به طور همزمان استفاده کنند.

تمارین

اهمیت: 5
console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

The console output is: 1 7 3 5 2 6 4.

The task is quite simple, we just need to know how microtask and macrotask queues work.

Let’s see what’s going on, step by step.

console.log(1);
// The first line executes immediately, it outputs `1`.
// Macrotask and microtask queues are empty, as of now.

setTimeout(() => console.log(2));
// `setTimeout` appends the callback to the macrotask queue.
// - macrotask queue content:
//   `console.log(2)`

Promise.resolve().then(() => console.log(3));
// The callback is appended to the microtask queue.
// - microtask queue content:
//   `console.log(3)`

Promise.resolve().then(() => setTimeout(() => console.log(4)));
// The callback with `setTimeout(...4)` is appended to microtasks
// - microtask queue content:
//   `console.log(3); setTimeout(...4)`

Promise.resolve().then(() => console.log(5));
// The callback is appended to the microtask queue
// - microtask queue content:
//   `console.log(3); setTimeout(...4); console.log(5)`

setTimeout(() => console.log(6));
// `setTimeout` appends the callback to macrotasks
// - macrotask queue content:
//   `console.log(2); console.log(6)`

console.log(7);
// Outputs 7 immediately.

To summarize,

  1. Numbers 1 and 7 show up immediately, because simple console.log calls don’t use any queues.
  2. Then, after the main code flow is finished, the microtask queue runs.
    • It has commands: console.log(3); setTimeout(...4); console.log(5).
    • Numbers 3 and 5 show up, while setTimeout(() => console.log(4)) adds the console.log(4) call to the end of the macrotask queue.
    • The macrotask queue is now: console.log(2); console.log(6); console.log(4).
  3. After the microtask queue becomes empty, the macrotask queue executes. It outputs 2, 6, 4.

Finally, we have the output: 1 7 3 5 2 6 4.

نقشه آموزش