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

אמרו לכם "לא לחסום את ה-thread הראשי" ו"לפצל את המשימות הארוכות". למה הכוונה בדברים האלה?

ג'רמי וגנר
ג'רמי וגנר

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

  • "אין לחסום את ה-thread הראשי".
  • "תפצלו את המשימות הארוכות".

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

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

מהי משימה?

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

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

למשימות יש 2 דרכים להשפיע על הביצועים. לדוגמה, כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מכניס בתור משימות לניתוח ולהדרה של קוד JavaScript הזה כדי שניתן יהיה לבצע אותו. בשלב מאוחר יותר במחזור החיים של הדף, המשימות מתחילות כשה-JavaScript פועל, כגון יצירת אינטראקציות באמצעות רכיבי handler של אירועים, אנימציות מבוססות-JavaScript ופעילות ברקע כמו איסוף נתונים. כל הדברים האלה – מלבד עובדי אינטרנט וממשקי API דומים – מתרחשים ב-thread הראשי.

מה ה-thread הראשי?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

אחת מהשיטות ששימשו את המפתחים כדי לפצל משימות קטנות יותר היא 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() הוא לא הכלי המתאים לביצוע העבודה – לפחות לא כשמשתמשים בו בצורה הזו.

בנוסף ל-setTimeout(), יש כמה ממשקי API אחרים שמאפשרים לדחות את ביצוע הקוד למשימה אחרת. אחד כולל שימוש ב-postMessage() לזמני קצוב קצרים יותר. אפשר גם לבצע חלוקה בין עבודות באמצעות requestIdleCallback() — אבל צריך להיזהר!requestIdleCallback() מתזמנים משימות בעדיפות הנמוכה ביותר האפשרית, ורק במהלך זמן חוסר פעילות של הדפדפן. כשה-thread הראשי עמוס, יכול להיות שמשימות שתוזמנו עם requestIdleCallback() לא יפעלו אף פעם.

שימוש ב-async/await ליצירת נקודות תפוקה

אחד הביטויים שתראו בהמשך המדריך הוא "תפוקה לשרשור הראשי" — אך מה זה אומר? למה כדאי לעשות את זה? מתי צריך לעשות את זה?

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

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

בפונקציה saveSettings() אפשר להציג את ה-thread הראשי אחרי כל קטע עבודה אם await את הפונקציה yieldToMain() אחרי כל קריאה לפונקציה:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

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

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

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

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

הכנסה רק כשצריך

מה אם יש לכם הרבה משימות, אבל אתם רוצים להתמקד רק כשהמשתמש מנסה לבצע אינטראקציה עם הדף? בדיוק בשביל זה isInputPending() נוצר.

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

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

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

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

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

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

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

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

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

פערים בממשקי ה-API הנוכחיים

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

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

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

ממשק API ייעודי של מתזמן

ממשק ה-API של מתזמן מציע כרגע את הפונקציה postTask(), שזמינה נכון למועד הכתיבה, בדפדפני Chromium וב-Firefox מאחורי דגל. postTask() מאפשר תזמון משימות פרטני, ואחת הדרכים לעזור לדפדפן לתעדף את העבודה כך שמשימות בעדיפות נמוכה יועברו ל-thread הראשי. postTask() משתמש בהבטחות ומקבל את ההגדרה של priority.

ל-API של postTask() יש שלוש עדיפויות שבהן אפשר להשתמש:

  • 'background' למשימות בעדיפות הנמוכה ביותר.
  • 'user-visible' למשימות בעדיפות בינונית. זו ברירת המחדל אם לא הוגדר priority.
  • 'user-blocking' למשימות קריטיות שצריך להפעיל בעדיפות גבוהה.

ניקח לדוגמה את הקוד הבא: ה-API של postTask() משמש להרצת שלוש משימות בעדיפות גבוהה ככל האפשר, ושתי המשימות הנותרות הן בעלות העדיפות הנמוכה ביותר האפשרית.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

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

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

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

תפוקה מובנית עם המשך דרך scheduler.yield

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

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

לידיעתך, הקוד שלמעלה מוכר ברובו, אבל במקום להשתמש ב-yieldToMain(), אפשר לבצע התקשרות באמצעות await scheduler.yield().

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

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

סיכום

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

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

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

תודה מיוחדת לפיליפ וולטון על הבדיקה הטכנית של המאמר הזה.

תמונה ראשית (Hero) מגיעה מ-Unwash, באדיבות Amirali Mirhashemian.