כלים וביצועים

פתרון בעיות בזיכרון באמצעות Firebase Crashlytics

רמקולים

  • Polina Govorkova

    מפתח תוכנה בכיר

מידע כללי

מידע על ההרצאה

בעזרת Firebase Crashlytics, אתם יכולים לשמור על הזיכרון שבשימוש באפליקציה קטן ויעיל, ולמנוע שגיאות חריגה מהזיכרון. בסשן הזה נסקור את חבילת הכלים המקיפה שלנו לפתרון בעיות בזיכרון, שזמינה במסוף Firebase וב-Android Studio. נתחיל בהסבר על איך להשתמש בתובנות לגבי מקרי ANR ובדוחות של GWP-ASan כדי לאבחן באגים חמקמקים בזיכרון. לאחר מכן נתעמק בכלים החדשים שלנו לאירועי OOM ו-MLK, שנועדו במיוחד כדי לעזור לכם להתמצא במגבלות הזיכרון החדשות ב-Android 17 ולהסתגל אליהן.

סיכום

  • שגיאות מסוג "האפליקציה לא מגיבה" נגרמות בדרך כלל בגלל צווארי בקבוק ברקע או ניצול מלא של הזיכרון, ולא בגלל עומס פעיל על המעבד. ‫Firebase Crashlytics שולח שאילתות אל ApplicationExitInfo כדי להציג מעקבים מרובי-תהליכים ולזהות את התהליכים שחוסמים את השורש.
  • קריסות בגלל אין זיכרון פנוי (OOM) מתרחשות כשהאפליקציות חורגות ממגבלות הזיכרון של Java בזמן הריצה. ‫Crashlytics משתלב עם ProfilingManager של Android 17 כדי לאסוף תמונות מצב של ערימה (heap snapshot) שעברו ביטול ערפול (de-obfuscation), שאפשר לבדוק ב-Perfetto או דרך ה-Android CLI.
  • התכונה "מגביל הזיכרון" ב-Android 17 סוגרת תהליכים ספציפיים שצורכים יותר מדי זיכרון RAM, כולל הקצאות מקוריות. מערכת Crashlytics מקבצת את הסגירות האלה במרכז הבקרה לצד תמונות מצב של הזיכרון, כדי להפריד בין דליפות זיכרון ב-C++‎ לבין הקצאות זיכרון מנוהלות בערימה (heap allocation).
  • פגיעה בזיכרון המקורי מופיעה לעיתים קרובות בשרשורים של המערכת ולא בקוד של האפליקציה. ‫Crashlytics תומך ב-GWP-ASan וב-Arm MTE, והדוחות של GWP-ASan מציינים את המיקומים המדויקים של ההקצאה והביטול של ההקצאה של באגים כמו use-after-free.

משאבים

Gemini Notebook

נושאים

  • firebase-crashlytics
  • android-memory
  • anr
  • out-of-memory
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

תמליל

הורדת התמליל

מבוא

  • Polina Govorkova

    שלום לכולם,

  • ברוכים הבאים למדריך לפתרון בעיות בזיכרון באמצעות Firebase Crashlytics.

  • רגע, איזה שם תאגידי.

  • בואו נגוון קצת.

  • איך לשמור על אפליקציות יעילות ומהירות בעזרת Firebase Crashlytics.

  • זה נשמע יותר טוב.

  • קוראים לי פולינה, אני מפתחת תוכנה בכירה בצוות Firebase Crashlytics, ואני אעביר את המצגת היום.

  • בשלב מסוים במחזור הפיתוח, תרגישו שפתרתם את כל הבאגים.

  • שגיאות ההקלדה בניתוח JSON, בדיקות המצביע הריק שנשכחו, הכול נעלם!

  • ואולי אתם שואלים את עצמכם, "זהו? סיימתי?"

  • אז יש לי חדשות רעות בשבילכם, כי אתם לא.

  • כי מה שיישאר לכם, למעשה, הן הבעיות הקשות.

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

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

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

  • שנתחיל?

  • למה בעיות שקשורות למהירות התגובה ולזיכרון הן כל כך ידועות לשמצה?

  • ניקח באג קלאסי: אתם מגדירים מחרוזת, מגדירים אותה כ-null, ובשלב מסוים מנסים לקרוא את האורך שלה.

  • מה מקבלים?

  • NullPointerException ישן וטוב.

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

  • תסתכלו על דוח הקריסות בצד ימין.

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • אנחנו יכולים לראות מיד שהבעיה נמצאת בשורה 42 ב-MainActivity.kt.

  • הגישה הזו נכונה לרוב הבאגים הפשוטים.

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

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • במקרה הטוב, הוא מראה רק את ההשפעה, שהיא שרשור או הקצאה תמימים שפשוט פעלו בזמן שהאפליקציה קפאה או שנגמר הזיכרון.

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

  • למזלנו, כשמדובר בבעיות כאלה, לא צריך לנחש, כי ל-Firebase Crashlytics יש את התשובה בשבילנו.

  • אבל לפני שנתחיל בפתרון בעיות, הצביעו אם אתם מכירים את Crashlytics.

  • מעולה.

  • איזה כיף לראות את כל הידיים האלה.

  • למי שלא מכיר, Firebase Crashlytics הוא פתרון ל-Error Reporting בזמן אמת שמספק נתונים מהשטח, יחד עם תובנות לפי הקשר ותובנות מבוססות-AI, טיפים לפתרון בעיות ואותות ברורים לגבי הגרסאות החדשות שלכם, והכול כדי לעזור לכם לפתור בעיות מהר יותר.

  • מפתחים אוהבים את Crashlytics, והוא הפתרון הפופולרי ביותר לדיווח על קריסות בניידים. הוא מפעיל יותר מ-370,000 אפליקציות מקוריות, כולל 57% מהאפליקציות המובילות ל-Android.

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

  • היום אני רוצה לספר לכם את הסיפור של ChromaClip.

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

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

  • הסיפור שלנו מתחיל ממש לפני שהשקתי את גרסה 1.0.

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

  • אין תגובה מ-ChromaClip.

  • אז זו שגיאה מסוג "האפליקציה לא מגיבה", שנקראת גם ANR.

ניפוי באגים וניתוח של שגיאות ANR

  • Polina Govorkova

    אז מה עושים במצב כזה, חוץ מלהיכנס לפאניקה?

  • האינסטינקט הראשוני שלנו הוא לפתוח את Android Studio ולבדוק את Logcat, כי טלפון הבדיקה מחובר ישירות למחשב הנייד.

  • אנחנו רואים את דוח הקריסות של ה-thread הראשי.

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • מה זה בעצם אומר?

  • קודם כל, ממש למעלה, אנחנו מקבלים אישור שמדובר ב-ANR, שפועל ב-MainActivity.

  • מה עוד?

  • אנחנו רואים את הסיבה, שהיא פסק זמן של שליחת קלט.

  • זה אומר שהמשתמש לחץ על המסך.

  • כן, עשינו את זה.

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

  • בנוסף, אנחנו רואים שהמערכת העבירה את הקובץ אל traces.txt, ומתחת לזה, logcat מדפיס את דוח הקריסות של ה-thread הראשי.

  • שימו לב שהמצב שווה ל-S שם.

  • ה-thread הראשי שלנו אפילו לא צורך משאבי מעבד (CPU) באופן פעיל.

  • ‫S מייצגת שינה (Sleep).

  • למה ה-thread הראשי שלנו במצב שינה בזמן שאנחנו ממתינים?

  • בואו נבחן מקרוב את מסגרות הסטאק.

  • כאן, באמצע המעקב, אנחנו רואים שה-thread הראשי שלנו נמצא בתוך Unsafe.park וממתין לאובייקט לא ידוע.

  • למה אנחנו לא יודעים למה הוא מחכה?

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

  • אבל כשמשתמשים ברמה גבוהה יותר של פרימיטיבים בו-זמניים, כל מה ש-logcat אומר לנו הוא שאנחנו ממתינים לאובייקט לא ידוע.

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

  • ומכיוון שאנחנו בודקים באופן מקומי, אנחנו יכולים לשלוף את ה-traces.txt הזה מהמכשיר ולגלול ידנית בין עשרות שרשורים רדומים, ואולי לאתר את הגורם לבעיה או אולי להשתמש בטוקנים כדי לשאול את ה-AI.

  • במקרים מסוימים זה באמת יעבוד, וגם במקרה הזה.

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

  • אין לנו גישה ישירה לקובץ traces.txt בטלפונים של המשתמשים, נכון?

  • לכן, טוב ששילבנו את Firebase Crashlytics.

  • כי הוא חוסך את הצורך בחיפוש ומגיע לשורש הבעיה של ה-ANR, גם בסביבת הייצור.

  • איך?

  • כאן נכנס לתמונה ApplicationExitInfo API של Android.

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

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

  • כל עוד אתם משתמשים ב-Crashlytics SDK בגרסה 18.3.5 ואילך, זה פשוט עובד בלי צורך בהגדרות נוספות.

  • לכן, ברגע שהאפליקציה מופעלת מחדש, ערכת ה-SDK של Crashlytics שולחת באופן אוטומטי שאילתה ל-ApplicationExitInfo, מתעדת את ה-dump המלא של ריבוי הליכי המשנה לצד נתוני הטלמטריה של המכשיר, ושולחת אותו לקצה העורפי של Crashlytics.

  • נפתח את מסוף Crashlytics ונראה מה הוא אומר לנו.

  • זוהי תצוגת פרטי הבעיה במסוף Firebase Crashlytics עבור ה-ANR, ומיד רואים בכרטיסיית דוח הקריסות את ה-worker של DefaultDispatcher.

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • זהו למעשה השרשור שהופיע בחלק העליון של Crashlytics, והוא צוואר הבקבוק של השרשור הראשי שלנו. Crashlytics מסמן אותו בתג שנקרא "חסימת שורש".

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

  • אגב, ב-Crashlytics יש תגים חכמים גם לגורמים אחרים.

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

  • אם שני שרשורים נתקעו בהמתנה זה לזה, Crashlytics יתייג אותם כ"מבוי סתום".

  • עכשיו נסתכל על מסגרות הסטאק עצמן בתוך השרשור החוסם ברמה הבסיסית (root).

  • אז יש לנו את PaletteExtractor processVideo שקורא ל-FrameDecoder getUncompressedFrame, שמודגש בכחול, והוא תקוע לגמרי בתוך createBitmap, רק בניסיון להקצות זיכרון לפריים בודד.

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

  • אפשר לראות את שתי הדרכים שבהן Crashlytics מציג את זה.

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

  • ובשני המקרים, אופס, נראה שאין כמעט כלום, אפילו לא 100KB.

  • אז בואו נחשוב.

  • מה שקרה כאן הוא שהתהליך היה זקוק לזיכרון, ולכן סביבת זמן הריצה ל-Android‏ (ART) השקיעה את כל הזמן שלה בהרצה קדחתנית של איסוף אשפה, בניסיון למצוא מספיק מקום למפת הסיביות הבאה, ולא הצליחה.

  • הקפאת ה-thread העובד והרעבת ממשק המשתמש למשך יותר מחמש שניות.

  • הנה טיפ קצר בשבילכם.

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

  • עכשיו השאלה היא אם האפליקציה שלנו היא הקורבן או שהיא גם זו שאוגרת את הזיכרון?

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

בדיקת חריגות של חוסר בזיכרון

  • Polina Govorkova

    תסתכלו כאן.

  • בחלק התחתון מופיעה רשימת הבעיות, וממש מעל ה-ANR עם שמונת האירועים, יש חריגה מסוג OutOfMemory.

  • אז יכול להיות שאנחנו האשמים אחרי הכול.

  • וזה בעצם רמז ענק.

  • כי ב-Android, שגיאת OutOfMemoryError ב-Java, או בקיצור OOM, לא אומרת רק שלטלפון היה מעט זיכרון RAM.

  • זה אומר שתהליך האפליקציה שלנו חרג ממגבלת ה-Java heap של סביבת זמן הריצה ל-Android‏ (ART).

  • אז בשלב הזה, התיאוריה שלי היא ש-ANR ו-OOM הם למעשה שני צדדים של אותו מטבע.

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

  • כדי לדעת בוודאות, נבדוק את החריגה OutOfMemory.

  • נתחיל עם דוח הקריסות.

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • רגע,

  • ‫Bitmap.createBitmap, זה נשמע מוכר.

  • לא ראינו את זה כבר?

  • אבל הפעם, תסתכלו מאיפה הוא נקרא.

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

  • אנחנו נופלים שוב לאותה מלכודת.

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

  • הבעיה היא רק עם המתקשר חסר המזל שביקש טיפת זיכרון כשלא היה.

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

  • במילים אחרות, אנחנו צריכים תמונת מצב של הזיכרון מ-Java.

  • אם אתם יודעים איך להפעיל OOM, אתם יכולים ליצור באופן ידני תמונת מצב של הזיכרון ב-Java באמצעות Memory Profiler ב-Android Studio.

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

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

  • Polina Govorkova

    אז זה עוד רגע של תהילה ל-Firebase Crashlytics.

  • בנוסף למידע על יציאה מהאפליקציה, Crashlytics משתלב עכשיו עם ProfilingManager API של Android.

  • ‫ProfilingManager API ב-Android 17 כולל פרופיילינג של הזיכרון שמופעל על ידי המערכת, וזה בדיוק מה שאנחנו צריכים כאן.

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

  • ואז Crashlytics יקבץ באופן אוטומטי את סגירות האפליקציה בגלל OOM ו-Memory Limiter, נדבר עליהן בהמשך, במרכז הבקרה.

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

  • כך נראה ה-OOM של ChromaClip במסוף Crashlytics אחרי שה-heap נלכד.

  • בחלק העליון מופיע הסבר למה ה-heap של Java התמלא ומה גרם לזה.

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

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

  • לאחר מכן, מערכת Crashlytics מצרפת את קובץ Perfetto שעבר ביטול ערפול, תמונת המצב של הזיכרון, לסשן.

  • מכאן אפשר להוריד את הקובץ.

  • אנחנו יכולים לעיין בכל קובצי ה-dump ב-Google Cloud Storage, או לפתוח אותם בממשק המשתמש של Perfetto.

  • לכן, לחיצה על Analyze (ניתוח) בממשק המשתמש של Perfetto פותחת את Perfetto heap dump explorer (כלי הניתוח של תמונות מצב של הזיכרון ב-Perfetto).

  • עכשיו, הרימו את היד אם כבר ראיתם את זה.

  • אוקיי, יש כמה ידיים.

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

  • תסתכלו על קבוצת העמודות הענקיות שמשתרעות כמעט על כל ה-Java heap.

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

  • אופס!

  • אני חושב שאני יודע מה קורה.

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

  • לכן, לא פלא שהפעילות של ההגדרות לא הצליחה להקצות את 256 הבייטים האלה.

  • טוב, לפחות תעלומה אחת נפתרה.

  • רוצה לפתור את זה יחד?

  • בשלב הזה יש לנו כמה אפשרויות.

  • אז מכיוון ש-Perfetto כבר הפנה אותנו אל PaletteExtractor, אנחנו יכולים לפתוח את ה-IDE שלנו ולשכתב את הלולאה באופן ידני, או להשתמש בסוכן ה-AI לכתיבת קוד.

  • אפשר גם לעקוף לגמרי את הבדיקה הידנית של ה-heap ולהשתמש ב-Android CLI.

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • לכן, התקנת מיומנויות של Android CLI ו-Perfetto תאפשר לכם להציג ולהוריד תמונות מצב של הזיכרון ב-Crashlytics שעברו ביטול ערפול, לשלוח שאילתות לגבי האובייקטים המובילים שנשמרו מתמונות המצב האלה של הזיכרון דרך trace_processor, ולאחזר נתונים תואמים של בעיות ב-Crashlytics כמו מפתחות מותאמים אישית, יומנים, נתיבי ניווט, השקות משויכות ועוד, והכול מהטרמינל.

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

  • אל תריצו את הפקודות האלה בעצמכם.

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

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • ויהיה לו מספיק הקשר כדי לבצע את כל התהליך בשבילכם.

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

  • תראו את התיקון הזה בקובץ PaletteExtractor.

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • במקום להקצות מיליוני אובייקטים, אנחנו פשוט דוגמים את הפריים וסופרים מספרים שלמים גולמיים ישירות.

  • אז בואו נחיל את התיקון, נסגור את הבעיות, נשדרג את הגרסה ונמשיך בבדיקות.

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

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

  • וממשק המשתמש קופא שוב.

  • עוד מקרה של ANR.

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

  • אבל תיקנו את ה-OOM, אז מה קורה?

בדיקת בעיות בזיכרון נייטיב

  • Polina Govorkova

    טוב, נחזור ל-Crashlytics.

  • נתחיל בחדשות הטובות.

  • עיינו ברשימת הבעיות שלנו.

  • החריגה של חוסר בזיכרון נעלמה לגמרי, כמו שקיווינו.

  • והחדשות הרעות?

  • עכשיו, כשה-ChromaClip שורד קצת יותר זמן, יש לו זמן לגלות מה עוד מסתתר מתחת.

  • ולכן, ה-ANR לא נעלם.

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

  • ובמקום הראשון, עם 11 אירועים, יש לנו את Memory Limiter kill, תודה לאל, שכבר סומן כבעל תמונת מצב של הזיכרון.

  • רגע.

  • מה זה "סגירה על ידי מגביל הזיכרון"?

  • אפשר לראות בהרמת ידיים מי מכם שמע על זה?

  • טוב.

  • כמה ידיים נדירות.

  • בואו נלמד ביחד.

  • אם לא נתקלתם עדיין בפעולת הסגירה של Memory Limiter, זה בגלל שהיא חדשה לגמרי ב-Android 17.

  • מצב OOM ב-Java מופעל רק כשחורגים ממגבלת ה-Java heap המנוהלת. אבל כש<b>הזיכרון שבשימוש</b> הכולל של האפליקציה כולל גם זיכרון RAM מקורי, כמו מאגרי נתונים גרפיים והקצאות C++‎, לא הייתה בעבר תקרה לכל אפליקציה.

  • אז היה לנו את מנגנון ה-LMK, שהיה גלובלי.

  • הוא היה נכנס לפעולה כשאפליקציה מסוימת הייתה מנצלת את כל זיכרון ה-RAM של המערכת עד שהטלפון כולו היה נתקע.

  • עכשיו, החל מ-Android 17, הכלי להגבלת הזיכרון מגביל את הזיכרון שבשימוש בכל אפליקציה בנפרד.

  • לכן, כשהתהליך צורך כמות לא פרופורציונלית של זיכרון RAM, מערכת ההפעלה סוגרת את האפליקציה הספציפית הזו.

  • ‫Crashlytics מתעד את כל הפרטים האלה ומקבץ את הסגירות יחד בלוח הבקרה.

  • אז אם תלחצו על Memory Limiter kill ב-Crashlytics, בדיוק כמו עם OOM, תקבלו הסבר למה מערכת ההפעלה התערבה, כך שלא תצטרכו לזכור את מה שאמרתי עכשיו.

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

  • ובדיוק כמו במקרה של OOM, דוח הקריסות מאשים פריים חף מפשע כשהמערכת הפעילה את התקע, ולוח הבקרה של Crashlytics מדגיש אותו.

  • עכשיו, סגירות של Memory Limiter מתרחשות בעיקר בקוד Native, כי בסביבות Native חסרה השכבה שמטפלת בבטיחות הזיכרון ובהקצאות.

  • אחרת, הייתה מתרחשת שגיאת OOM.

  • אז השאלה הראשונה שלנו היא: האם אובייקטים של Kotlin מחזיקים הקצאות ענקיות שמגובות ב-Native?

  • או שזה קוד C++‎ גולמי שדולף מעצמו?

  • כדי לגלות, נלחץ על Analyze in Perfetto UI (ניתוח בממשק המשתמש של Perfetto).

  • מעיינים בכרטיסייה Overview (סקירה כללית) בכלי Perfetto heap dump explorer.

  • אנחנו רואים שבקטע "מידע כללי", ב-anon RSS plus swap, הזיכרון הכולל של התהליך הגיע ל-4GB.

  • אוקיי.

  • עם זאת, מתחת לזה, אנחנו רואים שבקטע Bytes Retained by Heap, כל ה-heap המנוהל שלנו, כולל זיכרון מקורי שקשור לאובייקטים של Kotlin כמו מפות סיביות, הוא רק 42MB.

  • אוקיי.

  • אז תוך 10 שניות, שללנו את כל הקוד של Kotlin ו-Java.

  • לכן, הגורם לבעיה חייב להיות דליפה משכבת C++‎ מקורית.

  • ול-ChromaClip יש שכבת C++‎ דקה לעיבוד פריימים, אז לא צריך לחפש רחוק.

  • אם מפנים את סוכן התכנות או את הכלי המובנה לניתוח פרופילים של Android Studio אל NativePaletteEngine.cpp processFrame, הבאג נמצא ממש שם.

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • אז בכל פריים של סרטון, קוד ה-C++‎ שלנו מקצה מאגר זמני, שלא כל כך זמני אם לא משחררים אותו, איזו טעות של מתחילים.

  • אז הגיע הזמן לחבר את הנקודות.

  • מה קרה?

  • אחרי שנתקן את דליפת הזיכרון ב-Java, לדליפת הזיכרון ב-C++‎ יהיה זמן להצטבר ולגדול.

  • ב-Android 17, מגביל הזיכרון זיהה את זה וסגר את האפליקציה.

  • אבל במכשירים ישנים יותר, שבהם עדיין לא היה מגביל זיכרון, הבעיה הזו גרמה לשגיאת ANR.

  • אז סוף סוף הכול הגיוני.

  • בואו נוסיף את delete[] scratch כאן, נשדרג את הגרסה ונשלח את העדכון.

  • עכשיו, כשאין יותר סגירות של אפליקציות בגלל מגבלת הזיכרון ואין יותר שגיאות ANR, אפשר להשיק את גרסה 1.0.

  • מרגש.

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

  • אלפי אנשים מעלים סרטונים ויוצרים מהם רמיקסים בכל יום.

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

  • גם הם משתוללים.

  • פתאום יש הרבה יותר מהם.

  • והעבודה שלנו עדיין לא הסתיימה.

בדיקת קריסות שנגרמו בגלל פגיעה בזיכרון נייטיב

  • Polina Govorkova

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

  • אין אפילו שורת קוד אחת מהאפליקציה שלנו.

  • ‫RenderThread קורס עמוק בתוך libc.so בזמן פינוי הזיכרון.

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

  • אז קוד ה-C++‎ שלנו פגם במאגר הנתונים הזמני או שחרר אותו מוקדם יותר, ושרשור העיבוד פשוט נתקל בו מאוחר יותר.

  • אז איך אפשר לתפוס את האשם האמיתי ברגע המדויק שבו מתרחשת ההשחתה?

  • ב-Android יש שני כלים חזקים, ו-Crashlytics תומך בשניהם באופן מובנה.

  • הראשון הוא GWP-ASan, רשת ביטחון קלה שנוצרה במיוחד לסביבת הייצור.

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

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

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

  • אז מה מקבלים?

  • לניפוי באגים דטרמיניסטי מקומי, יש את ARM MTE, או Memory Tagging Extension.

  • התכונה MTE משתמשת במצביע חומרה ובתגי זיכרון כדי לבדוק כל הקצאה, והיא זמינה במכשירי ARM v9 כמו Pixel 8 ואילך.

  • לכן, אם תשלבו אותו עם GWP-ASan בסביבת הייצור, תיהנו מכל העולמות.

  • כמו שאפשר לצפות, כשאחד מהכלים מזהה את הבאג, Crashlytics מתייג את הבעיה באופן אוטומטי ומאפשר לכם לסנן לפי GWP-ASan או MTE, ומצמיד כרטיס מידע עם הסיבה המדויקת לפגיעה בנתונים במרכז הבקרה.

  • עכשיו נראה מה קורה במסוף Crashlytics כש-GWP-ASan מזהה את הקריסה.

  • לצד הכרטיסייה הרגילה של דוח הקריסות, יש את דוח הקריסות של הזיכרון.

  • ובחלק העליון, אנחנו רואים את הסיבה המדויקת לפגיעה, Use After Free.

  • ובמקום להראות שרשור עיבוד שקורס ב-libc.so, ‏ GWP-ASan נותן לנו עכשיו את הביוגרפיה המלאה של המאגר הזה בתוך effect_pipeline.cc שלנו.

  • אז בקטע Allocated By (הוקצה על ידי) אנחנו רואים איפה נוצר המאגר.

  • וממש מתחת ל-Deallocated By, אנחנו רואים את הגורם המדויק ששחרר אותו משרשור העיבוד.

  • והוא מסתתר בשורה 208 בצינור עיבוד הנתונים של האפקטים שלנו.

  • אז אנחנו פותחים את קוד ה-Native ב-EffectPipeline, והבאג מחייך אלינו.

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • בכל פעם שמשתמש מחליף את מסנן הצבע בשרשור ברקע, אנחנו משחררים את המאגר הישן בזמן ש-RenderThread עדיין קורא אותו.

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

  • ומדד המשתמשים שהאפליקציה שלהם לא קרסה זינק ל-100%.

  • יש!

  • זוכרים שבתחילת השיחה שאלנו את עצמנו: "סיימתי?"

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

  • אז עכשיו כל הבעיות נפתרו: מקרי ה-ANR, השגיאות מסוג "אין זיכרון פנוי", הסגירות של האפליקציה בגלל מגבלת הזיכרון והקריסות בגלל פגיעה בזיכרון המקורי.

  • וגם אם דוח הקריסות לא מדויק והגורם לבעיה מסתתר איפשהו בין השרשורים ב-C++‎, ‏Android ו-Firebase Crashlytics נותנים לכם את הכלים לאתר את הבעיה ולשלוח את האפליקציה בראש שקט.

סיכום והרשמה לגישה מוקדמת

  • Polina Govorkova

    אז בואו נסכם מה אתם יכולים להשתמש בו מיד כשתחזרו למחשב הנייד: תובנות לגבי שרשורים של ANR ומטא-נתונים רלוונטיים אחרים דרך ApplicationExitInfo; איסוף אוטומטי של טריגרים להשבתה של OOM ומגביל הזיכרון דרך ProfilingManager; ותמיכה מלאה ב-GWP-ASan וב-Arm MTE לפגיעה בזיכרון המקורי.

  • בנוסף, אנחנו משיקים בגרסת טרום-השקה (Preview) פרטית את האפשרות ליצור תמונות מצב של הזיכרון בשטח ב-Android מגרסה 17 ואילך.

  • רוצים להיות בין הראשונים שינסו את האיסוף האוטומטי של תמונות מצב של הזיכרון ב-Firebase Crashlytics וינתחו אותן באמצעות Perfetto ו-Android CLI? צלמו את השקף והירשמו בקישור.

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

  • מי ייתן שה-thread הראשי שלכם לא ייחסם, שה-heap שלכם יישאר נקי ושהגרסה הבאה שלכם תהיה הכי יציבה עד כה.

  • בהצלחה בניפוי הבאגים.

  • שוב תודה רבה.

קוד

קטעי קוד

קטע טקסט בדקה 01:45 ב "מבוא"
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
קטע טקסט ב-01:58 ב "מבוא"
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
קטע טקסט ב-04:45 בסרטון "ניתוח שגיאות ANR וניפוי באגים"
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
קטע טקסט בדקה 08:08 בסרטון "ניתוח שגיאות ANR וניפוי באגים"
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
קטע טקסט בדקה 11:54 בסרטון Investigating אין זיכרון פנוי (OOM) exceptions
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
קטע קוד של Shell בדקה 15:54 בסרטון "תיעוד וניתוח של תמונות מצב של הזיכרון"
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
קטע טקסט בדקה 16:27 בסרטון "תיעוד וניתוח של תמונות מצב של הזיכרון"
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
קטע קוד ב-Kotlin בדקה 16:47 בסרטון Capturing and analyzing heap dumps
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
קטע קוד ב-C++‎ בדקה 21:06 בסרטון Investigating native memory issues
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
קטע קוד ב-C++‎ בדקה 25:14 בסרטון Investigating native memory corruption crashes
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}