אופטימיזציה למשימות ארוכות

אמרו לכם 'אל תחסמו את ה-thread הראשי' ו'צריך לפצל את המשימות הארוכות', אבל מה זה אומר בפועל?

פורסם: 30 בספטמבר 2022, עדכון אחרון: 19 בדצמבר 2024

ההמלצות הנפוצות לשמירה על מהירות של אפליקציות JavaScript הן בדרך כלל:

  • ‫"Don't block the main thread" (אל תחסמו את ה-thread הראשי).
  • ‫"Break up your long tasks" (פיצול משימות ארוכות).

זו עצה מצוינת, אבל איזו עבודה היא כוללת? משלוח של פחות JavaScript הוא דבר טוב, אבל האם זה אומר אוטומטית שממשקי המשתמש יהיו רספונסיביים יותר? אולי כן, אולי לא.

כדי להבין איך לבצע אופטימיזציה של משימות ב-JavaScript, צריך קודם להבין מהן משימות ואיך הדפדפן מטפל בהן.

מהי משימה?

משימה היא כל חלק נפרד של עבודה שהדפדפן מבצע. העבודה הזו כוללת עיבוד, ניתוח של HTML ו-CSS, הפעלת JavaScript וסוגים אחרים של עבודה שאולי אין לכם שליטה ישירה עליהם. מכל אלה, קוד ה-JavaScript שאתם כותבים הוא אולי המקור הגדול ביותר למשימות.

הדמיה של משימה כפי שהיא מוצגת בפרופיל הביצועים בכלי הפיתוח ל-Chrome. המשימה נמצאת בראש הערימה, עם handler של אירוע לחיצה, קריאה לפונקציה ועוד פריטים מתחתיה. המשימה כוללת גם עבודת רינדור בצד שמאל.
משימה שהופעלה על ידי handler של אירוע click, שמוצגת בפרופיל הביצועים של כלי הפיתוח ל-Chrome.

משימות שמשויכות ל-JavaScript משפיעות על הביצועים בכמה דרכים:

  • כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מוסיף לתור משימות לניתוח ולקימפול של ה-JavaScript הזה, כדי שאפשר יהיה להריץ אותו בהמשך.
  • בזמנים אחרים במהלך מחזור החיים של הדף, המשימות מתווספות לתור כש-JavaScript מבצע עבודה כמו תגובה לאינטראקציות באמצעות handlers של אירועים, אנימציות מבוססות-JavaScript ופעילות ברקע כמו איסוף נתונים של Analytics.

כל הפעולות האלה – למעט web workers וממשקי API דומים – מתבצעות ב-thread הראשי.

מהו ה-thread הראשי?

ה-main thread הוא המקום שבו רוב המשימות מופעלות בדפדפן, ושבו כמעט כל קוד ה-JavaScript שאתם כותבים מבוצע.

ה-thread הראשי יכול לעבד רק משימה אחת בכל פעם. כל משימה שנמשכת יותר מ-50 אלפיות השנייה היא משימה ארוכה. אם משך המשימה חורג מ-50 אלפיות השנייה, משך הזמן הכולל של המשימה פחות 50 אלפיות השנייה נקרא תקופת החסימה של המשימה.

הדפדפן חוסם אינטראקציות בזמן שמופעלת משימה בכל אורך, אבל המשתמש לא יכול להבחין בכך כל עוד המשימות לא פועלות יותר מדי זמן. עם זאת, אם משתמש ינסה ליצור אינטראקציה עם דף שיש בו הרבה משימות ארוכות, הוא ירגיש שממשק המשתמש לא מגיב, ואולי אפילו יחשוב שהוא לא תקין אם השרשור הראשי חסום למשך תקופות ארוכות מאוד.

משימה ארוכה בפרופיל הביצועים של כלי הפיתוח ל-Chrome. החלק החוסם של המשימה (יותר מ-50 אלפיות השנייה) מוצג עם תבנית של פסים אלכסוניים אדומים.
משימה ארוכה כפי שמוצגת בפרופיל הביצועים של Chrome. משימות ארוכות מסומנות במשולש אדום בפינת המשימה, והחלק שחוסם את הזמן מסומן בדוגמה של פסים אדומים אלכסוניים.

כדי למנוע חסימה של ה-thread הראשי למשך זמן רב מדי, אפשר לפצל משימה ארוכה לכמה משימות קטנות יותר.

משימה ארוכה אחת לעומת אותה משימה שמחולקת למשימות קצרות יותר. המשימה הארוכה היא מלבן גדול אחד, ואילו המשימה המחולקת היא חמישה ריבועים קטנים שביחד הם באותו רוחב כמו המשימה הארוכה.
תמונה להמחשה של משימה ארוכה אחת לעומת אותה משימה שמחולקת לחמש משימות קצרות יותר.

זה חשוב כי כשמשימות מחולקות, הדפדפן יכול להגיב לעבודה בעדיפות גבוהה הרבה יותר מהר – כולל אינטראקציות עם המשתמשים. לאחר מכן, המשימות שנותרו יפעלו עד להשלמתן, כדי לוודא שהעבודה שהוספתם לתור תתבצע.

איור שמראה איך פיצול משימה יכול להקל על אינטראקציה של משתמש. בחלק העליון, משימה ארוכה חוסמת את הפעלת גורם הטיפול באירועים עד שהמשימה מסתיימת. בתחתית, המשימה שחולקה לחלקים מאפשרת ל-handler של האירוע לפעול מוקדם יותר ממה שהיה קורה אחרת.
הדמיה של מה שקורה לאינטראקציות כשמשימות ארוכות מדי והדפדפן לא יכול להגיב לאינטראקציות מספיק מהר, לעומת מצב שבו משימות ארוכות מחולקות למשימות קצרות יותר.

בחלק העליון של האיור הקודם, מטפל באירועים שהוכנס לתור על ידי אינטראקציה של משתמש נאלץ להמתין למשימה ארוכה אחת לפני שהוא יכול להתחיל, מה שגורם לעיכוב בהתרחשות האינטראקציה. במקרה כזה, יכול להיות שהמשתמש יבחין בהשהיה. בחלק התחתון, ה-event handler יכול להתחיל לפעול מוקדם יותר, והאינטראקציה עשויה להרגיש מיידית.

עכשיו, אחרי שהבנתם למה חשוב לחלק את המשימות, תוכלו ללמוד איך לעשות את זה ב-JavaScript.

אסטרטגיות לניהול משימות

עצה נפוצה בארכיטקטורת תוכנה היא לחלק את העבודה לפונקציות קטנות יותר:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

בדוגמה הזו, יש פונקציה בשם saveSettings() שקוראת לחמש פונקציות כדי לאמת טופס, להציג סמל טעינה, לשלוח נתונים לחלק האחורי של האפליקציה, לעדכן את ממשק המשתמש ולשלוח נתונים לניתוח.

מבחינה רעיונית, saveSettings() מתוכנן היטב. אם אתם צריכים לנפות באגים באחת מהפונקציות האלה, אתם יכולים לעבור על עץ הפרויקט כדי להבין מה כל פונקציה עושה. חלוקת העבודה באופן הזה מקלה על הניווט בפרויקטים ועל התחזוקה שלהם.

עם זאת, בעיה פוטנציאלית כאן היא ש-JavaScript לא מריץ כל אחת מהפונקציות האלה כמשימה נפרדת, כי הן מבוצעות בתוך הפונקציה saveSettings(). כלומר, כל חמש הפונקציות יפעלו כמשימה אחת.

הפונקציה saveSettings כפי שמוצגת בכלי ליצירת פרופיל ביצועים של Chrome. הפונקציה ברמה העליונה קוראת לחמש פונקציות אחרות, אבל כל העבודה מתבצעת במשימה ארוכה אחת, ולכן התוצאה שגלויות למשתמשים אחרי הפעלת הפונקציה לא גלויה עד שכל הפונקציות מסתיימות.
פונקציה אחת saveSettings() שקוראת לחמש פונקציות. העבודה מופעלת כחלק ממשימה מונוליטית ארוכה אחת, שחוסמת כל תגובה חזותית עד שכל חמש הפונקציות מסתיימות.

בתרחיש הטוב ביותר, אפילו רק אחת מהפונקציות האלה יכולה להוסיף 50 מילישניות או יותר לאורך הכולל של המשימה. במקרה הגרוע ביותר, יותר משימות כאלה יכולות לפעול הרבה יותר זמן – במיוחד במכשירים עם מגבלות על המשאבים.

במקרה הזה, saveSettings() מופעלת על ידי קליק של משתמש, ומכיוון שהדפדפן לא יכול להציג תגובה עד שכל הפונקציה מסיימת לפעול, התוצאה של המשימה הארוכה הזו היא ממשק משתמש איטי ולא רספונסיבי, והיא תימדד כמהירות תגובה לאינטראקציה באתר (INP) נמוכה.

דחייה ידנית של ביצוע הקוד

כדי לוודא שמשימות חשובות שמוצגות למשתמשים ותגובות של ממשק המשתמש מתבצעות לפני משימות בעדיפות נמוכה יותר, אתם יכולים להעביר את השליטה לשרשור הראשי על ידי הפסקה קצרה של העבודה כדי לתת לדפדפן הזדמנויות להריץ משימות חשובות יותר.

אחת השיטות שבהן מפתחים משתמשים כדי לחלק משימות למשימות קטנות יותר כוללת setTimeout(). בשיטה הזו, מעבירים את הפונקציה אל setTimeout(). הפעולה הזו דוחה את ההפעלה של הקריאה החוזרת למשימה נפרדת, גם אם מציינים זמן קצוב של 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

הפעולה הזו נקראת החזרת ערך, והיא מתאימה במיוחד לסדרת פונקציות שצריכות לפעול ברצף.

עם זאת, לא תמיד הקוד מאורגן בצורה הזו. לדוגמה, יכול להיות שיש לכם כמות גדולה של נתונים שצריך לעבד בלולאה, והמשימה הזו יכולה לקחת הרבה זמן אם יש הרבה איטרציות.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

השימוש ב-setTimeout() כאן בעייתי בגלל ארגונומיה של מפתחים, ואחרי חמש הקפות של setTimeout() מוטמעים, הדפדפן יתחיל להטיל עיכוב מינימלי של 5 מילישניות לכל setTimeout() נוסף.

setTimeout יש גם חסרון נוסף כשמדובר בהעברת שליטה: כשמעבירים שליטה לשרשור הראשי על ידי דחיית הפעלת קוד למשימה הבאה באמצעות setTimeout, המשימה הזו מתווספת לסוף התור. אם יש משימות אחרות שממתינות, הן יפעלו לפני הקוד שנדחה.

‫API ייעודי להעברת השליטה: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

scheduler.yield() הוא API שנועד במיוחד להעברה ל-thread הראשי בדפדפן.

זה לא תחביר ברמת השפה או מבנה מיוחד; scheduler.yield() היא פשוט פונקציה שמחזירה Promise שייפתר במשימה עתידית. כל קוד שמשורשר להפעלה אחרי ש-Promise נפתר (בשרשור .then() מפורש או אחרי await שלו בפונקציה אסינכרונית) יופעל במשימה העתידית הזו.

בפועל: מוסיפים await scheduler.yield() והפונקציה תשהה את ההרצה בנקודה הזו ותעבור ל-thread הראשי. הביצוע של שאר הפונקציה – שנקרא המשך הפונקציה – יתוזמן להפעלה במשימה חדשה של לולאת אירועים. כשהמשימה הזו מתחילה, ההבטחה שהמתינה תמומש, והפונקציה תמשיך לפעול מהנקודה שבה היא נעצרה.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
הפונקציה saveSettings כפי שמוצגת בפרופיל הביצועים של Chrome, מחולקת עכשיו לשתי משימות. המשימה הראשונה קוראת לשתי פונקציות, ואז יוצאת מהן, כדי לאפשר את העיבוד של הפריסה והצביעה, וכך להציג למשתמש תגובה גלויה. כתוצאה מכך, אירוע הקליק מסתיים תוך 64 אלפיות השנייה בלבד. המשימה השנייה קוראת לשלוש הפונקציות האחרונות.
ההפעלה של הפונקציה saveSettings() מפולגת עכשיו בין שתי משימות. כתוצאה מכך, הפעולות layout ו-paint יכולות לפעול בין המשימות, וכך המשתמש מקבל תגובה חזותית מהירה יותר, כפי שנמדד על ידי האינטראקציה עם מצביע העכבר, שהיא עכשיו קצרה הרבה יותר.

היתרון האמיתי של scheduler.yield() על פני גישות אחרות להעברת השליטה הוא שההמשך שלה מקבל עדיפות, כלומר אם מעבירים את השליטה באמצע משימה, ההמשך של המשימה הנוכחית יפעל לפני שיתחילו משימות דומות אחרות.

כך נמנעת הפרעה לסדר הביצוע של הקוד שלכם על ידי קוד ממקורות אחרים של משימות, כמו משימות מסקריפטים של צד שלישי.

שלושה תרשימים שמציגים משימות בלי ויתור, עם ויתור ועם ויתור והמשך. אם לא משתמשים ב-yield, יש משימות ארוכות. בשיטת ה-yielding, יש יותר משימות קצרות, אבל יכול להיות שהן יופרעו על ידי משימות אחרות שלא קשורות אליהן. עם העברה והמשך, יש יותר משימות קצרות, אבל סדר הביצוע שלהן נשמר.
כשמשתמשים ב-scheduler.yield(), ההמשך מתחיל מהמקום שבו הוא הפסיק לפני המעבר למשימות אחרות.

תמיכה בדפדפנים שונים

התג scheduler.yield() עדיין לא נתמך בכל הדפדפנים, ולכן צריך להשתמש בחלופה.

פתרון אחד הוא להוסיף את scheduler-polyfill אל ה-build, ואז אפשר להשתמש ישירות ב-scheduler.yield(). ה-polyfill יטפל בחזרה לפונקציות אחרות של תזמון משימות, כך שהיא תפעל באופן דומה בדפדפנים שונים.

לחלופין, אפשר לכתוב גרסה פחות מתוחכמת בכמה שורות, באמצעות setTimeout בלבד שעטוף ב-Promise כגיבוי אם scheduler.yield() לא זמין.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

בדפדפנים שלא תומכים ב-scheduler.yield(), לא תהיה המשכיות עם עדיפות, אבל הדפדפן עדיין יפנה משאבים כדי להישאר רספונסיבי.

לבסוף, יכולים להיות מקרים שבהם הקוד לא יכול להעביר את השליטה לשרשור הראשי אם לא ניתנת עדיפות להמשך שלו (לדוגמה, דף עמוס ידוע שבו העברת השליטה עלולה לגרום לכך שהעבודה לא תושלם למשך זמן מסוים). במקרה כזה, אפשר להתייחס ל-scheduler.yield() כאל שיפור הדרגתי: הפעולה תתבצע בדפדפנים שבהם scheduler.yield() זמין, אחרת היא תימשך.

אפשר לעשות זאת באמצעות זיהוי תכונות וחזרה להמתנה של מיקרו-משימה אחת בשורה אחת נוחה:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

פיצול עבודה ארוכה באמצעות scheduler.yield()

היתרון בשימוש באחת מהשיטות האלה של scheduler.yield() הוא שאפשר להשתמש בה בכל פונקציה של async.await

לדוגמה, אם יש לכם מערך של משימות להפעלה, שלעתים קרובות מצטברות למשימה ארוכה, אתם יכולים להוסיף yield כדי לפצל את המשימה.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

המשך הפעולה של runJobs() יקבל עדיפות, אבל עדיין יאפשר להריץ עבודות בעדיפות גבוהה יותר, כמו תגובה חזותית לקלט של משתמשים, בלי לחכות לסיום של רשימת המשימות הארוכה.

עם זאת, זה לא שימוש יעיל בהעברת תנועה. scheduler.yield() מהיר ויעיל, אבל יש לו תקורה מסוימת. אם חלק מהמשימות ב-jobQueue קצרות מאוד, יכול להיות שהתקורה תצטבר במהירות ותגרום לכך שזמן ההשהיה וההפעלה מחדש יהיה ארוך יותר מזמן הביצוע של העבודה בפועל.

אחת הגישות היא להריץ את העבודות בקבוצות, ולבצע yield רק אם עבר מספיק זמן מאז ה-yield האחרון. מועד סיום נפוץ הוא 50 אלפיות השנייה, כדי למנוע ממשימות להפוך למשימות ארוכות, אבל אפשר לשנות אותו כדי לאזן בין מהירות התגובה לבין הזמן שנדרש להשלמת תור המשימות.

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

התוצאה היא שהעבודות מחולקות כך שהן אף פעם לא יימשכו יותר מדי זמן, אבל ה-runner מניב רק ל-thread הראשי בערך כל 50 אלפיות השנייה.

סדרה של פונקציות עבודה, שמוצגות בחלונית הביצועים של כלי הפיתוח ל-Chrome, עם פירוט של הביצוע שלהן על פני כמה משימות
משימות שצורפו למספר משימות.

לא להשתמש ב-isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

ממשק ה-API‏ isInputPending() מאפשר לבדוק אם משתמש ניסה ליצור אינטראקציה עם דף, ומחזיר ערך רק אם יש קלט בהמתנה.

כך אפשר להמשיך את JavaScript אם אין קלט בהמתנה, במקום להניב ולסיים את התור של המשימות. השיפורים האלה יכולים להוביל לשיפורים משמעותיים בביצועים, כפי שמפורט בכוונה להשיק, באתרים שאחרת לא יחזרו לשרשור הראשי.

עם זאת, מאז השקת ה-API הזה, ההבנה שלנו לגבי ויתור על זמן מעבד גדלה, במיוחד עם ההשקה של INP. אנחנו כבר לא ממליצים להשתמש ב-API הזה, וממליצים במקום זאת להשתמש ב-yield בלי קשר לשאלה אם יש קלט בהמתנה או לא, מכמה סיבות:

  • יכול להיות שהפונקציה isInputPending() תחזיר את הערך false בטעות, למרות שהמשתמש ביצע אינטראקציה בנסיבות מסוימות.
  • קלט הוא לא המקרה היחיד שבו משימות צריכות להניב. אנימציות ועדכונים אחרים בממשק המשתמש יכולים להיות חשובים באותה מידה כדי לספק דף אינטרנט רספונסיבי.
  • מאז השקנו ממשקי API מקיפים יותר להעברת תנועה, שנותנים מענה לבעיות שקשורות להעברת תנועה, כמו scheduler.postTask() ו-scheduler.yield().

סיכום

ניהול משימות הוא דבר מאתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת לניהול ולתעדוף משימות, אלא מספר טכניקות שונות. לסיכום, אלה הדברים העיקריים שכדאי להביא בחשבון כשמנהלים משימות:

  • העברת השליטה ל-thread הראשי למשימות קריטיות שמשפיעות על המשתמש.
  • שימוש ב-scheduler.yield() (עם חזרה לשיטה הקודמת בדפדפנים אחרים) כדי להשיג המשכים עם עדיפות גבוהה בצורה ארגונומית
  • לבסוף, מומלץ לבצע כמה שפחות פעולות בפונקציות.

מידע נוסף על scheduler.yield(), על תזמון משימות יחסי מפורש scheduler.postTask() ועל תעדוף משימות זמין במסמכי ה-API בנושא תזמון משימות לפי סדר עדיפויות.

בעזרת אחד או יותר מהכלים האלה, תוכלו לבנות את העבודה באפליקציה כך שתינתן עדיפות לצרכים של המשתמשים, ועדיין לוודא שגם עבודה פחות קריטית תתבצע. כך נוכל ליצור חוויית משתמש טובה יותר, שתהיה רספונסיבית יותר ומהנה יותר לשימוש.

תודה מיוחדת לפיליפ וולטון (Philip Walton) על הבדיקה הטכנית של המדריך הזה.

תמונה ממוזערת מתוך Unsplash, באדיבות Amirali Mirhashemian.