メモリの再利用: 強制排除とスワップ

前のセクションでは、カーネルがシステム全体でメモリを管理する方法について説明しました。この章では、Android が個々のアプリのメモリ使用量をパーティショニングして制御するために使用するメカニズムであるメモリ制御グループ(memcg)について詳しく説明します。

memcg は、システムが個々のアプリを再利用の対象にしたり、メモリ上限を設定したりできるレベルであるため、memcg を理解することは非常に重要です。これにより、アプリのメモリ フットプリントを事前に管理できます。

メモリ コントロール グループ(memcg)

コントロール グループ(cgroup)は、プロセスを階層グループに編成し、システム リソース(CPU、メモリ、I/O など)をそれらの間で分散できるようにする Linux カーネル機能です。memcg は、メモリ専用の cgroup コントローラです。

memcg の主なコンセプト

  • Memcg Charge: memcg 内のプロセスがメモリページ(匿名またはファイル バックアップ)を割り当てると、そのページは memcg に「チャージ」されます。memcg の合計料金は、その中のすべてのプロセスで使用されているすべてのページの合計です。
  • 階層型アカウンティング: メモリ使用量がツリーの上位に報告されます。子 cgroup の課金は、親の使用量にもカウントされます。
  • メモリ上限: 各 memcg には、グローバル システム メモリとは無関係に、上限(memory.maxmemory.high など)を設定できます。上限を超えると、再利用がトリガーされたり、OOM Killer が起動したりします。
  • memcg ごとの再利用: memcg が上限を超えた場合、またはシステムでメモリが必要な場合、カーネルは再利用の対象として特定の memcg を指定できます。つまり、ファイルページを削除するか、匿名ページを ZRAM にスワップします。

Android の memcg 階層

Android は、特定の階層を使用してアプリのプロセスを管理します。この構造により、システムはさまざまな種類のアプリ(フォアグラウンドとバックグラウンドなど)に異なるポリシーを適用できます。

Android memcg 階層

  • /sys/fs/cgroup/apps/: すべての Android アプリケーションのルート。
  • uid_<UID>/: 各アプリのユーザー ID のディレクトリ。同じアプリ パッケージに属するすべてのプロセスがこのグループを共有します。
  • pid_<PID>/: 個々のプロセスと、そこからフォークされた子プロセスのディレクトリ。これにより、複数のプロセスを持つアプリのきめ細かい制御とアカウンティングが可能になります。

実践型演習: memcg の探索

この演習では、MemoryLab の memcg ディレクトリを見つけ、メモリのチャージをリアルタイムで確認します。

1. MemoryLab を起動する

デバイスで MemoryLab が実行されていることを確認します。

2. MemoryLab の memcg を見つける

まず、実行中の MemoryLab アプリの PID を取得します。

adb shell pidof com.android.memorylab
# Example output: 11672

次に、その cgroup ディレクトリを探します。これは /proc/<PID>/cgroup で確認できます。

adb shell cat /proc/11672/cgroup
# Example output: 0::/apps/uid_10274/pid_11672

cgroup v2 では、0:: の後のパスは /sys/fs/cgroup を基準とした memcg 階層を表します。したがって、完全なパスは /sys/fs/cgroup/apps/uid_10274/pid_11672/ です。

3. memcg の統計情報を読み取る

そのディレクトリに移動して、キーファイルを確認します。

# Current memory usage (in bytes)
adb shell cat /sys/fs/cgroup/apps/uid_10274/pid_11672/memory.current
# Example output:
# 1591324672

memory.current には、現在この cgroup に課金されているメモリの合計量(バイト単位)が表示されます。

# Detailed statistics
adb shell cat /sys/fs/cgroup/apps/uid_10274/pid_11672/memory.stat | head -n 10
# Example output:
# anon 1585098752
# file 741376
# kernel 5484544
# kernel_stack 393216
# pagetables 4583424
# sec_pagetables 0
# percpu 216
# sock 0
# vmalloc 4096
# shmem 20480

memory.stat には内訳が表示されます。* anon: 匿名メモリ(ヒープ、スタック)の量。* file: ファイル バックアップ メモリ(ページ キャッシュ)の量。* swap: ZRAM にスワップアウトされたメモリ量。

4. 変更をモニタリングする

  1. MemoryLab を開きます。
  2. memory.current の値をメモします。
  3. [Allocate Java Memory (10MB)] を数回タップします。
  4. memory.current をもう一度お読みください。タップするたびに約 10 MB ずつ増加します。
  5. [ビットマップを割り当てる] をタップします。memory.stat を確認して、anon が増加していることを確認します。

memory.reclaim によるプロアクティブな再利用

memcg(v2)の強力な機能は memory.reclaim ファイルです。このファイルに値を書き込むと、カーネルは、この memcg とその下の memcg から、その量のメモリを直ちに回収しようとします。

Android での memory.reclaim の使用方法

Android の CachedAppOptimizer は、この機能を使用して、アプリがフリーズされた後にアプリからメモリを最大限に再利用します。Android のアプリのフリーズ機能により、キャッシュに保存されたアプリが実行されていない間、できるだけ少ない RAM を消費するようにします。アプリがバックグラウンドに移動してフリーズすると、システムはアプリの現在のメモリ使用量を memory.reclaim ファイルに書き込みます。これにより、カーネルは可能なすべてのファイルページを強制的に削除し、すべてのアノニマス ページを ZRAM にスワップして、アプリの常駐フットプリントを最小限に抑えます。

memory.current を読み取って memory.reclaim に書き戻すことで、同じ「最大再利用」を手動で実現できます。

# Force reclaim of everything
adb shell "cat /sys/fs/cgroup/apps/uid_10274/pid_11672/memory.current > /sys/fs/cgroup/apps/uid_10274/pid_11672/memory.reclaim"

演習: 強制的な再利用とトレース

次に、カーネルに MemoryLab からメモリを回収させ、Perfetto トレースでアクティビティをキャプチャします。

  1. 準備: MemoryLab に割り当て(Java とビットマップ)があることを確認します。
  2. トレースを開始: このインライン トレース構成を使用して、スケジューリング、再利用、ページ キャッシュのイベントをキャプチャします。

    # Use a config that captures scheduling, reclaim and page cache events
    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/reclaim.perfetto-trace <<EOF
    buffers: {
        size_kb: 131072
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "linux.ftrace"
            ftrace_config {
                ftrace_events: "sched/sched_switch"
                ftrace_events: "sched/sched_wakeup"
                ftrace_events: "kmem/rss_stat"
                ftrace_events: "mm_filemap_add_to_page_cache"
                ftrace_events: "mm_filemap_delete_from_page_cache"
                ftrace_events: "vmscan/mm_vmscan_direct_reclaim_begin"
                ftrace_events: "vmscan/mm_vmscan_direct_reclaim_end"
                ftrace_events: "vmscan/mm_vmscan_memcg_reclaim_begin"
                ftrace_events: "vmscan/mm_vmscan_memcg_reclaim_end"
                symbolize_ksyms: true
            }
        }
    }
    data_sources: {
        config {
            name: "linux.process_stats"
            process_stats_config {
                scan_all_processes_on_start: true
            }
        }
    }
    EOF
    
  3. トリガーの圧力と再利用:

    • MemoryLab で [Allocate Native Memory (1GB)] をタップします。これにより、システム全体のプレッシャーがトリガーされます。
    • 別のターミナルで、200 MB を再利用します。

      adb shell "echo 200M > /sys/fs/cgroup/apps/uid_10274/pid_11672/memory.reclaim"
      
  4. Fault Back In: MemoryLab に戻します。[Thrash Pagecache (Refault test)] をタップします。

  5. トレースを停止: トレース ターミナルで Ctrl+C キーを押します。

Perfetto で再利用を分析する

トレースを開くと、システム全体とアプリごとの再利用の両方が動作していることを確認できます。

Perfetto に直接再利用と memcg 再利用が表示されている

トレースで次のものを探します。

  • kswapd: [Kernel threads] で kswapd0 を検索します(上のスクリーンショットでは、手動で上部に固定されています)。システムが空きページを見つけようとすると、スリープ状態から復帰して実行される(緑色のスライス)様子が表示されます。
  • 直接再利用: com.android.memorylab プロセス スレッドを確認します。スレッドのスケジューリング トラックのすぐ下に、紫色の ftrace イベント スライス(mm_vmscan_direct_reclaim_begin など)が表示されます。これは、アプリスレッド自体がカーネルによるページの解放を待機して停止していることを示します。
  • RSS とスワップ カウンタ:
    • mem.rss.anon: 割り当てボタンをタップするたびに増加します。
    • mem.swap: kswapd とアプリ独自のスレッド(直接再利用の対象)が匿名ページを ZRAM に圧縮するにつれて、着実に増加します。
  • memcg Reclaim: 手動で再利用をトリガーした瞬間にズームインすると、rss.anonrss.file の両方が急激に減少し、mm_vmscan_memcg_reclaim イベントが発生していることがわかります。

← システム全体 | ↑ 上へ | kswapd と lmkd の相互作用 →