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

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

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

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

  • "אל תחסמו את השרשור הראשי".
  • "Break up your long tasks"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

שיטות לניהול משימות

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

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

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

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

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

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

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

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

דחיית ביצוע הקוד באופן ידני

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

אחת השיטות שבהן מפתחים משתמשים כדי לפצל משימות למשימות קטנות יותר היא setTimeout(). בשיטה הזו, מעבירים את הפונקציה אל setTimeout(). הפעולה הזו תעכב את ביצוע הקריאה החוזרת (callback) למשימה נפרדת, גם אם תציינו זמן קצוב לתפוגה של 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: not supported.
  • 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() מחולק עכשיו לשתי משימות. כתוצאה מכך, אפשר להריץ את הפריסה והצביעה בין המשימות, וכך לתת למשתמש תגובה חזותית מהירה יותר, כפי שמתבטא באינטראקציה עם הסמן, שהיא קצרה בהרבה עכשיו.

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

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

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

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

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

פתרון אחד הוא להוסיף את scheduler-polyfill לגרסה היציבה, ואז אפשר להשתמש ב-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() הוא שאפשר await אותו בכל פונקציית async.

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

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

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

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

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

גישה אחת היא לקבץ את המשימות, ולבצע יצירת גרסת build רק אם חלף מספיק זמן מאז היצירה הקודמת. מועד הגשה נפוץ הוא 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() ועל תעדוף משימות זמין במסמכי התיעוד של Prioritized Task Scheduling API.

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

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

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