Hatena::ブログ(Diary)

naoyaのはてなダイアリー

February 12, 2008

Linux のプロセスが Copy on Write で共有しているメモリのサイズを調べる

Linux は fork で子プロセスを作成した場合、親の仮想メモリ空間の内容を子へコピーする必要があります。しかしまともに全空間をコピーしていたのでは fork のコストが高くなってしまいますし、子が親と同じようなプロセスとして動作し続ける場合は、内容の重複したページが多数できてしまい、効率がよくありません。

そこで、Linux の仮想メモリは、メモリ空間を舐めてコピーするのではなく、はじめは親子でメモリ領域を共有しておいて、書き込みがあった時点で、その書き込みのあったページだけを親子で個別に持つという仕組みでこの問題を回避します。Copy-On-Write (CoW) と呼ばれる戦略です。共有メモリページは、親子それぞれの仮想メモリ空間を同一の物理メモリにマッピングすることで実現されます。より詳しくは コピーオンライト - Wikipedia などを参照してください。

この CoW による親子でのメモリの共有はメモリを多く必要とするプロセスを多数生成するようなプログラムを動かす場合に特に重要です。mod_perl で Perl ウェブアプリケーションを動かす場合、もしくは FastCGI でスクリプト言語で書かれたアプリケーションを常駐させる場合などが顕著な例です。

例えば mod_perl の場合、MaxClients の設定に共有領域のサイズを考慮する必要があります。この辺りは竹迫さんによる 2003 年の Shibuya.pm の発表資料が詳しいです。

4年とちょっと前の Shibuya.pm です。4年前に聞いた話を今になって掘り起こしているとは、我ながら進歩があるのかないのか。

もとい、今日たまたまプロセスのメモリ空間中の CoW で共有されている領域がどの程度あるかを計測する方法を調べてみたのですが、予想に反して、ps などのユーティリティで簡単に調べる方法が見つけられませんでした。(プログラムを直接変更できるのであれば libgtop や Perl の GTop を使う手があります。) 結局 Google から調べて /proc/PID/smaps を調べれば良いということがわかりました。Perl:forkしたプロセス間でのメモリ領域の共有 (Link: 遅レス。 - Apache + mod_perl - MaxClients の値に注意) | Typemiss.net が大変参考になりました。

/proc/PID/smaps では仮想メモリ空間の各アドレスにマッピングされた領域の状態一覧を参照することができます。

% cat /proc/12225/smaps| head
08048000-080bb000 r-xp 00000000 75:00 65545      /bin/zsh4
Size:                460 kB
Rss:                 460 kB
Shared_Clean:        460 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:          460 kB
080bb000-080be000 rw-p 00073000 75:00 65545      /bin/zsh4
Size:                 12 kB

この smaps にある各領域のサイズのうち、Shared_Clean と Shared_Dirty のサイズを集めて合計すれば、そのプロセスが他プロセスと共有しているメモリのサイズがわかります。smaps は man proc によると、Vanilla カーネル 2.6.14 から利用可能だそうです。

Perl には Linux::Smaps という /proc/PID/smaps のデータをプログラマブルに扱えるモジュールがあります。これを使って、共有領域のサイズを調べる簡単なスクリプトを作りました。

#!/usr/bin/env perl
use strict;
use warnings;
use Linux::Smaps;

@ARGV or die "usage: %0 [pid ...]";

printf "PID\tRSS\tSHARED\n";

for my $pid (@ARGV) {
    my $map = Linux::Smaps->new($pid);
    unless ($map) {
        warn $!;
        next;
    }

    printf
        "%d\t%d\t%d (%d%%)\n",
        $pid,
        $map->rss,
        $map->shared_dirty + $map->shared_clean,
        int((($map->shared_dirty + $map->shared_clean) / $map->rss) * 100)
}

引数に与えられた PID のプロセスの、共有領域サイズを調べて表示します。例えば mod_perl 組み込みの httpd で実行すると

% shared_memory_size.pl `pgrep httpd`
PID     RSS     SHARED
6615    79160   44424 (56%)
6656    78832   43268 (54%)
6657    75680   43436 (57%)
6658    77480   43332 (55%)
6661    77600   43952 (56%)
6665    78452   43952 (56%)
...

という結果が得られます。50% 強が共有領域で、まずまずの結果ではないかと思います。

テストプログラムを動かして /proc/PID/smaps の様子を見る

さて、/proc/PID/smaps で共有メモリのサイズが分かったのはいいとして、実際 /proc/PID/smaps のデータはどのようなデータになっているのでしょうか。カーネルの処理を追ってみたいと思います。

まずは、試しにテスト用のプログラムを作って、データの変化を見てみました。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

enum {
    BUFSIZE = 1024 * 1000 * 100,  // 100MB
};

char buf[BUFSIZE];

int main (int argc, char **argv)
{
    for (int i = 0; i < BUFSIZE; i++)
        buf[i] = 'a';

    fprintf(stderr, "PID: %d\n", getpid());

    while (1) sleep(100);
    return 0;
}

100MB 分のデータ領域(バッファ)を確保して、sleep するプログラムです。なお、バッファの領域を確保しただけだと bss に割り当てられてしまい実メモリを消費しないので、起動直後にそこに 100MB 分データの書き込みを行っています。

このプログラムを動かしてプロセスの smaps を見てみます。

0804a000-0e1f2000 rw-p 0804a000 00:00 0          [heap]
Size:             100000 kB
Rss:              100000 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:    100000 kB
Referenced:       100000 kB

調度 100000kB (100MB) の領域がありました。ここがプログラム中の buf 配列に相当する仮想メモリ領域です。100MB の物理メモリ領域とマッピングされているので、Rss が 100000 kB です。Private_Dirty が 100000 kB になっている点が非常に気になります。

次に、このプログラム内でプロセスを fork させて CoW で共有した領域がどうなっているか、確認します。(コメントアウトした箇所はあとで有効にします。)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

enum {
    BUFSIZE = 1024 * 1000 * 100,  // 100MB
};

char buf[BUFSIZE];

int main (void)
{
    for (int i = 0; i < BUFSIZE; i++)
        buf[i] = 'a';

    pid_t pid = fork();
    if (pid == 0) {
        /*
        for (int i = 0; i < BUFSIZE / 2; i++)
            buf[i] = 'b';  // creating private pages
        */

        while (1) sleep(100);
    }

    fprintf(stderr, "parent: %d, child: %d\n", getpid(), (int)pid);
    wait(NULL);

    return 0;
}

親子で二つのプロセスが立ち上がった後、親の smaps を確認してみます。

0804a000-0e1f2000 rw-p 0804a000 00:00 0          [heap]
Size:             100000 kB
Rss:              100000 kB
Shared_Clean:          0 kB
Shared_Dirty:     100000 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:       100000 kB

先ほどは Private_Dirty だった 100000 kB の領域が、Shared_Dirty になっているのが分かります。ここが CoW で共有している領域であることが推測されます。

次に、プログラム中のコメントアウト部分を有効にします。この部分を有効にすると、親と子で共有した領域の前半 50MB に子が書き込み処理を行います。結果、配列のうち前半 50MB は親子で別々の領域にマッピング、後半 50MB はかわらず共有領域としてマッピングされるようになるはずです。

smaps を見てみます。

0804a000-0e1f2000 rw-p 0804a000 00:00 0          [heap]
Size:             100000 kB
Rss:              100000 kB
Shared_Clean:          0 kB
Shared_Dirty:      50000 kB
Private_Clean:         0 kB
Private_Dirty:     50000 kB
Referenced:       100000 kB

50000 kB (50MB) が Shared_Dirty、50000 kB (50MB) が Private_Dirty になりました。予想通りです。以上から

  • CoW で共有される領域は Shared_Dirty
  • 共有されていないプライベートデータは Private_Dirty

としてそれぞれのサイズが報告されているのだろうと予想がつきます。

/proc/PID/smaps のカーネルのコードを追う

より具体的に、Shared や Private が一体どのような区別なのかを調べるためカーネルの proc ファイルシステムのコードを追ってみました。ソースは 2.6.23 の i386 です。

proc ファイルシステムのコードを見ていくと、/proc/PID/smaps が read されると fs/proc/task_mmu.c の show_smap() 関数が呼ばれるようになっているのがわかります。show_smap() 内では、仮想メモリ空間の全ページを渡り歩いて各ページに対し smaps_pte_range() 関数を実行する処理が行われています。walk_page_range() はデータ構造を再帰的に渡り歩き、そのデータに対して与えられた関数を実行していくという、Visitor パターンのような関数です。

static int show_smap(struct seq_file *m, void *v)
{
    struct vm_area_struct *vma = v;

    /* 新しい mem_size_stats 構造体を用意 */
    struct mem_size_stats mss;

    /* ゼロクリア */
    memset(&mss, 0, sizeof mss);

    /* 仮想メモリ空間の全ページを渡り歩いて smap_pte_range() を実行 */
    if (vma->vm_mm && !is_vm_hugetlb_page(vma))
        walk_page_range(vma, smaps_pte_range, &mss);

    return show_map_internal(m, v, &mss);
}

mem_size_stats は smaps の各エントリに相当する構造体のようで、以下のようなメンバがあります。

struct mem_size_stats
{
    unsigned long resident;
    unsigned long shared_clean;
    unsigned long shared_dirty;
    unsigned long private_clean;
    unsigned long private_dirty;
    unsigned long referenced;
};

smaps の出力と各メンバが一対一で対応しているのが分かります。

walk_page_range() に与えられた関数 smaps_pte_range() が、各ページテーブルエントリの属性等から、そのページテーブルに格納されているページが実際どのような種類のページとしてマッピングされているかを判定して、mem_size_stats (mss) に集計結果を格納する処理を行っていました。処理内容にコメントをつけてみました。

static void smaps_pte_range(struct vm_area_struct *vma, pmd_t *pmd,
                unsigned long addr, unsigned long end,
                void *private)
{
    struct mem_size_stats *mss = private;
    pte_t *pte, ptent;
    spinlock_t *ptl;
    struct page *page;

    /* PTE にロックをかける */
    pte = pte_offset_map_lock(vma->vm_mm, pmd, addr, &ptl);

    /* 引数に与えられたアドレス空間を終端まで舐める */
    for (; addr != end; pte++, addr += PAGE_SIZE) {
        ptent = *pte;

        /* PTE にページが割り当てられていなければ何もせず、次へ */
        if (!pte_present(ptent))
            continue;

        /* RSS にページ一つ分のサイズ (基本 4kB) を足しこむ */
        mss->resident += PAGE_SIZE;

        /* ページを取り出して通常のページでなければ次へ */
        page = vm_normal_page(vma, addr, ptent);
        if (!page)
            continue;

        /* Accumulate the size in pages that have been accessed. */
        if (pte_young(ptent) || PageReferenced(page))
            mss->referenced += PAGE_SIZE;

        /* ここが重要: ページの参照カウントが 2 以上なら shared、1 なら private */
        if (page_mapcount(page) >= 2) {
            /* PTE にダーティフラグが立っていたら shared_dirty */
            /*   そうでなければ shared_clean として集計        */
            if (pte_dirty(ptent))
                mss->shared_dirty += PAGE_SIZE;
            else
                mss->shared_clean += PAGE_SIZE;
        } else {
      /* PTE のダーティフラグを見る。shared の場合と同じ */
            if (pte_dirty(ptent))
                mss->private_dirty += PAGE_SIZE;
            else
                mss->private_clean += PAGE_SIZE;
        }
    }
    
    /* ロック解除 */
    pte_unmap_unlock(pte - 1, ptl);
    cond_resched();
}

smaps で報告されている Shared_Clean / Shared_Dirty は複数以上の仮想メモリ領域からマッピングされているページの合計数を数えていることがわかります。

親と子の仮想領域から同じページをマップすると被マップ数は 2 になりますから、その領域は Shared に分類されます。そのページにダーティフラグが立っていれば、Shared_Dirty になります。ダーティフラグが立っているページは、デバイスにマッピングされていれば定期的に回収されますが、そうでなければそのままメモリ領域に残るはずです。 同期が取れた時点でフラグが落ちる、つまり Clean になりますが、そうでなければページは Dirty のまま残るはずです。

先のテストで、プロセスが一つのときはすべて Private_Dirty に、プロセスが二つになると Shared_Dirty に、半分の領域に書き込むと Shared / Private に半々になるのが確認できましたが、すべてこれで説明がつきます。

  • プロセスが一つの場合は、バッファ領域の被マップ数は 1 です。またページ確保後に書き込みを行っている (bss への割り当て回避のために 'a' で初期化しています) ので該当領域のページテーブル群はダーティフラグが立ちます。 → Private_Dirty 100MB となります。
  • fork で子プロセスを作ると、バッファ領域の被マップ数は親と子からで合計 2 になり、各ページは Shared と判定されます。プロセス一つの場合同様、バッファ領域には書き込みを行っているためダーティフラグが立っています。 → Shared_Dirty 100MB となります。
  • 子プロセスでバッファの前半に書き込みを行うと、CoW により前半部分は親と子で別々の領域をマップすることになります。従って前半部分のページ群の被マップ数は 1 になります。残った後半は変わらず被マップ数は 2 です → Private_Dirty 50MB / Shared_Dirty 50MB になります。

top を勘違いしていた

ところで top には SHR という項目があります。ずっとこのカラムが、親子での共有メモリのサイズなんだと勘違いをしていました。(もしかすれば自分の勘違いではなく、カーネルが更新される過程で SHR の意味する内容が変更になったということもあるかもしれませんが、そこまでは分かりません。)

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 6676 apache    15   0  171m  79m 6552 S    0  2.1   2:09.07 httpd
 6865 apache    15   0  169m  77m 6172 S    8  2.0   1:59.57 httpd
 6665 apache    16   0  170m  77m 6520 S    0  2.0   2:12.29 httpd
 6873 apache    15   0  169m  77m 6508 S    0  2.0   1:52.46 httpd
 6678 apache    16   0  169m  77m 6548 S    0  2.0   2:07.98 httpd

Practical mod_perl の 9 章 http://modperlbook.org/html/9-3-Process-Memory-Measurements.html

t: SHR  --  共有メモリサイズ (kb)
   タスクが利用している共有メモリの総量。他のプロセスと共有される可能性のあるメモリを単純に反映している。

という man top の該当カラムの解説を見ると一見間違いないように思えますが、このカラムは /proc/PID/statm から値を取得していて、検証プログラムを動かしながら計算してもこの項目が共有領域分のサイズにはなりませんでした。

man proc の statm にある

/proc/[number]/statm
        Provides information about memory status in pages.  The columns are:
         size       total program size
         resident   resident set size
         share      shared pages
         text       text (code)
         lib        library
         data       data/stack
         dt         dirty pages (unused in Linux 2.6)

share が top で使われている値です。statm の値はページ数なので、4kB を掛けたものが top の SHR カラムの値になっています。

この share 値がどこから来たものなのか、カーネルを追って見ます。statm の read に対するコールバックは fs/proc/array.c の proc_pid_statm() です。

int proc_pid_statm(struct task_struct *task, char *buffer)
{
    int size = 0, resident = 0, shared = 0, text = 0, lib = 0, data = 0;
    struct mm_struct *mm = get_task_mm(task);

    if (mm) {
     /* task_statm() で計算 */
        size = task_statm(mm, &shared, &text, &data, &resident);
        mmput(mm);
    }

    return sprintf(buffer, "%d %d %d %d %d %d %d\n",
               size, resident, shared, text, lib, data, 0);
}

task_statm() も追ってみます。fs/proc/task_mmu.c にあります。

int task_statm(struct mm_struct *mm, int *shared, int *text,
           int *data, int *resident)
{
    /* shared は mm_struct.file_rss の値 */
    *shared = get_mm_counter(mm, file_rss);

    *text = (PAGE_ALIGN(mm->end_code) - (mm->start_code & PAGE_MASK))
                                >> PAGE_SHIFT;
    *data = mm->total_vm - mm->shared_vm;
    *resident = *shared + get_mm_counter(mm, anon_rss);
    return mm->total_vm;
}

shared 項は mm_struct 構造体の file_rss メンバの値であることがわかります。file_rss という名前から、ファイル領域にマップされたメモリサイズではないかと予想されます。この file_rss は実際どういうものなのかは、カーネル 2.6.11.1 の古い実装を見るとよりはっきり分かります。

int task_statm(struct mm_struct *mm, int *shared, int *text,
           int *data, int *resident)
{
  /* shared は rss - anon_rss */
    *shared = mm->rss - mm->anon_rss;

    *text = (PAGE_ALIGN(mm->end_code) - (mm->start_code & PAGE_MASK))
                                >> PAGE_SHIFT;
    *data = mm->total_vm - mm->shared_vm;
    *resident = mm->rss;
    return mm->total_vm;
}

物理メモリに確保した領域から、無名マッピングにより割り当てられたサイズの差分です。無名マッピング以外ということは、やはり明示的にファイルマッピングされた領域 (mmap された共有ライブラリ領域などが一例) のページ数であると見て間違いなさそうです。

まとめ

  • /proc/PID/smaps を見ると CoW で親子が共有しているメモリのだいたいのサイズがわかります。
  • Perl では Linux::Smaps で smaps をプログラマブルに扱うことができます。
  • smaps では共有領域のサイズは Shared、そうでない領域のサイズは Private として報告されます
  • カーネルのコードを追うと、Shared は仮想メモリ空間からの被マップ数が 2 以上のページの合計、Private は被マップ数が 1 のページの合計であることがわかりました。
  • また Shared_Dirty / Shared_Clean の Dirty と Clean の違いはページテーブルエントリのダーティフラグの有無であることもわかりました

以上から、/proc/PID/smaps の値からなぜ CoW の共有領域のサイズが求められるかがはっきりしました。また、top の SHR 項目は思っていたもの (CoW の共有領域のサイズだと思い込んでいた) とは違っていたこともわかりました。

なるほどーなるほどー 2008/02/13 02:38 勉強になります。
Solarisってこの辺の仕組みってどうなってるんだろ??
てか、どうやって調べたらいいんだろ・・・

naoyanaoya 2008/02/13 08:52 自分も Solaris のことは全然わかりません...

ちなみに Google で調べていたら HP-UX での同様の話は見つりました。Solaris でもカーネルの情報に対するインタフェースになっている機能周りを追っていくのがいいのでしょうか。

kosakikosaki 2008/02/14 09:55 TOPみたいに定期的に更新するツールで、全ページなめる/proc/pid/smapを毎回チェックするわけにはいかないからねぇ・・
このへん、悩ましいんだ

naoyanaoya 2008/02/14 10:37 なるほど、やっぱりそういう理由なんですな。

junkawajunkawa 2008/02/16 09:00 とても勉強になります。
http://lwn.net/Articles/230975/
http://tinyurl.com/2bvhuw
↑では、プロセスの共有メモリを詳細化するパッチが紹介されています。また、この開発は現在も続いているみたいなので、共有メモリのサイズ取得が容易になりそうです。

kosakikosaki 2008/02/25 14:21 上記URLのkpagemap seriesは2.6.25-rc1(だったかな?)で無事マージされたみたいですね。
でも /proc/kpagemap が /proc/kpageflags, /proc/kpagecountに変更になった影響にユーザーランドアプリが追いついていないような(^^;

ちなみに、今回追加された/proc/pid/pagemapも全PTEなめるので使い方間違えると性能に影響出ます・・・・

naoyanaoya 2008/02/25 15:13 おお、タイムリー!