1. todok-r

    No comment

    todok-r
Changes in title
-コピーオンライトによる仮想アドレス〜物理アドレスマッピングの変化を観察する
+コピーオンライトの動作をユーザ空間から観察する
Changes in body
Source | HTML | Preview

はじめに

Linuxを触り始めてからしばらく経ちますが、未だによくわからないことが多くあります。親プロセスからforkされた子プロセスは概念上は親プロセスとは別々のアドレス空間を持ちますが、実際にはfork直後は親プロセスと子プロセスはページテーブルを共有しています。つまりforkした時点では親プロセスと子プロセスとは物理メモリ上の同一領域にマッピングされています。
この状態から脱却したいと思い、基礎から勉強しなおすために、しかし、このままの状態でどちらかのプロセスがデータを変更すると、もう片方のプロセスのデータも変化してしまうこととなります。試して理解-Linuxのしくみ-実験と図解で学ぶOSとハードウェアの基礎知識を購入しました。そのため、どちらかのプロセスがメモリへの書き込みを行ったタイミングでカーネルは書き込みの対象となるページのコピーを行います。このような動作をコピーオンライトと呼びます。
本書を読んでいる中で気になった点をさらに調査し、知識を深めていきたいと思います。
第一弾として、単語はよく聞くけど、実際にどんな動作をしているかがよく理解できていないコピーオンライトについて調べてみました。

コピーオンライトの動作を検証テストプログラムの仕様

試して理解-Linuxのしくみ-実験と図解で学ぶOSとハードウェアの基礎知識ではp135からコピーオンライトの説明があり、p139ではコピーオンライトの実行前後のメモリ全体の使用量増減の観点でテストプログラムを使った検証を行っています。本記事では通常はユーザ空間から意識することのないコピーオンライトの動作をテストプログラムを使って見てみたいと思います。
これを参考にして、本記事では仮想アドレス〜物理アドレスのマッピングの観点で検証を行います。見ていく観点は以下となります。
なお、コピーオンライトの説明についてはネット上に豊富に説明があるため割愛します。

検証観点
  • コピーオンライトはどの単位で行われるか
  • どのタイミングでコピーオンライトが行われるか

このために、テストプログラムでは以下を行います。テストプログラムでは以下を行います。

  1. プロセス開始時にメモリ領域を確保する
  2. 親プロセスはforkして子プロセスを生成する
  3. 親プロセスは1.で確保したメモリ領域の仮想アドレスと対応する物理アドレスを出力する
  4. 子プロセスは1.で確保したメモリ領域の仮想アドレスと対応する物理アドレスを出力する(仮想アドレス→物理アドレスのマッピングが親プロセスと同じであることを確認する)
  5. 子プロセスは1.で確保したメモリ領域の1ページ目に値を書き込む
  6. 子プロセスは1.で確保したメモリ領域の仮想アドレスと対応する物理アドレスを出力する(仮想アドレス→物理アドレスのマッピングが親プロセスと異なっていることを確認する)
  7. 子プロセスは1.で確保したメモリ領域の2ページ目に値を書き込む
  8. 子プロセスは1.で確保したメモリ領域の仮想アドレスと対応する物理アドレスを出力する(1ページ目への書き込みに対してどう変化するかを確認する)

テストプログラムのソースコード

/proc/pid/pagemapを使ってユーザ層プロセスの仮想アドレスを物理アドレスへ変換する方法は
https://stackoverflow.com/a/45126141
を参考にしました。
(markdownの書き方が悪いのか、以下のコード表示ではインデントがうまく行われていません)

main.c
#define _XOPEN_SOURCE 700
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

typedef struct {
    uint64_t pfn : 54;
    unsigned int soft_dirty : 1;
    unsigned int file_page : 1;
    unsigned int swapped : 1;
    unsigned int present : 1;
} PagemapEntry;

/* Parse the pagemap entry for the given virtual address.
 *
 * @param[out] entry      the parsed entry
 * @param[in]  pagemap_fd file descriptor to an open /proc/pid/pagemap file
 * @param[in]  vaddr      virtual address to get entry for
 * @return 0 for success, 1 for failure
 */
int pagemap_get_entry(PagemapEntry *entry, int pagemap_fd, uintptr_t vaddr)
{
    size_t nread;
    ssize_t ret;
    uint64_t data;

    nread = 0;
    while (nread < sizeof(data)) {
    ret = pread(pagemap_fd, &data, sizeof(data),
        (vaddr / sysconf(_SC_PAGE_SIZE)) * sizeof(data) + nread);
    nread += ret;
    if (ret <= 0) {
        return 1;
    }
    }
    entry->pfn = data & (((uint64_t)1 << 54) - 1);
    entry->soft_dirty = (data >> 54) & 1;
    entry->file_page = (data >> 61) & 1;
    entry->swapped = (data >> 62) & 1;
    entry->present = (data >> 63) & 1;
    return 0;
}

/* Convert the given virtual address to physical using /proc/PID/pagemap.
 *
 * @param[out] paddr physical address
 * @param[in]  pid   process to convert for
 * @param[in] vaddr virtual address to get entry for
 * @return 0 for success, 1 for failure
 */
int virt_to_phys_user(uintptr_t *paddr, pid_t pid, uintptr_t vaddr)
{
    char pagemap_file[BUFSIZ];
    int pagemap_fd;

    snprintf(pagemap_file, sizeof(pagemap_file), "/proc/%ju/pagemap", (uintmax_t)pid);
    pagemap_fd = open(pagemap_file, O_RDONLY);
    if (pagemap_fd < 0) {
    return 1;
    }
    PagemapEntry entry;
    if (pagemap_get_entry(&entry, pagemap_fd, vaddr)) {
    return 1;
    }
    close(pagemap_fd);
    *paddr = (entry.pfn * sysconf(_SC_PAGE_SIZE)) + (vaddr % sysconf(_SC_PAGE_SIZE));
    return 0;
}

void show_pagemap(pid_t pid, unsigned int* start, unsigned int* end, FILE* fout)
{
    uintptr_t paddr;
    unsigned int* p;

    for (p = start; p < end; p++) {
    if (virt_to_phys_user(&paddr, pid, (uintptr_t)p)) {
        fprintf(stderr, "error: virt_to_phys_user\n");
    }

    fprintf(fout, "%p : %p\n", p, paddr);
    }
}

void dump_pagemap(pid_t pid, unsigned int* start, unsigned int* end, const char* path)
{
    FILE* fout = fopen(path, "w");
    if (!fout) {
    fprintf(stderr, "error: couldn't open %s. err:%s\n", path, strerror(errno));
    return;
    }

    show_pagemap(pid, start, end, fout);

    fclose(fout);
}


int main(int argc, char* argv[])
{
    int pid, self;
    int pagesize = sysconf(_SC_PAGE_SIZE);
    int pagenum = 3;
    int memsize;
    unsigned int *start, *end, *p;

    if (argc > 1) {
    pagenum = strtol(argv[1], NULL, 10);
    }

    memsize = pagesize * pagenum;
    start = calloc(pagesize, pagenum);
    printf("%p, %d\n", start, pagesize);

    end = start + (memsize / sizeof(*start));

    pid = fork();
    self = getpid();

    if (pid == 0) {
    dump_pagemap(self, start, end, "./child_before.txt");

    //1ページ目を更新
    *start = 1;
    dump_pagemap(self, start, end, "./child_page_1.txt");

    //2ページ目を更新
    p = start + pagesize / sizeof(*start);
    *p = 2;

    dump_pagemap(self, start, end, "./child_page_2.txt");
    } else if (pid > 0) {
    dump_pagemap(self, start, end, "./parent.txt");
    waitpid(pid, NULL, 0);
    } else {
    fprintf(stderr, "fork failed. err:%s\n", strerror(errno));
    }

    free(start);

    return EXIT_SUCCESS;
}

/proc/pid/pagemapから正しいマッピング情報を取得するためにはroot権限が必要となるため、本テストプログラム実行時はsudoをつける等してください。
仮想アドレスとそれに対応する物理アドレスは以下ファイルに出力されます。

  • parent.txt:親プロセスの仮想アドレス〜物理アドレスのマッピング。上記3.の確認。
  • child_before.txt:子プロセスの仮想アドレス〜物理アドレスのマッピング。上記4.の確認。
  • child_page_1.txt:メモリ領域の1ページ目を更新後の子プロセスの仮想アドレス〜物理アドレスのマッピング。上記6.の確認。
  • child_page_2.txt:メモリ領域の2ページ目を更新後の子プロセスの仮想アドレス〜物理アドレスのマッピング。上記8.の確認。

出力は形式は"仮想アドレス : 対応する物理アドレス"です。アドレスは4バイト単位で出力します。

結果

テストプログラムを実行した結果、私の環境では1ページのサイズは4096バイト、callocで確保した領域のアドレスは0xf79010 〜0xf7bffc となりました(実行のたびにアドレスは変化します)。
テストプログラムでは3ページ分のサイズをcallocで確保していますが、先頭アドレスはページ先頭から16バイトずれています(メモリ管理領域分か)。その分が後ろに飛び出ているため、確保した領域は4ページにまたがっています。

$ head -n 5 parent.txt 
0xf79010 : 0x10fa3010
0xf79014 : 0x10fa3014
0xf79018 : 0x10fa3018
0xf7901c : 0x10fa301c
0xf79020 : 0x10fa3020
$ tail -n 5 parent.txt 
0xf7bffc : 0x81480ffc
0xf7c000 : 0x10ebe000
0xf7c004 : 0x10ebe004
0xf7c008 : 0x10ebe008
0xf7c00c : 0x10ebe00c

4.の結果

予想していた結果とは異なりました。
0xf7c000以降の16バイトについて親プロセスと子プロセスとで異なる物理アドレスへのマッピングとなっていました。
ただし、0xf79010〜0xf7bffcまでは同じ物理アドレスへマッピングしており、想定通りとなっています。メモリ領域のサイズによってコピーオンライトのかかり方が異なるかもしれません。この辺については別途調査したいと思います(ただし、0xf79010〜0xf7bffcまでは同じ物理アドレスへマッピングしており、想定通りとなっています。メモリ領域のサイズによってコピーオンライトのかかり方が異なるかもしれません。この辺りについては別途調査したいと思います(TODO)。

$ diff parent.txt child_before.txt 
3069,3072c3069,3072
< 0xf7c000 : 0x10ebe000
< 0xf7c004 : 0x10ebe004
< 0xf7c008 : 0x10ebe008
< 0xf7c00c : 0x10ebe00c
---
> 0xf7c000 : 0xa10a4000
> 0xf7c004 : 0xa10a4004
> 0xf7c008 : 0xa10a4008
> 0xf7c00c : 0xa10a400c

6.の結果

1ページ目のデータを更新することで、1ページ目全体のマッピングが更新されました。
それ以外のページのマッピングは更新されていません。

$diff child_before.txt child_page_1.txt
1,1020c1,1020
< 0xf79010 : 0x10fa3010
< 0xf79014 : 0x10fa3014
< 0xf79018 : 0x10fa3018
< 0xf7901c : 0x10fa301c
< 0xf79020 : 0x10fa3020
...
< 0xf79fec : 0x10fa3fec
< 0xf79ff0 : 0x10fa3ff0
< 0xf79ff4 : 0x10fa3ff4
< 0xf79ff8 : 0x10fa3ff8
< 0xf79ffc : 0x10fa3ffc

8.の結果

2ページ目のデータを更新することで、2ページ目全体のマッピングが更新されました。

$diff child_page_1.txt child_page_2.txt
1021,2044c1021,2044
< 0xf7a000 : 0x920a4000
< 0xf7a004 : 0x920a4004
< 0xf7a008 : 0x920a4008
< 0xf7a00c : 0x920a400c
< 0xf7a010 : 0x920a4010
...
< 0xf7afec : 0x920a4fec
< 0xf7aff0 : 0x920a4ff0
< 0xf7aff4 : 0x920a4ff4
< 0xf7aff8 : 0x920a4ff8
< 0xf7affc : 0x920a4ffc

結論

今回の動作検証から以下であることが言えそうです。

  • コピーオンライトはページ単位で行われる(TODOについては別途調査したい)
  • コピーオンライトはページ内のデータが初めて更新された際に実行される

ユーザ層からコピーオンライトの動きを見てみましたが、カーネル層でどのように実装しているかが気になってきたので、機会があれば調査を行いたいと思います。