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

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

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

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

  • ‫"Don't block the main thread" (אל תחסמו את ה-main thread).
  • "כדאי לחלק את המשימות הארוכות".

זו עצה מצוינת, אבל איזו עבודה נדרשת כדי ליישם אותה? משלוח של פחות 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 Technology Preview: supported.
  • Safari: not supported.

Source

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

זה לא תחביר ברמת השפה או מבנה מיוחד, אלא פשוט פונקציה שמחזירה Promise שתקבל ערך במשימה עתידית.scheduler.yield() כל קוד שמשורשר להרצה אחרי ש-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() מחולק עכשיו לשתי משימות. כתוצאה מכך, הפריסה והציור יכולים לפעול בין המשימות, ולספק למשתמש תגובה חזותית מהירה יותר, כפי שנמדד על ידי האינטראקציה הקצרה בהרבה של מצביע העכבר.

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

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

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

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

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

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

לחלופין, אפשר לכתוב גרסה פחות מתוחכמת בכמה שורות, באמצעות 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 ממשיך לפעול במקום להניב ולסיים את התור של המשימות. השיפורים האלה יכולים להוביל לשיפורים מרשימים בביצועים, כפי שמפורט בIntent to Ship, באתרים שאולי לא יחזרו לשרשור הראשי בדרך אחרת.

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

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

סיכום

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

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

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

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

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

התמונה הממוזערת נלקחה מ-Unsplash, באדיבות Amirali Mirhashemian.