はじめに
Crystal言語を学び始めてから、スタックとかヒープとかいう言葉をよく目にします。
たとえば、Classはヒープ領域に確保されるけど、Structはスタック領域に確保されるよといったことがAPIリファレンスに書かれてます。自分の頭の整理のためにQiitaに記事を書いていきます。なお「本記事のソースはChatGPT」なので誤りが含まれている可能性は、あらかじめご了承ください
スタックとヒープはどう違うのか?
以下はChatGPTのGPT-4によって作成してもらった表です。
| 特性 | スタック (Stack) | ヒープ (Heap) |
|---|---|---|
| 使用時期 | 関数呼び出し時に自動的に使用される | プログラマによって明示的に割り当てられ、解放される |
| 管理方法 | コンパイラによって自動管理される | プログラマが直接管理する |
| 寿命 | 関数が終了すると解放される | 明示的に解放されるまで残る |
| サイズ | 固定サイズ(コンパイル時に決定) | 動的サイズ(実行時に決定) |
| メモリ割り当て | 速い(連続したメモリブロック) | 比較的遅い(非連続メモリを探す必要がある) |
| 例 | ローカル変数, 関数の引数, 戻り値 ,関数コールスタック | malloc()やcalloc()で割り当てられたデータ , 大きなデータ構造体 |
簡単に言うと、malloc などで確保したメモリがヒープ、関数を抜けると自動的に消えてしまうような変数はスタックだと思われます。さらにChatGPTに説明してもらいましょう。
- ヒープ
- ヒープでのメモリ割り当ては、通常、OSのシステムコールを通じて行われます。C言語で
malloc()やcalloc()のような関数を使用すると、これらは内部的にOSのシステムコールを使ってメモリを割り当てます。これにより、必要に応じて動的にメモリが割り当てられ、解放されます。
- ヒープでのメモリ割り当ては、通常、OSのシステムコールを通じて行われます。C言語で
- スタック
- スタックに関しては、プログラムが起動するとき、OSは自動的に一定量のスタックメモリを割り当てます。関数が呼ばれると、その引数、戻り値、およびローカル変数がこのスタック上に配置されます。関数が終了すると、そのデータは自動的に解放されます。
スタックはプログラムの起動時にメモリが割り当てられます。このメモリのサイズを超えて、メモリを確保しようとすると「スタックオーバーフロー」が発生するそうです。よく深い再帰を利用すると、クラッシュするやつですね。これは関数コールスタックがスタックに確保されることによるのでしょう。
スタックに割り当てられたメモリの寿命
スタック上の変数の寿命は、それが定義されている関数のスコープに限定されます。Crystal言語においてもこの原則は有効みたいで、スタックに確保されたデータは、メソッドの壁を超えて利用することはできません。インスタンス変数にスタックを利用してデータを確保し、それをgetterから呼び出すと、データはコピーされます。コピーされたデータに変更を加えても反映されません。
しかし、一方で、@ をつけて呼び出すと、直接メモリが参照されます。スタックにメモリを確保したオブジェクトは、コピーが非常に速いですが、変更を繰り返すような場合は遅くなるという特性があるので注意が必要です。
スタックで確保された領域は、関数を抜けると自動的に解放されるとされます。(具体的には関数が終了するとスタックポインタが元の位置に戻るそうです)おそらくCrystalでもこの原則が守られているのではないかと思います。
Crystal的には、スタックが使える場合はスタックを使った方がパフォーマンスが良くなるようです。
ヒープに割り当てられたメモリはlibgcで管理される
スタックに割り当てられたメモリは自動的に解放されますが、ヒープの場合はそうではありません。しかし、Crystal言語を使っている限り、ユーザーはそのことを気にする必要はありません。なぜなら bdwgc(libgc)が管理してくれるからです。
libgcを使うためには malloc を GC_malloc に変更し realloc を GC_realloc 変更するそうです。
(libgcの動作原理ですが、ChatGPTによると、プログラム全体をスキャンしてスタック、レジスタ、グローバル変数領域などからポインタと思われる値を探して、それが指し示すメモリ領域がまだ有効かどうか(プログラムから到達可能かどうか)を判断するそうですが、この動作原理が正しいとするとかなりの実行コストがかかりそうです。ChatGPTが間違っている可能性もあるので話半分にしてください。)
一般的な方針
そこで、関数内で完結するような一時的なデータ、コピーしても構わないような小さなデータ構造にはスタックを利用し、関数のスコープを超えて利用されるような大きなデータ構造や、長期間保存する必要があり変更を加えないようなデータにはヒープを利用するのがよいと思われます。なお、ポインタはどちらのケースでも使えて、スタックに配置された構造体がヒープ上の領域を指し示すポインタを持つことも、ヒープ上に配置された構造体が、スタック上の領域を指し示すポインタを持つことも可能だそうです。
メモリアドレスを見るだけでヒープ領域かスタック領域か判別できるのか?
C言語の提供する抽象化レベルで、メモリアドレスは単にメモリ空間内のある位置を指し示すものであるため、その位置がスタック領域に属しているのか、ヒープ領域に属しているか判別することはできないそうです。(とChatGPTは言っています)
メモリマップを表示する
Linuxでは、プロセスのメモリマップを表示することができます。
cat /proc/1234/maps
とするとPID 1234 のプロセスのメモリマップを表示することができます。pmap コマンドを使うことでさらにわかりやすい表示にすることができます。
pmap -x [PID]
Crystalで作成したツールのメモリ使用状況のプロファイルについてはもう少し勉強したうえで、別に記事を書きたいと思います。
マルチプロセスにおいてメモリはどう共有されるのか
これもChatGPTに聞いた内容ですので、Crystalにどこまで当てはまるかはわかりませんが、C言語における一般論としては
- プロセスが異なればメモリは別に管理される
- Rubyの
forkとか parallel はこれに相当するのだと思います
- Rubyの
- プロセスが同じであれば、ヒープ領域と、グローバル変数は、プロセス内のすべてのスレッドで共有される
- それぞれのスレッドは独自のスタック領域を持つ
そうです。Crystalでもこの原則が当てはまるかどうかはわかりませんでした。
詳しく知っている人がいたら、コメント欄で教えていただけると幸いです。
マルチスレッドでメモリの書き換えには、Channelを使ったりMutexやAtomicといったものを使いこなす必要があるようです。
この記事はChatGPT(GPT-4)を情報源にしたところも多く、やや確度が低いです。しかし残念ながらCrystal言語に関する日本語の資料はほとんどないので、正しい情報を知りたい場合は、GitHubのissueの英語の議論をゴリゴリ調べた上で、ソースコードを自分で読み解く、それしかないと思います。誰か信頼性の高い日本語の記事を書いてほしいですね。この記事は以上です。