いつも心に省メモリ。どうも、かわしんです。今日はメモリアロケーションについてのポエムを綴ります。さらっと流してください。
ちなみに、ここでいう省メモリとはメモリサイズだけの話ではありません。
メモリをアロケート(確保)するとき、あなたはどんな感情を抱くだろうか?おそらく何も感じない人がほとんどだろう。というかメモリをアロケートしたことにすら気づいていないのかもしれない。
僕はメモリをアロケートするたびに心が痛む。本当はアロケートしなくてもいいのではないか、別のところでまとめてアロケートした方がいいのではないか?色々悩んだ結果、苦渋の選択としてメモリをアロケートするのだ。
メモリアロケーションのコストとは何か
僕がなんとなくメモリアロケーションに罪悪感を覚え始めた時、僕はメモリアロケーションのことを何も知らなかった。大きなメモリを確保するほどコスト(確保に必要な計算時間)が大きくなると思っていたし、不必要にメモリアロケーションを恐れていた。
メモリアロケーションを正しく恐れることができるようになったのは、malloc の仕組みを勉強した時だった。
ご存知のとおりメモリには 2 種類ある。スタック(stack)とヒープ(heap)だ。
まず、スタックのメモリ確保のコストはほぼない と言っていい。コンパイル時に関数ごとに確保されるサイズが確定し、関数が実行される時にスタックポインタの値をそのサイズだけずらすことでスタックメモリが確保される(スタック領域はアドレスの大きい方から小さい方に拡張されるのでスタックポインタは減算される)。実行時のコストはスタックポインタを減らす 1 命令だけだ。また、関数から抜ける時にスタックポインタが元に戻ることで自動的にスタック領域のメモリは解放される。
一方で、一般に(少なくとも僕のなかで)「メモリアロケーション」と呼ばれて恐れられるのは ヒープ領域の確保 だ。ヒープは実行時にサイズが決まるような構造のために利用される。実行前のコンパイル時にサイズが決まるのであればコストの小さなスタックやグローバル領域のメモリを使えばいい。自動で拡張される配列(vector)とかユーザからの接続のたびに確保されるコネクションコンテキストとか、文字列の操作のためにも使われたりする。
C 言語においてヒープ領域のメモリアロケーションは malloc
という関数によって行われる。他の言語でもおそらく似たような仕組みでアロケートされると思う。malloc
は指定されたサイズのメモリを確保して返すだけの関数だが、その実装は様々な種類があり有名なものだと tcmalloc
とか jemalloc
とか glibc の malloc
とかがある。1 それぞれマルチスレッド環境で速くしたりとか、より無駄になる領域が少なくなるようにとか、シンプルに速いだとか色々頑張っているらしい。が、どれも基本的な考え方は同じっぽい。
ここでは詳しくは説明しないので ガチャピン先生の malloc 動画 2 を観て勉強してほしい。すごくわかりやすくて面白い。ちなみにこの中で唐突に現れる「ご冗談でしょう、ファインマンさん」の元ネタを知らなかったので「ご冗談でしょう、ファインマンさん」という本を上下巻買って読んだが、理科と数学が好きな少年だった自分にはすごく刺さったのでこちらも是非読んでみてほしい。
さて、malloc の中では一般に大きなメモリに対しては mmap を使い、4KB までの小さなメモリについては 8, 16, 32, ... バイトとメモリサイズのクラスごとに free list がありそこに未使用のメモリがずらっと並んでいてその先頭のものを使用して返す。サイズクラスごとの free list の一覧は配列になっており、要求されるサイズから一撃でインデックス番号経由で引いてくることができるので速い。
つまり、大きすぎないメモリについては理論的にはアロケートするサイズによって計算時間は変わらない。多分。もしかしたらサイズによっては free list が枯渇しがちで余計なオーバーヘッドがあったりするのかもしれないけど、そこは勘弁してほしい。
これが malloc の中身を勉強して知った興味深い事実だった。 メモリアロケーションのコストとして気にすべきはサイズではなく回数 だったのだ。
メモリアロケーションに対する罪悪感の正体
メモリアロケーションの回数が多くなればプログラムは遅くなる。また、メモリアロケーションの回数が多いということは解放し忘れるバグによるリークの可能性も高くなる。さらにメモリアロケーションは失敗することがある。物理メモリが枯渇した時にはもちろん確保できない。メモリアロケーションに失敗した場合のエラーハンドリングを各所で行うことになり実装が肥大化する。
そう、メモリアロケーションにはデメリットしかない。それゆえに僕はメモリアロケーションに罪悪感を感じるのだ。
世の関数に告ぐ。勝手にメモリアロケーションするな、渡すから
僕は普段 Go をよく使うのだが、昔はこんな関数をよく作ってしまっていた。
func (item *Item) BuildMsg() []byte { buf := make([]byte, 100) // item を buf に書き込んでいく // ... return buf }
いい感じにオブジェクト指向で書いてやったぜ、ふふんとか思っていた。
これには 2 つ問題がある。1 つは複数のメッセージバイト列を結合する時に 無駄なメモリコピー が発生することだ。BuildMsg()
によって生成されたバイト列を結合先のバイト列にコピーする必要がある。多分、これだけではピンとこないと思うので、後述の正解例をみて比較してほしい。
2 つめは、複数回メッセージを生成する時に都度メモリアロケーションが発生することだ。定期的に item
の中身を書き換えてメッセージを書き出してハートビートを送るみたいなユースケースが考えられる。
ではどうすればいいのか。それはメモリは外部から渡して書き込んでもらうのだ。そうすれば全体でのメモリアロケーションはたったの 1 回で済む。また、メモリコピーも最小限で済む。
func (item *Item) BuildMsg(buf []byte) int { // item を buf に書き込んでいく // ... // 書き込んだサイズを返す return n } buf := make([]byte, 1024) // 複数回メッセージを送る時にバッファを使いまわしてアロケーションをしない for { // 複数のメッセージを結合する場合でもメモリコピーはメッセージを作成する 1 回だけ n := item1.BuildMsg(buf) nn := item2.BuildMsg(buf[n:]) n += nn conn.Write(buf[:n]) time.Sleep(time.Second) item1.id++ item2.id++ }
関数内でメモリをアロケートしてそれを返すという処理を書いているときは、一度立ち止まって外でメモリを確保して渡せないか考え直してみるといいかもしれない。
プログラミング言語によるメモリアロケーションへの態度の違い
よく使われる大抵の言語には GC (ガベージコレクション)が搭載されていることが多い。これは参照の消えたメモリを自動で解放してくれる便利な仕組みだが、同時に我々からメモリアロケーションの存在を見えにくくして、罪悪感を薄れさせている。そして無秩序にメモリをアロケートしてしまう。
C 言語ではメモリアロケーションをする時には必ず malloc()
関数を呼び出すため、プログラムを書く我々ははっきりとメモリアロケーションの存在を認識 する。
一方で GC のある言語では文法の裏にメモリアロケーションが隠蔽される。いろんなところでメモリを確保してどこかで紛失しても GC が後処理をしてくれるからメモリアロケーションを意識する必要はないのだ。しかし、GC のある言語でもメモリは確実にアロケートされている。そして、そのアロケーションには malloc()
と同様コストがかかっている。
Go は GC のある言語だが、C に似た言語であるためか少しはメモリアロケーションを意識できる。可変長配列(Go では slice という)の確保は make()
というビルトイン関数を使う。また構造体の確保では new()
というビルトイン関数を使うこともできる(僕はあまり使わないが)。これらの関数を使う時、我々はメモリアロケーションの存在をはっきりと認識することができる。実際は、構造体をポインタ化したり関数の外部に渡したりする場合はスタックに確保されそうな書き方でもヒープに確保されてしまうのだが、明示的にヒープに確保する文法があるというのはわかりやすくて良い 。
そんな Go 言語だが メモリの枯渇は全く考慮されていない 。malloc()
関数はメモリの確保に失敗した場合 NULL
を返す。しかし、Go の make()
はメモリの確保に失敗した場合例外を発生させプロセスは死ぬ。recover()
すればエラーハンドリングすることもできるが、メモリの枯渇は Go にとっては例外であり想定されるエラーではないのだ。メモリが安くなりじゃぶじゃぶ使えるようなこのご時世では、メモリ枯渇を心配するくらいなら金の力で解決しろということなのだろう。
Go はまだメモリアロケーションを意識できるが、大抵の GC 付きの言語はメモリアロケーションのコストを考慮しない。
例えばファイルの読み込みの場合、Go では n, err := file.Read(buf)
と外で確保したメモリを渡して書き込んでもらう。C 言語でも同様だ。しかし、Python や Ruby では file.read()
とすると関数内部で必要なサイズのメモリが確保されてバイト列が返される。勝手に関数の中でメモリアロケーションをする、そういう言語なのだ。そもそもアロケーションコストを意識するほど速い言語ではないからだと思えば妥当である。こういう言語ではメモリ管理のことなどは考えずに機能を実現することに主眼を置いてプログラミングするべきなのかもしれない。
メモリ管理とプログラミング言語と言ったら Rust のことも紹介しなくてはならない。が、ここで紹介するには分量が多いので、Rust の教科書 の「4.8. 所有権」「4.9. 参照と借用」「4.10. ライフタイム」を読むと Rust のメモリ管理を理解できると思う。個人的な感想としては、C++ に似てるなと思った。
プログラミング言語がせっかくメモリ管理を隠蔽してくれているのにわざわざメモリアロケーションのことを意識する必要はあるのか?と思われるかもしれない。そういうあなたにはこの記事を読んでほしい。要約すると「まともなプログラムを書くためには抽象化によって隠蔽された内部を知らざるを得ない」ということだ。
プログラムの目的とメモリアロケーション
世の中のプログラムには 2 種類あると思っている。
- やりたいことを実現させる( 振る舞いや機能が重視される )プログラム
- やりたいことを実現した上でより速く、効率的に、たくさんのユーザに対して動く( 性能が重視される )プログラム
この 2 つだ。
前者は、1回限りの書き捨てのスクリプトだったり、アクセスの少ない Web サービスやプロトタイプだったりする。
後者は大量のユーザに利用されるサービスであったり、シビアな環境で使われるアプリであったり、たくさんのエンジニアに利用されるライブラリやミドルウェア、OS などの低レイヤーであったりする。
この対比は去年書いた 作る人と使う人 - kawasin73のブログ に似ているような気もする。どちらが偉いとかそういうものではない。僕も両方とも書く。
前者ではメモリアロケーションなどの性能を気にすることはない。1 回しか使わないのだから最適化する実装コストに合わない。なんならメモリリークしてもいい。最適化で悩んでいる暇があったら次の仕事や機能に取り掛かる回転の速さが求められる。
後者ではメモリ設計が重要である。性能に直に響いてくる。メモリはまとめてアロケートするなどメモリアロケーションの回数を少なくするように設計をする必要がある。
問題は性能が重視されるプログラムであっても最初は機能が重視されて作られるということだ。早すぎる最適化は悪であり、最適化はいつも後でするべきだ。
しかし、いつどこでメモリをアロケートするかというメモリ設計はプログラムの根幹であり、後で変更するには困難を伴う。性能が重視される可能性のあるプログラムを書くときは 少なくとも最初に大雑把なメモリ設計をしてから雑に書き始める ということをしないと、後で痛い目にあうことになる。
どうメモリを設計するのか
メモリ設計とは、どこでメモリを確保して、どのコンポーネントがメモリを保持して、どこでメモリを解放するか を設計することである。
メモリ設計にはいくつかのパターンがあり、この「省メモリプログラミング」という本に詳しく書いてあるので見てみるといいかもしれない。
省メモリプログラミング―メモリ制限のあるシステムのためのソフトウェアパターン集 (Software patterns series)
この本の第 5 章「Memory Allocation:メモリ割り当て」では以下の 7 種類のメモリ設計方法が紹介されている。
- Fixed Allocation:固定アロケーション
- Variable Allocation:可変アロケーション
- Memory Discard:メモリの一括破棄
- Pooled Allocation:プールアロケーション
- Compaction:コンパクション
- Reference Counting:参照カウント
- Garbage Collection:ガベージコレクション
この章の要約は僕の Github issue にまとめてある。
まとめ
怪文書感満載なのは承知の上だが、これを読んで少しはメモリアロケーションに思いを馳せる人が増えれば幸いである。
本当にメモリを扱うためにはキャッシュのことも考えないといけないのはわかってはいるが、まだ僕はよく理解できていないので書いていない。そのうち勉強したい。