メモリアロケータ用の領域を全てmemsetしたらプロセスがOOMを起こした時のメモ

experience of investigating OOM when calling memset to allocated memory

最近自分のプロジェクトの為にメモリアロケーターを書いた時に躓いた問題に関するメモ。Cを扱う人々にとっては常識のようだが、自身の学びの一環として書き残しておく。

学んだ事

  • memsetを大きな確保済みメモリ領域に対して呼ぶのは避ける

発端

今まで自分が何かCのプログラムを書く際、メモリアロケーターに管理させる為のメモリを最初に適当な量だけ確保し、 後々必要に応じて適切な大きさのチャンクに切り分けて使用する事でmallocを呼ぶ箇所を最小限に抑えている。

この時、最初にアロケーターに確保させるメモリの容量が大きすぎると動作環境のLinuxが即座にOOM-Killerを叩き起こしていた。

#define Gigabytes(n) ((size_t)(n) * 1024 * 1024 * 1024)
arena_allocator Arena = {0}; // アロケータを新しくスタックに置く
size_t MemorySize = Gigabytes(2); // 2GBのメモリを確保する
InitializeArena(&Arena, MemorySize); // ここで必ずOOMを起こす

uint8 *Memory = AllocateArena(&Arena, sizeof(Something) * Quantity);

2GBものメモリを使い切るのは稀なので普段なら指定の容量を512MBなどまで減らす等で対応していたが、今回ふとした思いつきでコードを辿ってみると具体的なOOMの発生源がInitializeArenaの内部でmalloc後に呼んでいるmemsetだったことが分かった。その為、AllocateArenaでメモリを実際に使用する際に必要なだけゼロクリアするようにするとOOMが発生しなくなった。

malloc何故か普通にアドレスを返してくるのに、memsetでやっとOOMが起きるというのが理解できず、 「そもそも空き容量が無いのに何故mallocが成功するの?」と不審に思ったので調べてみた所、 デマンドページングというメモリの取り扱い方を学んだので書き記しておく。

デマンドページング

デマンドページングとはOSがメモリを管理する手法の1つで、 「アクセスされた仮想ページにまだ物理アドレスが割り当てられていない時に物理アドレスを確保する」というもの。

mallocによる一定のサイズ以上のメモリの確保(即ち厳密に言えばmmap?)が成功した時に帰ってくるアドレスは実際の物理アドレスではなく、メモリ上に配置されたページテーブルが管理している仮想アドレスである。この仮想アドレスを通してメモリ領域にアクセスしようとした時、**メモリ管理ユニット(MMU)**が実際のプロセスのページテーブルを参照し、 使用しようとしている仮想アドレスに紐付いた物理アドレスに変換してくれる。

この仮想->物理へのマッピングは通常固定長のブロックに区切られた状態で行われ(今どきのコンピューターは大体4KBらしい)、このブロックをページと呼ぶ。そしてこのページを使ったメモリ管理の方法をページング方式と呼ぶ。

ここからが問題なのだが、実際にmallocによって一定量のページ数を確保した時、まだ物理アドレスとの紐付けは行われていない。 この状態でページに対してアクセスを行った時、MMUは静かにページフォールトCPU OSに向けて伝え、それを受け取ったOSはすぐさま指定のページに適切な物理アドレスを割り振り、何事もなかったかのようにプロセスに制御を戻す。 この「必要になったら物理領域をページに割り当て」とする仕様の事をデマンドページングと呼ぶ。

即ち今回の場合、確保した直後のメモリ領域全体に対してmemsetを呼んだ際にメモリ書き込みが発生するため、物理メモリの領域を(今すぐ使うわけでもないのに)一気に割り当てようとしていたのが原因でOOMが起きていたという事になる。

確認

以下のようなコードをコンパイルして実行し、メモリ使用量を確認してみる。(動作環境 Arch Linux 64bit / メモリ8GB)

// allocation.c
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    size_t Size = (size_t)128 * 1024 * 1024; // 128MB
    void *A = malloc(Size);

    if (A) {
        // memset(A, 0, Size);
        sleep(10);
    }

    free(A);
    return 0;
}

コンパイルして実行

$ gcc -o alloc ./allocation.c
$ ./alloc

確認

$ ps u -C alloc
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
user      282193  0.0  0.0 133416   728 pts/5    S+   20:03   0:00 ./alloc

仮想メモリ(VSZ)が133416KB ~ 大体128MB確保されているが、物理メモリ(RSS)は728KBと大きく差が出ている。
mallocが最適化でカットされているとかでもなさそうだ。(実際仮想メモリは確保されているので) memsetのコメントアウトを消して再度コンパイルし、確認すると以下のようになる。

$ ps u -C alloc
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
user      282951  7.5  1.6 133416 131952 pts/5   S+   20:07   0:00 ./alloc

がっつり使っている。
これで実際に謎は解けた。

感想

例えば大きい配列が欲しい時に毎回必ず「とりあえず1~2GBください」とか言っておいて、その一部分だけ使って終わったらfreeみたいな事が出来るのはこのデマンドページングのおかげである。 すごく単純に表現をすると「取り敢えず処理の進行に必要な分だけ仮想アドレス領域を保証してほしい」というのがmalloc(mmap)の大雑把な挙動なんだろう。

またその一方で、mallocの返してきたアドレスが本当に指定の容量ぶんだけ使えるかどうかが分からない事態もあり得る。 例えばPCに積まれたメモリの空き容量が10GBだったとして、それ全てを確保できたからと言って実際にそれだけの領域がプロセスの為に用意された訳では無い。今回の0埋めによるOOMがわかりやすい証拠である。

結論

アロケーターがメモリを確保した時に全部memsetするのはやめることにした。メモリ確保/初期化という重要なものを1つの関数にまとめるのは相当に大胆な一般化で、今回はそれが自らの首を締める形になってしまった。そもそもアロケーターから確保した領域が0クリアされているからといってキャスト先の全ての型で0に等しい値になるとも限らない。ちょっと考えればわかる話だった。

これからはアロケーターを使う側で事前にバッキングバッファーを確保し、アロケーターに渡す形で使用する事にする。

追記: Windowsではどうか

普段Windowsのコードを書く時はVirtualAllocを使用しているが、Windowsも同様に実際にアクセスが行われるまで物理アドレスの確保をしないようだ。

補足

ワーキングセットや予約・コミットに関しては割愛。

参考にした記事・サイト