メモリの理解は大事
ソフトウェア・エンジニアとって低レイヤーの知識が大事かどうか、よく聞かれます。
直感的には大事だと思っているのですが、その理由についてなかなか言葉にできておりませんでした。低レイヤーと言っても幅広く、その中でも特に大事な部分があるのかな、と。
しかし、最近その中でも、コンピュータがメモリをどのように扱うかという部分は間違いなく大事だな、と思うようになりました。
以前はメモリが高価で貴重なリソースだったため、使い道やどこに割り当てられているかを意識することが重要でした。しかし、ハードウェアの進化により、メモリは非常に安くなっています。そのため、最近はあまりメモリを細かく意識することは減りました。
意識したとしても
「PCのメモリがキツイ!新しいPC買おっかな」
「サーバのメモリが足りなくてプロセスがクラッシュしまくっている!サーバのスペック上げよっと」
こんなやり取りになるのではないでしょうか?
無論、エンジニアの生産性は大事なため、PCやサーバのスペックを少し上げるだけで解決するほうが費用対効果が高いのも理解はしています。
ただ、自分としてはそれでも、ソフトウェア・エンジニアとしてメモリの仕組みを理解することは大事だと思っています。
では、実際にどういうときにメモリを理解していると、どう良いのか、例を交えて紹介させてください。
1. パフォーマンス
まず、メモリを理解しているとパフォーマンスの面で利点があります。
変数がどこに配置されるのか、というのが1つの例です。一般的に変数はスタックに配置されたほうがヒープに配置された場合よりも処理が速くなります。その背景をいくつか述べておくと…
スタックはメモリ上で連続して配置されるため、キャッシュの局所性によりCPUがアクセスしやすいため
ヒープのような間接参照がないため
実際にスタックとヒープでどれぐらい差が出るのか実験してみました。
実験に使用したコードはStackoverflowで以下のコードを見つけたので、こちらを利用したいと思います。
#include <cstdio>
#include <chrono>
namespace {
void on_stack() {
int i;
}
void on_heap() {
int* i = new int;
delete i;
}
}
int main() {
auto begin = std::chrono::system_clock::now();
for (int i = 0; i < 1000000000; ++i) {
on_stack();
}
auto end = std::chrono::system_clock::now();
std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());
begin = std::chrono::system_clock::now();
for (int i = 0; i < 1000000000; ++i) {
on_heap();
}
end = std::chrono::system_clock::now();
std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
return 0;
}
コードを軽く解説をしておきます。
on_stack 関数内ではintをスタック上で、 on_heap関数内ではnewを使って intをヒープ上に作成しています。
実際にClangを使ってコンパイルして実行してみましょう。なお、コンパイラの最適化オプションはオフにしています。※1
% clang++ -O0 -o stack_vs_heap stack_vs_heap.cpp
:
:
% ./stack_vs_heap
on_stack took 1.581633 seconds
on_heap took 16.051865 seconds
かなり差が出ました。
なるべくon_heapみたいな結果になるコードは避けたいですね😊
少し結論ありきな例かもしれませんが、こういうことを知っているだけでもパフォーマンスを意識したコードが書けるようになると思います。
メモリの重要性が(少しずつでも)伝わりましたでしょうか?
2. 言語の意味論の理解
メモリの理解が役立つもう一つの例として、言語の意味論(semantics)の理解があります。
例えば、関数の引数がどのように渡されるかというのはその一つです。
こちらも例を用いて説明してみます。
C++では、デフォルトで関数の引数は「値渡し」されます。つまり、関数の呼び出し時に引数がコピーされます。
void increment(int x) {
x++; // ローカル(関数内)のコピーのみ変更する
}
int main() {
int num = 5;
increment(num);
cout << num; // まだ5が出力される
}
上の例では、引数xは「値渡し」されており、呼び出された関数内で行われることは元の変数に影響を与えません。
CやC++では、&演算子を用いて変数への「参照を渡す」こともできます。この場合はコピーでははなく、変数への参照が渡されます。
void increment(int& x) {
x++; // 元の変数を変更している
}
int main() {
int num = 5;
increment(num);
cout << num; // 6が出力される
}
ここでは引数xは「参照渡し」されており、関数内で値を更新すると元の変数が変更されます。
パッと見では分かりにくいと思います。直感的な説明をすると、2つ目の例では変数の場所(アドレス、参照)を渡していると考えることができます。これは変数をコピーしたり、呼び出された関数内で元の値を変更したい場合に使われます。※2
これだけだと中途半端な説明かもしれませんが、メモリの仕組みを理解していると言語の意味論なども理解しやすくなることは伝わるかと思います。
基本のもう一つの例
自分が好んで書く「基本が重要である」の一ネタでした。
もし興味があれば、以前の書いた記事も読んでみてください。
それではコーディングを楽しんでください!そしてメモリを忘れずに😉
※1 最適化をオフにしないと、実際には何も意味のない処理だとコンパイラが検知して関数の中身ごと削除されコンパイルされる可能性があるからです。
※2 正しくは、関数の引数がどのように渡されるかはCPUアーキテクチャの呼び出し規約や、コンパイラの振る舞い(パフォーマンス上の理由など)によって決まるので一概には言えないのですが、今回は詳細は割愛させていただきました。