איך להשאיר את העבודה ברקע פעילה באמצעות תהליך מצומצם

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

בדף הזה מוסבר למה זה פוגע באפליקציה שלכם ובשאר המכשיר, איך להעביר את העבודה ברקע לתהליך ייעודי וקל באמצעות android:process, ואיך לוודא שהזיכרון נשמר באמצעות adb shell ו-Perfetto.

למה עבודה בתהליך יחיד שומרת את הזיכרון של ממשק המשתמש

מערכת Android מנהלת את הזיכרון ואת סיום התהליכים ברמת הפירוט של תהליך במערכת ההפעלה (pid), ולא של רכיבים נפרדים (Activity,‏ Service או BroadcastReceiver). מנהל הפעילויות (OomAdjuster) מקצה לכל תהליך ערך oom_score_adj יחיד שמשקף את הרכיב הפעיל או את הקישור הנכנס החשובים ביותר בתהליך הזה:

  • כשהמשתמש מבצע אינטראקציה עם Activity, התהליך פועל במצב TOP (oom_score_adj = 0).
  • כשהמשתמש יוצא מהאפליקציה ואין לה רכיבים פעילים ברקע, התהליך עובר לקטגוריה PREV (oom_score_adj = 700) או CACHED (oom_score_adj >= 900). במאגר השמירה במטמון, הפלטפורמה מקפיאה את התהליך באמצעות cgroup v2 freezer, דוחסת את הזיכרון שלו באמצעות CachedAppOptimizer ומאפשרת ל-lmkd לסיים את התהליך קודם, כשיש עומס על הזיכרון.
  • עם זאת, אם אותו תהליך מפעיל גם שירות שפועל בחזית (כמו הסוגים dataSync,‏ mediaPlayback,‏ connectedDevice או location ב-oom_score_adj = 200) או מארח שירות שהמערכת או לקוח חשוב אחר מקושרים אליו, התהליך כולו נשאר בעדיפות הגבוהה הזו גם אחרי שה-Activity נעצר.
השוואה בין מיקום משותף של תהליך יחיד ששומר על זיכרון ממשק המשתמש מוצמד
       ב-oom_score_adj 200 לבין בידוד של תהליך מפוצל ששומר רק את
       ה-worker ב-oom_score_adj 200 בזמן שתהליך ממשק המשתמש יורד למטמון.
תרשים 1. מיקום משותף של תהליך יחיד בהשוואה לבידוד של תהליך מפוצל במהלך עבודה ברקע.

כשתהליך ממשק משתמש כבד נשאר ב-oom_score_adj = 200, כמה קטגוריות גדולות של זיכרון נשארות בזיכרון הפיזי:

  • Activity נעצר והיררכיית התצוגות: אלא אם Activity הסתיים באופן מפורש, ה-Window, עץ התצוגות, מבני הפריסה ומצב התצוגה שלו נשארים נגישים בערימת ה-Java בזמן שהמשתמש נמצא באפליקציה אחרת.
  • הקשרים של גרפיקה מקומית והקצאות GPU: HardwareRenderer רגיל שומר על ההקשר של ה-GPU ועל המטמונים שלו, ואילו Activity שהופסק נמצא בתהליך שלא נשמר במטמון. אפליקציות ומנועי משחקים שיוצרים הקשרים משלהם ל-Vulkan (VkDevice, VkSwapchainKHR, מאגרי מתארים, מטמונים של צינורות עיבוד נתונים) או ל-OpenGL ES (EGLContext, טקסטורות, מאגרי נתונים זמניים של קודקודים) שומרים גם את מבני הדרייברים, תוכניות ההצללה ומאגרי הנתונים הזמניים של הגרפיקה, אלא אם המנוע מפרק באופן מפורש את המכשיר הגרפי כשהוא נעצר.
  • פרגמנטציה של ערימה ותקורה בזמן ריצה: גם כשאפליקציה משחררת מטמונים ב-onTrimMemory, דפים של מקצה מקורי שאוכלסו באופן חלקי, דפים מלוכלכים של העתקה בזמן כתיבה של Zygote, ספריות משותפות שנטענו (.so) ומטא-נתונים של זמן ריצה נשארים בזיכרון הפרטי של התהליך. מכיוון שהתהליך נמצא מעל הסף של המטמון (oom_score_adj < 900), הפלטפורמה לא מקפיאה אותו ולא מפעילה עליו דחיסה של אפליקציות שנשמרו במטמון. למידע נוסף על ההשפעה הזו על שירותים מאוגדים, ראו מיקום משותף של שירות מאוגד עם ממשק משתמש שדורש הרבה זיכרון.

איך תהליך ברקע קל מגן על האפליקציה

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

  • מגבלות הזיכרון ברקע מחמירות יותר מהמגבלות בחזית. ב-Android מגרסה 17 ואילך, מגביל הזיכרון מחיל מגבלת זיכרון קטנה יותר שלא גלויה על האפליקציה, כל עוד אף אחד מהתהליכים שלה לא מציג ממשק משתמש גלוי, כולל בזמן שפועל שירות שפועל בחזית. המגבלה חלה על כל התהליכים של האפליקציה ביחד. אם התהליך שמפעיל את השירות שפועל בחזית עדיין מחזיק את ממשק המשתמש ואת זיכרון ה-GPU, הזיכרון הזה נכלל במגבלה הקטנה יותר. כשהאפליקציה חורגת מהמגבלה, הליבה (kernel) מאטה אותה בזמן שהיא מאחזרת את הדפים שלה, מה שמאט את ההורדה, הסנכרון או ההפעלה שפועלים. אם האפליקציה ממשיכה להקצות זיכרון אחרי ששטח ההחלפה נגמר, המערכת סוגרת אותה. כדי לבדוק אם האפליקציה הושפעה, עיינו בקטע מגבלות הזיכרון של האפליקציה.
  • תהליכים גדולים יותר מסתיימים קודם כשיש עומס על הזיכרון. כאשר lmkd צריך לפנות זיכרון ומוגדר לבחור את התהליך הכבד ביותר בקטגוריית חשיבות (ro.lmk.kill_heaviest_task), תהליך שירות שפועל בחזית שמחזיק גם מאות MB של זיכרון ממשק משתמש מסתיים לפני תהליכים קטנים יותר באותה חשיבות, והעבודה ברקע נפסקת יחד איתו. אינטראקציה בין kswapd ל-lmkd
  • אפשר להקפיא את מצב ממשק המשתמש שנשמר במטמון, לדחוס אותו ולכתוב אותו בחזרה בזיכרון ה-Flash. אם מאפשרים למצב של ממשק המשתמש שפועל בחזית לעבור לתהליך שנשמר במטמון, מערכת ההפעלה יכולה להקפיא את התהליך, לדחוס את הזיכרון שלו ל-zRAM ולכתוב את הדפים הקרים שלו בחזרה לאחסון Flash. הזיכרון שבשימוש של האפליקציה מצטמצם, אבל מצב ממשק המשתמש נשאר ללא שינוי. כך גם העבודה ברקע וגם ממשק המשתמש שנשמר במטמון יכולים להמשיך לפעול עד שהמשתמש חוזר.
  • תקציבי זיכרון לכל תהליך הופכים למעשיים. לתהליך שמריץ רק עבודה ברקע יש קבוצת עבודה קטנה וצפויה, כך שאפשר להקצות לו תקציב זיכרון מצומצם בלי להגביל את ממשק המשתמש.

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

בידוד של עבודה ברקע בתהליך ייעודי

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

עיבוד הצהרת מניפסט רכיבים מתארחים המצב שבו המשתמש יוצא מממשק המשתמש בזמן שהעבודה מתבצעת ברקע
תהליך ממשק המשתמש הראשי ברירת המחדל (com.example.app) ‫Activity, תצוגות של ממשק המשתמש, מטמוני תמונות, מנוע רינדור של Vulkan או OpenGL ES, מנוע אודיו וסצנות הוא יורד ל-PREV ואז ל-CACHED (oom_score_adj >= 900). הוא מוקפא, נדחס ונכתב חזרה לזיכרון ה-Flash.
תהליך של עובד ברקע android:process=":bg" ‫Service בחזית, הורדת נכסים ברקע, עובד סנכרון או מנהל מכשירים מחוברים פועל ב-FGS (oom_score_adj = 200) רק עם הקוד וה-heap של ה-worker, בלי תצוגות של ממשק משתמש או הקשרים של GPU.

הצהרה על תקציבי התהליכים והזיכרון

כדי להקצות את הרקע Service לתהליך משנה פרטי, צריך להגדיר את android:process=":bg" ב-AndroidManifest.xml. הנקודתיים (:) בתחילת השם יוצרות תהליך פרטי שמוגבל למזהה הייחודי של האפליקציה.

ב-Android 17 QPR2 (רמת API‏ 37.2) ואילך, משלבים פיצול תהליכים עם תקציבי זיכרון של אפליקציות לכל תהליך בתוך הבלוק <processes>, כדי שמערכת ההפעלה תאכוף תקרה מחמירה של קבוצת עבודה על ה-worker ברקע בלי להגביל את תהליך ממשק המשתמש בחזית:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:name=".HeavyApplication"
        android:label="@string/app_name">

        <!-- App-wide budgets that cover every process, including :bg -->
        <memory-budget android:maxMb="512" />
        <memory-budget
            android:maxMb="128"
            android:state="background" />

        <processes>
            <!-- Tight budget for the headless background worker process -->
            <process android:process=":bg">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <activity
            android:name=".MainActivity"
            android:exported="true" />

        <service
            android:name=".AssetDownloadService"
            android:process=":bg"
            android:foregroundServiceType="dataSync"
            android:exported="false" />

    </application>
</manifest>

השימוש בזיכרון ב-:bg נספר גם במסגרת התקציב שלו וגם במסגרת התקציב של כל האפליקציה. במכשירים עם גרסאות שקודמות ל-Android 17 QPR2, המערכת מתעלמת מ-<memory-budget>, ו-android:process=":bg" פועל בכל רמות ה-API.

הגבלת האתחול ב-Application.onCreate

מערכת Android יוצרת מופע של המחלקה Application וקוראת ל-Application.onCreate בכל תהליך שנוצר בשביל החבילה. טעות נפוצה כשמתחילים להשתמש ב-android:process=":bg" היא להשאיר ערכות כלים כבדות של ממשק משתמש, מאגרי טעינת תמונות, ערכות Analytics SDK או אתחול מנוע מקורי (System.loadLibrary) בתוך שיטת Application.onCreate לא מוגנת. אם תעשו את זה, תנפחו את התהליך של :bg באותו נפח בסיסי שאתם מנסים להימנע ממנו.

בודקים את Application.getProcessName, שזמין ב-Android 9 (רמת API‏ 28) ומעלה, בתוך onCreate ומפעילים רק את יחסי התלות שנדרשים לתהליך הנוכחי:

Kotlin

class HeavyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        val processName = Application.getProcessName()
        if (processName != null && processName.endsWith(BG_PROCESS_SUFFIX)) {
            initBackgroundWorkerProcess()
            return
        }
        initMainUiProcess()
    }

    private fun initBackgroundWorkerProcess() {
        // Initialize only lightweight HTTP, storage, and sync primitives.
        // Avoid loading native rendering libraries or allocating UI caches.
    }

    private fun initMainUiProcess() {
        // Initialize UI frameworks, image caches, and native engines.
        System.loadLibrary("heavy_engine_jni")
    }

    companion object {
        private const val BG_PROCESS_SUFFIX = ":bg"
    }
}

Java

public final class HeavyApplication extends Application {
    private static final String BG_PROCESS_SUFFIX = ":bg";

    @Override
    public void onCreate() {
        super.onCreate();
        String processName = Application.getProcessName();
        if (processName != null && processName.endsWith(BG_PROCESS_SUFFIX)) {
            initBackgroundWorkerProcess();
            return;
        }
        initMainUiProcess();
    }

    private void initBackgroundWorkerProcess() {
        // Initialize only lightweight HTTP, storage, and sync primitives.
        // Avoid loading native rendering libraries or allocating UI caches.
    }

    private void initMainUiProcess() {
        // Initialize UI frameworks, image caches, and native engines.
        System.loadLibrary("heavy_engine_jni");
    }
}

ספריות שמופעלות דרך ספק לניהול גישה, כמו Jetpack App Startup, מופעלות רק בתהליך הראשי, כי ספק לניהול גישה שמוצהר בלי android:process פועל בתהליך הראשי. אם הקוד ב-:bg תלוי בספרייה כזו, צריך להפעיל אותה באופן מפורש ב-initBackgroundWorkerProcess.

אם אתם מפתחים באמצעות מנוע משחק כמו Unity, הטמיעו את השירות שפועל ברקע בספריית Android רגילה או בפלאגין שכתוב ב-Java או ב-Kotlin, שלא מפנה למחלקות של המנוע כמו UnityPlayer. אחרי זה, התהליך :bg אף פעם לא טוען את ספריות ה-Native של המנוע. כדי לוודא שהתהליך הסתיים, בודקים ש-adb shell cat /proc/<pid>/maps של התהליך :bg לא מופיע ברשימה של libunity.so או libil2cpp.so.

תקשורת בלי להצמיד את תהליך ממשק המשתמש

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

  • הקישור של ממשק המשתמש ל-worker בטוח. כשהאפליקציה MainActivity נקשרת אל AssetDownloadService, המערכת מעלה את :bg לרמת החשיבות של ממשק המשתמש הגלוי. כשהמשתמש עוזב, תהליך ממשק המשתמש נשמר במטמון והקישור כבר לא מעלה שום דבר.
  • ה-worker שתלוי בתהליך של ממשק המשתמש לא בטוח. אם הקוד ב-:bg נקשר לשירות או שולח שאילתה לספק לניהול גישה שפועל בתהליך הראשי, המערכת מעלה את התהליך הראשי לרמת החשיבות הגבוהה של ה-worker, והזיכרון של ממשק המשתמש נשאר מוצמד שוב. במקום זאת, צריך לרשום קריאה חוזרת (callback) בממשק המשתמש עם ה-worker, כמו Messenger או ממשק קריאה חוזרת של AIDL. העברת אובייקט Binder כקריאה חוזרת (callback) לא משנה את החשיבות של אף אחד מהתהליכים.

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

Kotlin

class MainActivity : Activity() {
    private var downloadService: IBinder? = null

    private val downloadConnection = object : ServiceConnection {
        override fun onServiceConnected(
                name: ComponentName,
                service: IBinder
        ) {
            downloadService = service
            // Register a Messenger or AIDL callback for progress updates.
        }

        override fun onServiceDisconnected(name: ComponentName) {
            // The binding stays active and reconnects if the worker restarts.
            downloadService = null
        }
    }

    override fun onStart() {
        super.onStart()
        val intent = Intent(this, AssetDownloadService::class.java)
        bindService(intent, downloadConnection, Context.BIND_AUTO_CREATE)
    }

    override fun onStop() {
        super.onStop()
        if (downloadService != null) {
            // Unregister the progress callback here, before unbinding.
            downloadService = null
        }
        // Unbind the ServiceConnection even if onServiceConnected hasn't run
        // yet.
        unbindService(downloadConnection)
    }
}

Java

public final class MainActivity extends Activity {
    private IBinder downloadService;

    private final ServiceConnection downloadConnection =
            new ServiceConnection() {
                @Override
                public void onServiceConnected(
                        ComponentName name, IBinder service) {
                    downloadService = service;
                    // Register a Messenger or AIDL callback for progress
                    // updates.
                }

                @Override
                public void onServiceDisconnected(ComponentName name) {
                    // The binding stays active and reconnects if the worker
                    // restarts.
                    downloadService = null;
                }
            };

    @Override
    protected void onStart() {
        super.onStart();
        Intent intent = new Intent(this, AssetDownloadService.class);
        bindService(intent, downloadConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    protected void onStop() {
        super.onStop();
        if (downloadService != null) {
            // Unregister the progress callback here, before unbinding.
            downloadService = null;
        }
        // Unbind the ServiceConnection even if onServiceConnected hasn't run
        // yet.
        unbindService(downloadConnection);
    }
}

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

תיאום בטוח של מצב משותף בדיסק

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

  • ‫SQLite ו-Room: משתמשים ביומן כתיבה מראש (מצב היומן שמוגדר כברירת מחדל ב-Room ברוב המכשירים) כדי ששני התהליכים יוכלו לקרוא ולכתוב במסד הנתונים בו-זמנית. אל תשלחו קריאה אל enableMultiInstanceInvalidation מ-:bg. ‫Room מארח את שירות ביטול התוקף שלו בתהליך הראשי, כך שה-worker נקשר לתהליך הראשי ושומר על זיכרון ממשק המשתמש ברמת החשיבות הגבוהה של ה-worker. במקום זאת, צריך להגדיר שה-worker ידווח על שינויים באמצעות הקריאה החוזרת (callback) שממשק המשתמש רושם, ושהממשק יבצע שאילתה חוזרת למסד הנתונים כשהוא מתחיל.
  • העדפות: אל תשתפו את SharedPreferences בין תהליכים. משתמשים ב-DataStore בקוד עם כמה תהליכים או מעבירים את המצב באופן מפורש דרך IPC.
  • קובצי נכסים: כותבים חבילות נכסים שהורדו לקובץ זמני ב-:bg, קוראים ל-fdatasync ואז ל-posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) ב-:bg אחרי הכתיבה (כפי שמוצג בקטע החזרת דפי זיכרון למערכת ההפעלה), ומשנים את שם הקובץ באופן אטומי. צריך לאפשר לממשק המשתמש לקבל את התוצאה בפעם הבאה שהוא מופעל ומתחבר ל-worker, במקום לקרוא לתהליך של ממשק המשתמש שעלול להיות קפוא.

בדיקת ההשפעה ב-adb shell

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

1. השוואה בין מצבי תהליכים וציוני OOM

מפעילים את עומס העבודה ברקע, לוחצים על כפתור הבית כדי שה-Activity ייעצר, ושולחים שאילתה ל-Activity Manager:

adb shell dumpsys activity processes com.example.app | grep -E "Proc #|oom:|state:"

בארכיטקטורה של תהליך יחיד, התהליך הראשי נשאר מוגבה גם אם ה-Activity שלו נעצר. במשך כ-15 שניות אחרי שהמשתמש יוצא, Activity Manager שומר על תהליך שמריץ שירות שפועל בחזית ב-oom_score_adj = 50 (fg-service-act) ואז מוריד אותו ל-200 (fg-service). הפלט הבא תועד 20 שניות אחרי הלחיצה על "דף הבית":

    Proc #1: prcp     F/S/FGS  ---NFUATI  t: 0 13794:com.android.memorylab/u0a284 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS   lastRss=0.00 lastCachedRss=0.00

בארכיטקטורה של תהליך מפוצל, רק תהליך ה-worker נשאר ב-oom_score_adj = 200, ותהליך ממשק המשתמש הראשי יורד ל-PREV (oom_score_adj = 700) ואחר כך לקטגוריה שנשמרה במטמון (oom_score_adj >= 900):

    Proc #3: prcp     F/S/FGS  ---NFUATI  t: 0 13544:com.android.memorylab:remote/u0a284 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS   lastRss=0.00 lastCachedRss=0.00
    Proc #1: prev     b/ /LAST -------TI  t: 0 13312:com.android.memorylab/u0a284 (previous)
        oom: max=1001 curRaw=700 setRaw=700 cur=700 set=700
        state: cur=LAST set=LAST  lastRss=0.00 lastCachedRss=0.00

אחרי שנגמר הזמן הקצוב לתפוגה של האפליקציה הקודמת, התהליך הראשי עובר לקטגוריה של המטמון (cch, oom_score_adj = 900, previous-expired) ומאבד את כל יכולות התהליך שלו (---------), כולל זמן מעבד (CPU) משתמע (I). בשלב הזה המערכת מקפיאה אותו ויכולה לדחוס אותו או להחזיר אותו בכל שלב בלי להפריע לתהליך של ה-worker.

2. השוואה בין dumpsys meminfo בתהליכים שונים

משווים בין הסיכום של dumpsys meminfo של ה-worker המורחב לבין תהליך ממשק המשתמש:

adb shell dumpsys meminfo com.example.app:bg
adb shell dumpsys meminfo com.example.app

פלט לדוגמה של App Summary לתהליך ה-worker של MemoryLab בכתובת oom_score_adj = 200:

 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:      380                          13840
         Native Heap:     1548                           2832
                Code:      132                          55816
               Stack:      200                            208
            Graphics:        0                              0
       Private Other:      496
              System:     1926
             Unknown:                                    4168

           TOTAL PSS:     4682            TOTAL RSS:    76864       TOTAL SWAP PSS:      441

פלט לדוגמה של App Summary לתהליך של ממשק המשתמש של MemoryLab ב-oom_score_adj = 700, שמכיל את ההקצאה המקורית של 64MB ב-Private Other:

 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:     1316                          19816
         Native Heap:    30408                          31596
                Code:     3148                         115796
               Stack:      656                            664
            Graphics:        0                              0
       Private Other:    67044
              System:    13687
             Unknown:                                   71092

           TOTAL PSS:   116259            TOTAL RSS:   238964       TOTAL SWAP PSS:      259

תהליך ה-worker צריך פחות מ-5MB של PSS, כי הוא משתף את רוב הקוד ודפי זמן הריצה שלו עם ה-Zygote. באפליקציה שמרנדרת באמצעות Vulkan או OpenGL ES, השורה Graphics של תהליך ממשק המשתמש כוללת גם זיכרון GPU. הדגימה הזו נלכדה במכשיר וירטואלי של Cuttlefish עם עיבוד תוכנה, כך ש-Graphics הוא 0 בשני התהליכים.

3. בדיקת החשבונאות של cgroup לכל תהליך

לכל תהליך ב-UID יש ספריית cgroup v2 משלו בתוך cgroup הורה של האפליקציה. הפקודה הבאה מציגה את oom_score_adj, את memory.current ואת memory.high ברמת האפליקציה לכל תהליך באפליקציה:

adb shell '
  for PID in $(pidof com.example.app com.example.app:bg); do
    CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
    echo "pid=$PID ($(tr -d "\0" < /proc/$PID/cmdline))" \
      "adj=$(cat /proc/$PID/oom_score_adj)" \
      "memory.current=$(cat $CG/memory.current)" \
      "app.memory.high=$(cat ${CG%/*}/memory.high)"
  done
'

פלט לדוגמה של MemoryLab אחרי לחיצה על Home:

pid=15301 (com.android.memorylab) adj=700 memory.current=103260160 app.memory.high=966787072
pid=16518 (com.android.memorylab:remote) adj=200 memory.current=3608576 app.memory.high=966787072

תהליך ה-worker מחויב בפחות מ-4MB, ואילו תהליך ממשק המשתמש מחויב בכ-98MB. לכל תהליך יש memory.current משלו, אבל מגבלת הזיכרון מגדירה את המגבלה ב-cgroup הראשי שמכיל את כל התהליכים של האפליקציה. שתי השורות מציגות את אותו app.memory.high של 966,787,072 בייטים, שהוא המגבלה הבלתי נראית של 922MB בפלט הבא.

כדי לראות את המגבלות הגלויות והלא גלויות שהמגביל מפעיל במכשיר, מריצים את הפקודה הבאה:

adb shell am memory-limiter status

פלט לדוגמה ממכשיר עם זיכרון RAM בנפח 4GB, שבו המגבלה של האפליקציות הלא גלויות היא חצי מהמגבלה של האפליקציות הגלויות:

Memory limiter
  enabled                  limits=true              killing=true             ignored=none
  visible:                 memHigh=1843MB           memMax=2048MB            swapMax=1024MB
  not-visible:             memHigh=922MB            memMax=1024MB            swapMax=1024MB

בדיקת ההשפעה ב-Perfetto

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

1. הקלטת מעקב

מתעדים מעקב של 15 שניות שמתעד את מוני הזיכרון של התהליך, את זיכרון ה-GPU ואת השינויים ב-oom_score_adj בזמן שמתחילים משימה ברקע באפליקציה ועוברים למסך הבית:

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/process-splitting.perfetto-trace <<'EOF'
buffers: {
  size_kb: 32768
  fill_policy: DISCARD
}
data_sources: {
  config {
    name: "linux.process_stats"
    process_stats_config {
      scan_all_processes_on_start: true
      proc_stats_poll_ms: 250
    }
  }
}
data_sources: {
  config {
    name: "android.gpu.memory"
  }
}
data_sources: {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "oom/oom_score_adj_update"
      ftrace_events: "gpu_mem/gpu_mem_total"
    }
  }
}
duration_ms: 15000
EOF
adb pull /data/misc/perfetto-traces/process-splitting.perfetto-trace

2. בדיקת טראקים של תהליכים בממשק המשתמש של Perfetto

פותחים את המעקב בממשק המשתמש של Perfetto ומרחיבים את קבוצות התהליכים של com.example.app ו-com.example.app:bg:

  1. מצמידים את הטראקים oom_score_adj ו-mem.rss.anon של שני התהליכים כדי שהם יופיעו זה לצד זה בחלק העליון של ציר הזמן.
  2. מאתרים את חותמת הזמן שבה המשתמש לוחץ על "דף הבית":
    • בתהליך ה-worker, הערך של oom_score_adj נשאר 200 והערך של mem.rss.anon נשאר קבוע וקטן.
    • בתהליך של ממשק המשתמש, הערך של oom_score_adj עולה מ-0 ל-200, נשאר שם למשך כשנייה בזמן שה-Activity נעצר, ואז עולה ל-700 (PREV). אחרי הזמן הקצוב לתפוגה של האפליקציה הקודמת, הוא עובר לטווח השמירה במטמון, שבו המערכת יכולה לדחוס את הזיכרון שלו ולתבוע אותו בחזרה בלי להשפיע על ה-worker.
מעקבי Perfetto לתהליך של ממשק המשתמש ולתהליך של ה-worker. הערך של oom_score_adj בתהליך של ממשק המשתמש עולה מ-0 ל-200 ואז ל-700 אחרי Home, בעוד שהערך של תהליך ה-worker נשאר על 200 עם כ-19MB של RSS אנונימי.
איור 2. התהליכים מתפצלים אחרי שהמשתמש לוחץ על "דף הבית".

בתמונה 2, תהליך ממשק המשתמש נשאר לזמן קצר ב-200 בזמן ש-Activity שלו נעצר ואז עולה ל-700, בזמן שה-worker נשאר ב-200 עם כ-19MB של RSS אנונימי. לשם השוואה, באיור 3 מוצג מעקב של 35 שניות אחרי ההגדרה של מיקום משותף, שבה התהליך היחיד נשאר ב-50 ואז ב-200 עם כל הזיכרון של ממשק המשתמש שלו.

מעקב של Perfetto אחרי תהליך יחיד באותו מיקום. oom_score_adj עולה מ-0 ל-50 אחרי Home ול-200 כ-15 שניות לאחר מכן, בעוד ש-RSS אנונימי נשאר גבוה.
איור 3. תהליך שמתרחש באותו מיקום אחרי שהמשתמש לוחץ על "דף הבית".

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

3. שאילתה על הזיכרון שבשימוש המוגבר באמצעות PerfettoSQL

מריצים את שאילתת PerfettoSQL הבאה בכרטיסייה Query (SQL)‎ כדי למדוד את ה-RSS הממוצע של קבצים אנונימיים וקבצים רגילים שכל תהליך באפליקציה מחזיק בכל רמה של oom_score_adj:

INCLUDE PERFETTO MODULE android.memory.process;

SELECT
  process_name,
  score AS oom_score_adj,
  ROUND(SUM(dur) / 1e9, 2) AS duration_sec,
  ROUND(SUM(anon_rss * dur) / SUM(dur) / 1048576.0, 1) AS avg_anon_mb,
  ROUND(SUM(file_rss * dur) / SUM(dur) / 1048576.0, 1) AS avg_file_mb
FROM memory_oom_score_with_rss_and_swap_per_process
WHERE process_name GLOB 'com.example.app*'
GROUP BY process_name, score
ORDER BY process_name, score;

פלט לדוגמה של המעקב המפוצל באיור 2:

process_name                  oom_score_adj  duration_sec  avg_anon_mb  avg_file_mb
com.android.memorylab                     0          4.11         93.3        110.4
com.android.memorylab                   200          1.06        126.8        110.5
com.android.memorylab                   700          9.72        115.1        110.5
com.android.memorylab:remote              0          0.01         NULL         NULL
com.android.memorylab:remote            200         12.87         18.9         53.7

פלט לדוגמה של המעקב המשותף באיור 3:

process_name           oom_score_adj  duration_sec  avg_anon_mb  avg_file_mb
com.android.memorylab              0          4.10         96.0        110.4
com.android.memorylab             50         16.04        117.6        110.5
com.android.memorylab            200         14.68        116.5        110.6

בהגדרת הפיצול, רק כ-19MB של זיכרון אנונימי נשארים ב-oom_score_adj = 200 אחרי השנייה הראשונה. בתצורה של מיקום משותף, כ-117MB נשארים ב-50 ואז ב-200 כל עוד השירות שפועל בחזית פועל.


← החזרת דפי זיכרון למערכת ההפעלה | ↑ למעלה