ניהול יעיל של הזיכרון בקנה המידה של 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. ערך בוליאני (נכון או לא נכון)
  3. מחרוזת ("שלום עולם")

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

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

מה לגבי מערכים?

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

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

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

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

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

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

מתי ערך הופך לאשפה?

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

תרשים אשפה

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

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

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

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

displayList.removeAllChildren();

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

מה זה Bloat?

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

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

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

V8 אוסף אשפה בפירוט

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

אספן גנרטיבי

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

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

הדור הצעיר

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

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

ערימה (heap) של הדור הצעיר

הדור הישן

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

סיכום V8 GC

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

מתקנים את Gmail

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

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

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

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

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

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

זיהוי בעיית זיכרון בציר הזמן של כלי הפיתוח

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

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

הוכחה שיש בעיה

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

גרף בצורת שן

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

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

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

במאמר הזה יש יותר מדי פונקציונליות ב-heap profiler, אבל אפשר למצוא תיעוד מפורט באתר Chrome Developers. נתמקד כאן בכלי ליצירת פרופיל של הקצאת ערימה (heap).

שימוש בכלי לניתוח ביצועי ערימה (heap Allocation Profiler)

הכלי ליצירת פרופיל של הקצאת ערימה (heap Allocation) משלב את המידע המפורט של תמונת המצב של הכלי לניתוח נתוני ערימה (heap Profiler) עם העדכון והמעקב המצטברים של חלונית ציר הזמן. פותחים את החלונית 'פרופילים', מפעילים פרופיל מסוג הקלטה של הקצאות זיכרון בערימה, מבצעים רצף של פעולות ומפסיקים את ההקלטה לצורך ניתוח. הכלי ליצירת פרופילים של הקצאה מצלם תמונות מצב של ערימה מדי פעם במהלך ההקלטה (בתדירות של כל 50 אלפיות שנייה!) ותמונת מצב סופית אחת בסיום ההקלטה.

כלי לניתוח של הקצאת ערימה (heap allocation)

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

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

פתרון משבר הזיכרון של Gmail

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

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

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

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

קריאה לפעולה

שאל את עצמך את השאלות הבאות:

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

סיכום

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