コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
mmap とメモリマップトファイル
4GB のログから、末尾の 1 行だけが見たい
read でファイルを読むと、カーネルはディスクの中身をいったんカーネル側のバッファへ読み込み、そこからこちらが渡したバッファへコピーします。数 KB なら気にする必要はありませんが、大きなファイルを扱うほどこのコピーが効いてきます。読みたい範囲を自分で計算し、シークし、バッファの長さを管理する手間も付いてきます。
一方で、ファイルの中身も一度は RAM の上に載ります。番地さえ結びつけられれば、コピーせずに直接読めるはずです。そして「触ったときに初めて読み込む」仕掛けは、ページフォールトとして既に手元にあります。
ファイルを、番地の側に貼り付ける
mmap は、ファイルをプロセスの仮想アドレス空間へ割り当てるシステムコールです。呼んだ時点では 1 バイトも読まれません。翻訳表に「この番地の範囲は、あのファイルのこの位置」と書き込まれるだけです。
Python
import mmap
with open("access.log", "rb") as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as m:
pos = m.rfind(b"\n", 0, len(m) - 1)
print(m[pos + 1:].decode())m は 4GB の bytes のように振る舞い、末尾から改行を探せます。しかし実際に RAM へ読み込まれるのは、末尾を触ったときの数ページだけです。触った瞬間にページフォールトが起き、OS がその位置のブロックだけをディスクから読み込みます。読み込み済みの位置を覚えておく変数も、バッファの継ぎ目を扱うコードも要りません。
同じファイルを貼れば、同じ物理ページを共有する
2 つのプロセスが同じファイルを共有モードで貼ると、両者の仮想アドレスは同じ物理ページへ結びつきます。片方が書いた内容がもう片方から即座に見えるので、これがそのまま共有メモリとして使えます。
実行ファイルや共有ライブラリの読み込みも、この仕組みです。libc を使うプロセスが 200 個動いていても、libc のコードは RAM 上に 1 つしか無く、200 通りの仮想アドレスがそこを指しています。プロセスを増やしても RAM が線形に増えないのは、この共有が効いているからです。
なお実行ファイルは、書き込みが自分の中だけで完結するモードで貼られます。共有していたページに書き込みが起きた瞬間だけ、そのページが複製されて自分専用になる仕組みなので、走っているコードを書き換えても元のファイルは壊れません。
常に速いわけではない
先頭から順に 1 度だけ読むなら、read に負けます。カーネルは連続読みを検知して先の分まで前もって読んでおきますが、mmap は触るまで読まないので、ページフォールトのたびに待ちが入るためです。
mmap が効くのは、巨大なファイルの一部を飛び飛びに触る場合と、複数プロセスで同じ内容を共有したい場合です。SQLite や LMDB がこの方式を採るのは、まさに前者の使い方をするからです。