[C言語] メモリリークは悪なのか
はじめに
今回はメモリリークは悪なのか調べていきたいと思います。
前提として
・仮想マシン(Linux)を使用しています。
・C言語中心に話していきます。
なぜ調べようと思ったのか
筆者が42Tokyoというエンジニア養成機関でC言語を学ぶ中で、メモリリークについてはとても厳しく評価されました。周りが厳しいので勝手に自分もメモリリークは悪だと根拠なしに考えていましたが、根拠を知らずにそれが正解だとするのはあまりにもエンジニアらしくないと思ったからです。
メモリリークとは
メモリリークとは動的に確保したメモリを開放せずに残すことです。
システムのメモリ量
システムのメモリ量、使用中のメモリ量をfreeコマンドで確認できます。
$ free
total used free shared buff/cache available
Mem: 4019084 1308620 1819296 56080 1107072 2710464
Swap: 999420 0 999420
・total: システムの全メモリ量
・used: システムが使用中のメモリ量からbuff/cacheを引いた量
・free: 見かけ上の空きメモリ量
・shared: 複数プロセス間で共有されているメモリ量
・buff/cache: バッファキャッシュとページキャッシュが利用するメモリ
・available: 実質的な空きメモリで、freeが足りなくなってきた時にシステムが利用しているバッファやキャッシュで解放可能な分をfreeに足した量
(余談)MemとSwapとは?
Memとは物理メモリ、Swapとはスワップ領域(仮想メモリ領域)のことです。
物理メモリ
物理メモリとは実際にPCに搭載されているメモリチップのこと。
CPUが直接データを読み書きするので高速だが、容量がシステムに依存するので増設しない限り拡張できない。
仮想メモリ
OSが物理メモリとハードディスクやSSDなどのストレージを組み合わせて提供するもの。
プロセスごとに独立した仮想アドレス空間が割り当てられ、物理メモリの容量を超えたデータを処理するために使用され使用するページ(固定サイズのデータ)のみを物理メモリにロードする。
実際にメモリを使用して、freeで確認してみる
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
size_t oneGB = 1024 * 1024 * 1024;
char *a = malloc(sizeof(char) * oneGB);
for (size_t i = 0; i < oneGB; i++)
a[i] = '1';
printf("Now using.\n");
while (1)
{
sleep(3);
}
free(a);
printf("Free.");
return (0);
}
1.0(GB) = 1024 × 1(MB)
= 1024 × 1024 × 1(KB)
= 1024 × 1024 × 1024 × 1(B)
charが1バイトなので、上記は1GB分mallocするコードです。
(環境によって配列はmallocするだけでは実際にメモリを使用しないので、for文を用いて全てに要素を入れています)
以下にこのプログラムの実行前と実行中にfreeコマンドを使用してメモリがどうなっているのか確認した結果を載せました。
"-h"オプションでGB単位で表示します。
(実行前)
$ free -h
total used free shared buff/cache available
Mem: 3.8Gi 1.4Gi 1.1Gi 58Mi 1.6Gi 2.5Gi
Swap: 975Mi 524Ki 975Mi
(実行中)
$ free -h
total used free shared buff/cache available
Mem: 3.8Gi 2.4Gi 85Mi 58Mi 1.6Gi 1.5Gi
Swap: 975Mi 524Ki 975Mi
usedが1GB増え、availableが1GB減っていることが確認できます。
freeせずにプログラムが終了したら、そのメモリはどうなるのか
先ほどのコードを少し改修して、メモリを確保した後に解放せずすぐにプログラムを終了するようにしてみます。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
size_t oneGB = 1073741824;
char *a = malloc(sizeof(char) * oneGB);
for (size_t i = 0; i < oneGB; i++)
a[i] = '1';
printf("Now using.\n");
return (0);
}
(実行前)
$ free -h
total used free shared buff/cache available
Mem: 3.8Gi 1.4Gi 1.1Gi 58Mi 1.6Gi 2.5Gi
Swap: 975Mi 780Ki 975Mi
(実行後)
$ free -h
total used free shared buff/cache available
Mem: 3.8Gi 1.4Gi 1.1Gi 58Mi 1.6Gi 2.5Gi
Swap: 975Mi 780Ki 975Mi
結果、実行前と実行後でメモリ量に変化はありませんでした。これはプログラムの終了時にOSが解放するからです。
freeしたメモリは再利用可能なのか
あまりにも大きなメモリを確保しようとすると"Segmentation fault"が起こります。
実際に試してみます。
(コード)
$ cat memory_use.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
size_t oneGB = 1073741824;
char *a = malloc(sizeof(char) * 8 * oneGB); // 8GB確保
for (size_t i = 0; i < 8 * oneGB; i++)
a[i] = '1';
printf("Now using.\n");
while (1)
sleep(3);
return (0);
}
(実行結果)
$ ./a.out
Segmentation fault
このSegmentation faultの詳細を"sudo dmesg"コマンドで確認することができます。
$ sudo dmesg
...
[ 7576.208119] __vm_enough_memory: pid: 7039, comm: a.out, no enough memory for the allocation
では、一気に8GBを確保するのではなく、1GB確保->free->1GB確保->free..を8回繰り返すことはできるのでしょうか?検証してみます。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
size_t oneGB = 1073741824;
for (size_t i = 0; i < 8; i++)
{
char *a = malloc(sizeof(char) * oneGB);
for (size_t i = 0; i < oneGB; i++)
a[i] = '1';
printf("Now using.\n");
free(a);
}
while (1)
sleep(3);
return (0);
}
(実行結果)
$ ./a.out
Now using.
Now using.
Now using.
Now using.
Now using.
Now using.
Now using.
Now using.
8回繰り返せることが確認できました。
ただ、これで再利用できていると結論付けるには少し弱い気もするのでmallocしたときのアドレスを確認しようと思います。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
size_t oneGB = 1073741824;
for (size_t i = 0; i < 8; i++)
{
char *a = malloc(sizeof(char) * oneGB);
for (size_t i = 0; i < oneGB; i++)
a[i] = '1';
printf("address: %p\n", a); //先頭のaddressを出力
free(a);
}
while (1)
sleep(3);
return (0);
}
(実行結果)
$ ./a.out
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
address: 0xffff54d0f010
毎回アドレスが一致していることがわかります。
もしも繰り返す中で毎回freeしないと以下のようになります。
(実行結果)
$ ./a.out
address: 0xffff5d35f010
address: 0xffff1d35e010
address: 0xfffedd35d010
Killed
実行途中でKilledされていますが、毎回アドレスが変わっていることがわかります。
よってfreeしたメモリはmallocによって再利用されると言えるでしょう。
(余談)Killed?
先ほど起きたKilledとはあまり馴染みがありませんが何でしょうか?
これはOOM killerという機能が作動しました。詳細を"dmesg"コマンドで確認することができます。
$ dmesg
...
[ 8514.786686] Out of memory: Killed process 7129 (a.out) total-vm:4196508kB, anon-rss:2787128kB, file-rss:1172kB, shmem-rss:0kB, UID:1000 pgtables:6656kB oom_score_adj:200
まず、"free -h"コマンドで確認できる回収可能なメモリ(available)を回収してもメモリが不足している場合、システムはメモリ不足で何もできないOOM(Out Of Memory)という状態になります。
OOMの時は適当なプロセスを強制終了して空きメモリを作ります。それがOMM killerです。
今回は意図的にメモリを不足させたので起こりましたが、普段の作業でOMM killerが起きる時はいずれかのプロセスやカーネルのメモリリークを疑うべきです。
結論.メモリリークは悪なのか
上記の検証を経てメモリリークについて自分なりに考えてみました。
・基本的には使用しなくなったメモリは適宜解放すべき。
->"適宜"というのが大事です。なぜならプログラム終了時に一気に解放してもしなくても結局OSが解放するからです。そうなるとメモリ不足などの問題が起こり得るのはプログラムの実行中です。いかに実行中に不要になったメモリを解放して、その瞬間瞬間で少ないメモリを使用しているかが大事になると思います。
(追記)この記事を書いた後に「富豪的プログラミング」というものの存在を教えてもらいました。簡単に言えば現代のハードウェアなら十分なメモリがある(確保できる)のだから、リソースを少なくすることよりも余分なリソースを使ってでもバグが起きづらかったりデバックのしやすいコードを書こうという考えです。とても論理的で納得感のある考えでした。一概にリソースを節約することが正しいとは言えませんね。
・単純なプログラムでは気にしなくも良い?
->個人のとても簡単で単純なプログラムな分にはメモリリークについては気にしなくても良いと思います。なぜなら結局すぐに終了するプログラムであればその時に解放されるからです。
プログラム終了時に自動で解放されるのなら、特にその終了直前にfreeするのは冗長的といえそうです。
とはいえ、エンジニアとしてmallocに対してfreeの責任を負うというのはコードとしても綺麗ですし、普段から意識しておくと本当にfreeが必要な場面で「freeし忘れる」なんてことはなくなると思います。(現に僕はずっとメモリリークを気にして過ごしていたので、毎回必ずチェックしています)
メモリリークは悪なのか。これをそれぞれのエンジニアが根拠を元に考えることが大切です。
参考文献
佐野 裕, 『Linuxの仕組み』, 日経BP, 2020年, ISBN 978-4822288918
Discussion