ניהול יעיל של הזיכרון בקנה המידה של Gmail

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

מבוא

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

סשן ב-Google I/O 2013

הצגנו את התוכן הזה ב-Google I/O 2013. כדאי לצפות בסרטון הבא:

Gmail, יש לנו בעיה…

צוות Gmail נתקל בבעיה רצינית. שמענו יותר ויותר סיפורים על כרטיסיות של Gmail שצורכות כמה ג'יגה-בייט של זיכרון במחשבים ניידים ובמחשבים שולחניים עם מחסור במשאבים, ולרוב הסיום היה שהדפדפן כולו קרס. סיפורים על מעבדים שמוצמדים ל-100%, אפליקציות שלא מגיבות וכרטיסיות עצובות ב-Chrome ('הוא מת, ג'ים'). הצוות לא ידע איך להתחיל לאבחן את הבעיה, שלא לדבר על לפתור אותה. הם לא ידעו עד כמה הבעיה נפוצה, והכלים הזמינים לא התאימו לאפליקציות גדולות. הצוות צורף לצוותים של Chrome, והם פיתחו יחד שיטות חדשות לטיפול בבעיות זיכרון, שיפרו את הכלים הקיימים והפעילו את האיסוף של נתוני הזיכרון מהשטח. אבל לפני שנגיע לכלים, נדבר על העקרונות הבסיסיים של ניהול זיכרון ב-JavaScript.

יסודות של ניהול זיכרון

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

סוגי נתונים בסיסיים

ב-JavaScript יש שלושה סוגים פרימיטיביים:

  1. מספר (למשל 4, ‏ 3.14159)
  2. בוליאני (true או false)
  3. מחרוזת ("Hello World")

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

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

מה לגבי מערכי נתונים?

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

הסברים על המונחים

  1. ערך – מופע של סוג פרימיטיבי, אובייקט, מערך וכו'.
  2. משתנה – שם שמפנה לערך.
  3. מאפיין – שם באובייקט שמתייחס לערך.

תרשים אובייקטים

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

תרשים אובייקטים

מתי ערך הופך לזבל?

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

תרשים אשפה

מהי דליפת זיכרון ב-JavaScript?

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

email.message = document.createElement("div");
displayList.appendChild(email.message);

מאוחר יותר, מסירים את הרכיב מרשימת התצוגה:

displayList.removeAllChildren();

כל עוד email קיים, רכיב ה-DOM שאליו מפנה ההודעה לא יוסר, גם אם הוא מנותק עכשיו מעץ ה-DOM של הדף.

מהו 'נפח גדול מדי'?

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

מהו איסוף אשפה?

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

פירוט של ה-Garbage Collector ב-V8

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

Generational Collector

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

בפועל, ערכים שהוקצו לאחרונה לא חיים זמן רב. מחקר של תוכניות Smalltalk הראה שרק 7% מהערכים נשארים אחרי איסוף של דור צעיר. מחקרים דומים בסביבות זמן ריצה שונות הראו שבממוצע, בין 90% ל-70% מהערכים שהוקצו לאחרונה אף פעם לא מועברים לדור הקודם.

דור צעיר

אשכול הדור הצעיר ב-V8 מחולק לשני מרחבים, שנקראים from ו-to. הזיכרון מוקצה מהמרחב המשותף שאליו הוא מיועד. ההקצאה מהירה מאוד, עד שהמרחב של to מתמלא, ואז מתבצעת הפעלה של אוסף דור חדש. באוסף של דור צעיר, קודם מחליפים את המרחב 'מ' במרחב 'אל', מתבצע סריקה של המרחב 'אל' הישן (עכשיו המרחב 'מ') וכל הערכים הפעילים מועתקים למרחב 'אל' או מועברים לדור הקודם. איסוף אופייני של דור צעיר יימשך בערך 10 אלפיות שנייה (ms).

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

אשכול דור צעיר

דור קודם

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

סיכום של V8 GC

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

תיקון Gmail

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

כלים וטכניקות

נתוני שדה ו-performance.memory API

החל מגרסה 22 של Chrome, performance.memory API מופעל כברירת מחדל. באפליקציות לטווח ארוך כמו Gmail, נתונים ממשתמשים אמיתיים הם חסרי ערך. המידע הזה מאפשר לנו להבדיל בין משתמשים מתקדמים – שמבלים 8 עד 16 שעות ביום ב-Gmail ומקבלים מאות הודעות ביום – לבין משתמשים רגילים יותר שמבלים כמה דקות ביום ב-Gmail ומקבלים כ-12 הודעות בשבוע.

ה-API הזה מחזיר שלושה נתונים:

  1. jsHeapSizeLimit – נפח הזיכרון (בבייט) שאליו מוגבל אשכול JavaScript.
  2. totalJSHeapSize – נפח הזיכרון (בייטים) שהוקצה על ידי אשכול JavaScript, כולל שטח פנוי.
  3. usedJSHeapSize – כמות הזיכרון (בבייטים) שבשימוש כרגע.

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

מדידת זיכרון בקנה מידה נרחב

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

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

מדידת זיכרון בקנה מידה נרחב

זיהוי בעיה בזיכרון באמצעות ציר הזמן של DevTools

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

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

הוכחת קיומה של בעיה

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

תרשים בצורת שיניים של מסור

אחרי שמוודאים שהבעיה קיימת, אפשר לקבל עזרה בזיהוי מקור הבעיה מ-DevTools Heap Profiler.

איתור דליפות זיכרון באמצעות הכלי ליצירת תמונת מצב של ערימה (heap profiler) ב-DevTools

בחלונית Profiler יש כלי ליצירת תמונת מצב של מעבד (CPU) וכלי ליצירת תמונת מצב של ערימה (heap profiler). כדי ליצור פרופיל של אשכול, מצלמים קובץ snapshot של תרשים האובייקטים. לפני יצירת קובץ snapshot, מתבצע איסוף אשפה גם מהדור החדש וגם מהדור הישן. במילים אחרות, יוצגו רק ערכים שהיו פעילים בזמן יצירת קובץ ה-snapshot.

יש יותר מדי פונקציונליות ב-Heap Profiler כדי שנוכל להתייחס אליה במאמר הזה, אבל תיעוד מפורט זמין באתר של מפתחי Chrome. כאן נתמקד בכלי ליצירת תמונת מצב של הקצאת זיכרון בערימה (heap allocation).

שימוש בכלי ליצירת תמונת מצב של הקצאת זיכרון בערימה (heap allocation profiler)

הכלי לניתוח הקצאות בערימה משלב את המידע המפורט של קובץ ה-snapshot מ-Heap Profiler עם העדכון המצטבר והמעקב של חלונית ציר הזמן. פותחים את החלונית Profiles (פרופילים), מתחילים פרופיל Record Heap Allocations (הקלטה של הקצאות זיכרון בערימות), מבצעים רצף של פעולות ומפסיקים את ההקלטה לצורך ניתוח. פרופיל המוקצים יוצר קובצי snapshot של אשכול מדי פעם במהלך ההקלטה (לפעמים כל 50 אלפיות השנייה!) וקובץ snapshot סופי אחד בסוף ההקלטה.

כלי ליצירת תמונת מצב של הקצאת זיכרון בערימה (heap allocation profiler)

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

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

פתרון בעיית הזיכרון ב-Gmail

בעזרת הכלים והשיטות שצוינו למעלה, צוות Gmail הצליח לזהות כמה קטגוריות של באגים: מטמון ללא הגבלה, מערכי קריאות חזרה (callbacks) שממשיכים לגדול ללא הגבלה ומחכים שמשהו יקרה אבל הוא אף פעם לא קורה, ומאזינים לאירועים ששומרים בטעות את היעדים שלהם. בעקבות תיקון הבעיות האלה, נפח הזיכרון הכולל של Gmail ירד באופן משמעותי. משתמשים ב-99% העליונים השתמשו ב-80% פחות זיכרון מאשר בעבר, וצריכת הזיכרון של משתמשים במדד החציוני ירדה בכמעט 50%.

שימוש בזיכרון ב-Gmail

מכיוון ש-Gmail השתמש בפחות זיכרון, זמן האחזור של ההשהיה של GC פחת, וכך חוויית המשתמש הכוללת השתפרה.

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

קריאה לפעולה

כדאי לשאול את עצמכם את השאלות הבאות:

  1. כמה זיכרון האפליקציה שלי משתמשת בו? יכול להיות שאתם משתמשים בזיכרון יותר מדי, וזה, בניגוד לאמונה הרווחת, משפיע לרעה על הביצועים הכוללים של האפליקציה. קשה לדעת מהו המספר הנכון בדיוק, אבל חשוב לוודא שלכל אחסון נוסף של נתונים ששמור בדף יש השפעה מדידה על הביצועים.
  2. האם הדף שלי לא כולל דליפות מידע? אם בדף יש דליפות זיכרון, הן עלולות להשפיע לא רק על הביצועים של הדף אלא גם על כרטיסיות אחרות. משתמשים במעקב אחר אובייקטים כדי לצמצם את החיפוש של דליפות.
  3. באיזו תדירות מתבצע ניקוי זיכרון (GC) בדף? אפשר לראות כל השהיה של GC באמצעות חלונית ציר הזמן בכלים למפתחים ב-Chrome. אם הדף מבצע פעולות GC בתדירות גבוהה, סביר להניח שאתם מקצים זיכרון בתדירות גבוהה מדי, ומנצלים את הזיכרון של הדור הצעיר.

סיכום

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