📚

プログラムがメモリをどう使うかを理解する(4)

2022/01/12に公開

この記事は
https://zenn.dev/rita0222/articles/e6ff75245d79b5
このシリーズの4本目です。
前回の記事は
https://zenn.dev/rita0222/articles/f59b79bab45a2a
こちらです。

まだ色々書きたいことはあるのですが、当初のタイトルである「プログラムがメモリをどう使うか」という観点では、今回で一区切りにします。

プログラムの実行から終了までを追う

前回で スタック・ヒープ・静的領域 という3大領域を認識しました。今回は仕上げとして、プログラムの実行を開始してから終了するまでに、それらがどう確保され、どう利用され、どう解放されていくのかを確認していきましょう。

プログラムを実行する際に、OSからアドレス空間を与えられる

プログラムから利用できるメモリにはアドレスが割り振られますが、昨今のOSでは同時に複数のプログラムが動作することが当たり前になっています。昔はわざわざ「マルチタスクOS」と呼んでいましたが、今となってはわざわざそんな呼び方をしなくても当たり前になっていますね。

そうなると心配性な人は「アドレスが重複してしっちゃかめっちゃかになったりしないかなぁ」なんてことを思われるかもしれません。そんなあなた、間違いなくプログラマーの適性があります。今後も頑張っていきましょう。

じゃあ実際どうなってるのかというと、これまた昨今の大半のOSでは 仮想アドレス というイカした仕組みを搭載しています。


プログラムをOS上で実行する際は プロセス と呼ばれる単位で管理されますが、このプロセスごとにそれぞれ異なるアドレス空間が割り当てられます。「異なるアドレス空間」ということは「同じアドレスの値でもプロセスが異なればそれは別物」ということです。1丁目の1番地と2丁目の1番地は別の場所を指すようなものですね。


このアドレス空間を実際にメモリとして利用しようとしたときに、OSが実際の物理メモリを割り当ててくれます。素晴らしいことに、物理メモリ上では連続していなくても、アドレス空間上で連続していれば、それは地続きのメモリとしてプログラム側からは扱うことができます。

このように、メモリ空間そのものではないが、実質的にメモリ空間と同様に扱えるアドレスの仕組みのことを 仮想アドレス と呼びます。神機能と言って差し支えない仮想アドレスですが、

  • 仮想アドレスに割り当てられる物理メモリはページと呼ばれる単位になる(多くのシステムでは4KB)
  • 物理メモリの割り当てと解放はそれなりに重たい処理なので、頻繁に発生させるのは避けたい
  • 物理メモリの断片化はページ単位なら仮想アドレスで吸収できるが、肝心の仮想アドレス空間上での断片化はどうしようもないので対策が必要

といった制約や要注意点もあります。これらについてはまた後ほど触れます。

プログラムをビルドした時点で確定する領域をロードする

プログラムをビルドした時点でサイズが決まるものは、以下に挙げるものになります。

  • 実行コード(関数単位でまとめられた機械語の羅列)
    • 読み取りと実行が許可されている
  • 定数(文字列リテラルを含む)
    • 読み取り専用
  • グローバル変数 & static変数
    • 読み書きOK
    • この領域はロード時にゼロクリアされることが保証されている

これらが占める領域は、プログラムの実行を開始する時点で確保され、終了までずっと同じアドレスで保持されます。前回では実行コードのことには触れませんでしたが、括りとしては静的に解決されるものなので、ここでまとめて取り上げました。

イミディエイトウィンドウで関数名を評価すると、その関数における実行コードの配置場所がわかります。

実は私も機械語を読んで「ほう、なるほど」と言えるレベルでは理解してないので、このバイト列はちんぷんかんぶんです。とりあえず、ここらへんのコードがCPUの命令に対応してるんだろうなぁ、くらいの理解でもプログラムは書けます。コンパイラには感謝しかないですね。

この実行コード配置領域は、関数が長くなるほど、関数が増えるほど、大きくなります。C++でテンプレートを駆使している場合、数100MB級まで膨れることも珍しくありません。プログラムをROMに焼かなくてはならない組み込み環境などでは死活問題になりますが、そうではない環境(PC・スマートデバイス・コンソール)においては、ここをケチるよりは実行コードが膨れてでも、それによって得られる生産性を享受した方がメリットが大きいはずです。

それぞれ配置されているものに応じたパーミッションが設定されるので、実行コードや定数領域への書き込み、実行コード以外の領域に対する関数呼び出し操作、みたいなことを行うと、OSないしCPUが怒ります。

    const char* strA = "HogeHoge";
    // 許されざる強引なキャストとその実行
    void(*func)() = (void(*)())strA;
    func();

上記は、文字列を「これはvoid()型の関数ポインタだよ、さあ実行してごらん」とそそのかす邪悪極まりないコードです。実行可能属性がない領域に対する実行(関数呼び出し)操作は、それこそ任意コードを注入して実行できるような脆弱性になりかねないので、OSやCPU機能で防ぐ手段が講じられています。

スタックを確保してエントリーポイントから処理を開始する

静的領域へのロードが済んだら、メインスレッドのスタックが確保されます。スタックのサイズもある意味ビルド時に静的に決まる要素なのですが、ソースコードに記述した内容ではなく、ビルドツールにオプションとして渡す値で決めることが多いです。

https://docs.microsoft.com/ja-jp/cpp/build/reference/f-set-stack-size?view=msvc-170

MSVCの場合、無指定の場合のサイズは 1MB となっています。

前回説明したように、スタックは領域の一番後ろから使用されます。
関数に進入した際に、関数内で定義されている全変数分の領域が確保されて、スタックのトップが前にスライドします。初期値代入やコンストラクタの呼び出しは、変数が定義されたタイミングで行われます。
スコープを抜ける(}を通過する)ことでデストラクタが呼び出され、関数から抜ける際に積んだ領域が破棄されて、スタックのトップが後ろにスライドします。

これも確かめてみましょう。確認しやすいように、こんなプログラムを用意します。

#include <iostream>

void f0()
{
    char str[] = "f0f0f0f0f0f0f0f0";
}

void f1()
{
    char str[] = "f1f1f1f1f1f1f1f1";
}

void f2()
{
    char str[] = "f2f2f2f2f2f2f2f2";
}

void f3()
{
    char str[] = "f3f3f3f3f3f3f3f3";
    f2();
}

int main()
{
    char str[] = "mainmainmainmain";
    f0();
    f1();
    f3();

    std::cout << "Hello World!\n";
}

関数に進入したら文字列がスタック上の配列にコピーされるはずです。わかりやすい!

いちいちスクリーンショットを貼るのも場所を取るので、ステップ実行する様子を動画に撮ってみました。
https://youtu.be/j46-WsGXxC8

mainから直接呼び出しているf0,f1,f3については、同じ領域を再利用しているのがわかります。f3->f2と呼び出しが深くなると、f3の上にf2の領域が積まれています。スタックの高速性を支えているのは、この「関数に進入した時点で、そこで定義されうる全変数のサイズをまとめて確保できる」ことに尽きます。この特性を活かすために、C++プログラマは可能な限り静的に解決できるものは静的にしようとするのです。

スタックが何段にも及んだり、大きなサイズの変数(配列)が定義された結果、スタックの容量を超えてしまうと、いわゆるスタックオーバーフローとなります。

ついでに、関数に引数を渡すとき、返り値を受け取る時の挙動も見ましょう。

#include <iostream>

int f0(int a, int b, int* c, int& d)
{
    char strA[] = "f0f0f0f0f0f0f0f0";
    char strB[] = "f0f0f0f0f0f0f0f0";
    a = 5;
    b = 6;
    *c = 7;
    d = 8;
    return 9;
}

int main()
{
    char strA[] = "mainmainmainmain";
    char strB[] = "mainmainmainmain";
    int x = 3;
    int y = 4;
    auto value = f0(1, 2, &x, y);

    std::cout << "Hello World!\n";
}

値・ポインタ・参照のよくばりセットです。まとめて理解してしまいましょう。
https://youtu.be/5Z_5l6TEx4Y

引数はスタックの一番底に積まれています。ポインタも参照も、結局はアドレスの値を渡しているだけです。

値渡し・参照渡しとよく区別されますが、メモリ上ではどちらもバイト列を渡しているにすぎません。それを直接値として扱うか、アドレスとして扱ってその先にある値を利用するか、の違いでしかないわけです。

返り値は直接、呼び出し元の変数に代入されます。引数とは異なり、関数内部で確保した領域から呼び出し元にコピーされるような挙動にはなりません。だからこそ、ファクトリ関数のようなパターンが有効であるとも言えますね。

他にもスタックには興味深い挙動がありますが、メモリレベルで理解する基本的な動作としてはこのような感じになると思います。

必要に応じてヒープからメモリを確保したり解放したりする

スタックでは扱えないような動的なサイズを持つもの、そして関数(スコープ)の出入りでは制御できない寿命を持つものは、仕方ないのでヒープからメモリを確保して使います。スレッドを複数利用する場合は、メインスレッドから新たにスレッドを作成する処理を呼ぶことになりますが、この時に新たなスレッドのスタック用のメモリを確保する必要があります。これもヒープから確保します。

ヒープからのメモリ確保は、最終的にはOSが提供するAPIによって行うものです。しかし、移植性の高いコードを目指す場合や、標準的な操作で十分事足りるような場合に、それらを直接呼び出すのは適さないので、

  • malloc(やそれに類する関数) で確保して free で解放する
  • new で確保して delete で解放する
  • 内部で malloc や new している型を初期化し、デストラクタで解放する
    といった扱い方をすることになります。

確保したら、解放する。当たり前のことなんですが、長年人類はこれがきちんとできずに、メモリリークと呼ばれるバグを量産してきました。C/C++が難しい理由として「自分でメモリを解放しないといけないから」と言われることもあるくらいです。なので、C++においてはこれらをユーザーレベルで都度ハンドリングせずに、スマートポインタを使って管理することが推奨されます。

明示的なメモリ確保以外でも、他人が作ったライブラリ(標準ライブラリやSDK含む)を扱うときは、それがヒープからのメモリ確保を伴うものなのかが不透明になりがちです。静的にサイズが決まらなさそうなものを扱うときには、ドキュメントをしっかり確認するようにしましょう。動的にサイズが変化する配列(コンテナ・文字列)状況に応じて異なる型のオブジェクトを生成する処理 などは怪しむべきです。

メモリの予算が決まっている場合、ヒープの利用状況を集計するなどして把握した上で、確保の失敗が起きないようにしないといけません。ヒープからのメモリ確保が失敗するのは次のケースです。

  • 仮想アドレス空間にマップ出来る物理メモリがなくなった
  • 仮想アドレス自体に要求を満たす連続領域がなくなった

前者はともかく、後者はプログラム側のメモリ管理が甘いと発生する事象です。
メモリの確保と解放を繰り返すと、領域の寿命やサイズの関係性によってはアドレス空間の空き領域が歯抜けのようになってしまい、大きなサイズをまとめて確保することができなくなります。これを断片化と呼びます。
通常、仮想アドレス空間は物理メモリより十分な大きさがある(x64なら128TB)ため、多少断片化しても後方のアドレスを使っていけば、物理メモリが空いていれば確保はできます。しかし長時間動作するプログラムでそれを繰り返していくと、128TB分あるアドレス空間でも使い切ることがありえます。物理メモリは足りてそうなのに、同じソフトを長時間使っていると落ちることがあるのはこのためです。
仮想アドレスを利用できる環境においても、メモリ管理はそれなりに賢くやらないとシステムとして安定しません。肝に銘じましょう。

終了処理

エントリーポイントから抜けるか、あるいは終了指示が出ると、OSはプロセスが利用していたリソースをまとめてごそっと解放します。ヒープの解放漏れがあってもお構いなしです。アドレス空間とそこにマップされていた物理メモリも全て解放され、後には何も残りません。

後片付けが多少甘かったり汚かったとしてもOSが面倒を見てくれるわけですが、そういったプログラムには往々にしてメモリリークが存在し、長時間の稼働には耐えられないことがほとんどです。終了して問題が残らなければ良しとするのではなく、きちんと後片付けのできるプログラムを作りましょう。

まとめ

今回で、タイトルである「プログラムがメモリをどう使うかを理解する」というお題は、一通りまとめきったかと思います。

  • プログラムのロード時に(OSにもよるが昨今の環境なら大抵の場合)仮想アドレス空間が与えられる
  • ビルド時に決定している静的領域を確保して、実行コードや定数をロードし、グローバル変数領域をゼロクリアする
  • メインスレッドのスタック領域を確保し、グローバルコンストラクタを実行した後にエントリーポイント(main関数)から処理を開始
  • 関数の呼び出しごとにメモリを積み上げ、戻るごとに捨てる
  • ヒープが必要な場合は確保し、不要になったら解放する
  • エントリーポイントから抜けるか、終了指示が出たら(グローバルデストラクタが実行された後で)すべてのリソースがOSによって片付けられる

ヒープについては、環境や確保サイズによって挙動が大きく異なるため、デバッガで追うところを示すのは断念しました。これはこれで独立した記事が書けるほどのテーマですので、いずれ筆を執ることがあるかもしれません。

ここまでにまとめてきた知識があれば、自分が作ったプログラムがどのくらいメモリを使うのかが見積もれるはずです。また、Visual Studioのデバッガが、プログラムの挙動を可視化するツールとしても非常に有用であることも示せたと思います。メモリを賢く使い、快適にデバッグして、より良いプログラミングライフをお過ごしください!

次回予告

そもそもこんな話を始めたのは
https://zenn.dev/rita0222/articles/c22a8367e31b4d5f4eeb
この記事で出てきた 「まとめて処理するものは固まっていてくれた方が、目線を動かさずに済むので高速に処理できる」 というのをちゃんと説明したかった、というのが非常に大きな要因としてあります。
というわけで、このタイトルで書くのは今回でおしまいですが、近日中にCPUキャッシュの話を書きます。メモリレイアウトにこだわる理由が納得いただけると思いますので、お楽しみに。

この記事を読んでいただいた方、またサポートいただいた方、ありがとうございました!

Discussion