かーねるさんとか

発言は個人の見解であり、所属する組織の公式見解ではありません。

いい感じに仮想メモリアドレスとファイルオフセットを変換するスマートポインタを作った

ファイルを mmap した仮想メモリアドレス空間の上でデータ構造の操作を行い、変更をそのままファイルへ保存したいと思ったことはありませんでしょうか。

面倒な点として、mmap されたファイルは、次回以降 mmap されたときに、同じ仮想メモリアドレスにマップされる保証がないため、連結リストのようなポインタを用いるデータ構造を保存する場合には、ポインタの値を仮想メモリアドレスから、ファイル内のオフセットに変換した上で、ファイルへ書き込む必要があります。

この変換は書き込みだけでなく、読み込みの場合はファイル内のオフセットから仮想メモリアドレスへ変換しなおす必要があり、実装が複雑化しやすいという問題があります。

今回は、そのような手間を省くために、変数の代入ごとに仮想メモリアドレスとファイルオフセットの変換を自動で行ってくれるスマートポインタを作ってみました。

ソースコードGitHub で公開しておりますので、ご興味がありましたら是非お試しください。

github.com

解決したかった問題

例えば、mmap したファイルの上で、連結リストを実装することを考えてみます。

まず、mmap を実行すると、OS はファイルをマップした仮想メモリの先頭のアドレスをリターンします。今回は例として、仮想アドレス 0x8b3000 にファイルがマップされたとします。(実際は通常もっと大きな値になります。)

連結リストを実装する際には、以下のような構造体を利用すると思いますが、リストの先頭となる node ( list_head ) を 0x8b3000 に置いて、next へ次の node のポインタの値を設定することとします。

struct node {
  int val;
  struct node *next;
};

struct node *list_head = 0x8b3000;

なんとなく、先頭の次の node は仮想メモリアドレス 0x8b3090 の領域に配置することにしました。

さて、この場合、リストの先頭の next の値 ( list_head->next ) には何を代入すべきでしょうか。

メモリ内だけで完結するプログラムであれば、以下のように、list_head->next には 0x8b3090 を代入することになると思われます。

struct node *new_node = 0x8b3090;

list_head->next = new_node;

ですが、list_head がメモリマップトファイルの上に置かれている今回においては、問題が発生します。

メモリマップトファイルの上の数値は、そのままファイルに保存されます。その結果、次回、同じファイルを mmap した時に、別の仮想メモリアドレスへファイルがマップされた場合、新しい node ( new_node ) は 0x8b3090 とは別の仮想メモリアドレスに配置されてしまいます。

このような、メモリ領域の再配置へ対応できるようにするためには、以下のように、ポインタを仮想メモリアドレスではなく、ファイル内のオフセットとして保存しておくようにする必要があります。今回の場合では、new_node は、ファイルの先頭から 0x90 離れた場所にあるので、list_head->next へは、0x90 を代入しておくべきです。

struct node {
  int val;
  unsigned long next; // file offset
};

struct node *list_head = 0x8b3000;

struct node *new_node = 0x8b3090;

list_head->next = (unsigned long) new_node - 0x8b3000; // 0x90;

このようにしておくと、例えば、次回 mmap によって、同じファイルが仮想メモリアドレス 0x8b3000 ではなく、0x9b3000 にマップされたとしても、new_node へは、ファイルがマップされた先頭のアドレス 0x9b3000 へ、list_head->next に保存されている 0x90 を加算することで、0x9b3090 に new_node があることがわかります。

void *addr = mmap(...); // 0x9b3000

struct node *list_head = addr;

struct node *new_node = (unsigned long) addr + list_head->next; // 0x9b3000 + 0x90

以上のように、メモリマップトファイルの上にデータ構造を保存しようとする場合、せっかく仮想メモリ空間上でデータの操作ができるにもかかわらず、ポインタの値は毎度ファイル上のオフセットと、仮想メモリアドレスを変換しなければなりません。

各代入において値の変換を行うのは手間がかかり、実装の複雑性が高まってしまいます。

解決策:fmalloc pointer ( fm_ptr )

今回は、このポインタの値の変換にかかる手間を大幅に削減できる fm_ptr というスマートポインタをつくってみました。

メモリマップトファイル上のポインタの理想的な挙動は、

  • プログラムが参照するときには仮想メモリアドレス
  • ファイルへ書き込まれるときには、ファイル内のオフセット

であると思われます。fm_ptr はこの挙動を実現します。

実装については、C++演算子オーバーロードを使っています。

大前提として、fm_ptr は、メモリ上にファイル内オフセットを保存します。

そして、参照される場合には、メモリマップトファイルのマップ開始アドレスを加算した値を返します。

また、値が代入される場合には、渡された値から、メモリマップトファイルのマップ開始アドレスを引いた値を、メモリ上に保存します。

これにより、プログラムが代入、参照、他にも加算や比較を行う場合には、仮想メモリアドレスを扱っているように見えます。

ポイントとして、演算子オーバーロードの処理は、メモリコピーによって値が別の場所へコピーされる場合には実行されないという点があります。

メモリマップトファイルのメモリ上のデータは、メモリのコピーによって、ストレージデバイスへ移動されるため、メモリ上に保存されているファイルオフセットは、演算子オーバーロードによって仮想アドレスへ変換されることなく、ストレージデバイスへ送られます。

使い方

fm_ptr をポインタとして利用してリストを実装すると以下のようになります。

struct node {
  int val;
  fm_ptr<struct node> next;
} __attribute__((packed));

static void list_append(struct node *head, struct node *newnode)
{
  struct node *n = head;
  while (n->next) {
    n = n->next;
  }
  n->next = newnode;
}

node のメンバの next がスマートポインタになっている以外は、普通のメモリ内で完結する連結リストの実装と同じになっています。

list_append の中では、next の値を参照、代入していますが、ここで特にファイルオフセットの値との変換を明記しなくてよいのは、
fm_ptr が内部的に自動で変換を行っているからです。

このように、fm_ptr によって、メモリ内で完結するデータ構造のプログラミングと近い形のまま、メモリマップトファイルへデータを保存することができるようになります。

使用の際には、メモリマップトファイルの先頭はスレッドローカルストレージ ( TLS )に設定することを想定しており、TLS 上の値を変更していくことで、複数のファイルを同時に扱えるようになっています。詳細は実装をご覧ください。

まとめ

メモリマップトファイル向けに、仮想メモリアドレスとファイルのオフセットを自動で変換するスマートポインタを作ってみました。