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

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

memcg を理解することは重要です。なぜなら、システムが個々のアプリを対象としてメモリを回収したり、メモリ制限を設定したりできるレベルであり、アプリのメモリ使用量をプロアクティブに管理できるからです。

メモリ制御グループ(memcg)

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

memcg の主なコンセプト

  • Memcg Charge: memcg 内のプロセスがメモリのページ(匿名またはファイルベース)を割り当てると、そのページは memcg に「チャージ」されます。memcg の合計チャージは、その中のすべてのプロセスで使用されるすべてのページの合計です。
  • 階層的なアカウンティング: メモリ使用量はツリーの上位でアカウンティングされます。子 cgroup のチャージは、親の使用量にもカウントされます。
  • メモリ上限: 各 memcg は、memory.high を介してソフト再利用しきい値をサポートします。アプリのメモリ予算は、このしきい値を使用して、OOM キラーを呼び出すことなく、ターゲットを絞った再利用と割り当てのスロットリングをトリガーします。また、memory.max を介したハードシーリングもサポートしています。これは、再利用で使用量をシーリング未満に維持できない場合に、システムメモリの上限を使用して暴走プロセスを終了します。
  • 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. [Allocate Bitmaps] をタップします。memory.stat で anon の増加を確認します。

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

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

Android が memory.reclaim を使用する仕組み

Android の CachedAppOptimizer は、この機能を使用して、アプリがフリーズされた後にアプリからメモリを最大限に回収します。Android の App Freezer は、キャッシュに保存されたアプリが実行されていないときに、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 での再利用の分析

トレースを開くと、システム全体とアプリごとの両方の再利用が実行されていることがわかります。

直接再利用と memcg 再利用を示す Perfetto

トレースで次の点を確認します。

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

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