メモリ管理物語〜GCはあなたを解放するか〜

表題は領域の解放と苦役からの解放の双方にかけて、適当につけてみた。


昨今自分が関与しているお仕事が Unity3d を用いたものだったりするのだけども、現在この環境における第一言語C#となっている。C#は元々 Microsoft から出て来た言語であるけども、聞くところによれば Unity が PlayStation系の環境もサポートすることによって、本家MSのXBOX系よりも使われる頻度が高くなったみたいな話も聞く。真偽の程はともかくとして、それはそれで興味深い話ではあるけども、今回の話題はそんなことではなく、そのC#の「メモリ管理」だったりする。


だいぶこの言語も定着し初期に比べては落ち着いた感があるが、C#という言語処理系を語る時、CやC++と比較して「GCの存在」が語られることは少なくない。時には実装は違えどGCを持つという点においては同様なJavaも引き合いに出され、このGCの存在が「メモリ管理からプログラマを解放するC/C++に対する優位性」のように語られたりする。ところが、Unity3dの仕事を請けるようになってから幾度となく遭遇したのは「C#環境における(主に他人の書いたコードの)領域解放タイミングにまつわるトラブル」なのだ。


GCはメモリ管理の苦役からプログラマを解放する福音ではなかったのか?
一体何がこのトラブルを引き起こしているのか?


…もちろん、賢明なプログラマはそんな景気の良いセールストークに踊らされてなどいないだろう。このケースも「便利な仕組みの導入は、その仕組みを理解した上での利用を求められる」という定石から全く外れていないからだ。


ひとまずおさらいとして、CやC++の領域管理を軽く説明してからC#の領域管理トラブルについて話してみようと思う。

Cの領域管理

Cは構造化パラダイムの言語環境であり、C++C#Javaに見るような「クラスの概念を導入したオブジェクト指向」をサポートしない。領域管理は基本CPUレベルのスタック上にローカル変数領域を割り当てる「スタックフレーム」と、ヒープと呼ばれるメモリ空間から要求されたサイズのメモリ領域を確保し、不要になった時点で解放を指示する機能を持つmalloc()/free()というペアの標準関数、もしくは同様の仕組みを持つ関数実装を作成することにより行われる(ゲーム機など標準関数が使えない環境でよくやる)。昨今のように幅の広いレジスタを多数持つCPU環境においては、高速化のため引数やローカル変数にCPUのレジスタを割り当てるコードを出力するコンパイラも多いが、動的メモリ領域管理の話からそれるので、それは置いておく。あと、グローバル変数のような静的領域の話も割愛。

ヒープの管理は極めて単純で、「確保しろ(malloc)」で確保、「解放しろ(free)」で解放。確保された領域の内容は不定(実行前の予測が不可能な「ゴミ」データが入っている)であるため、別途初期化が必要となる。

	MyStruct * ptr = malloc(sizeof(MyStruct));
	if(ptr == NULL) {
		/* alloc error */
	}
	ptr->member1 = 0;
	ptr->member2 = 0;
	free(ptr);

また、解放処理は問答無用で行われ、どれだけその領域を参照しているポインタが生きていようとも、解放を命じた時点で領域は解放される。もちろんそれらのポインタ値はそのままなので、慣れないプログラマは領域が解放済みにも関わらず、そこを指していたポインタを利用することに起因するバグをよく引き起こす。

C++の領域管理

C++はCの言語仕様を拡張したものと解釈されることが多い。厳密には異なる動作をするケースがあるが、めんどくさいので説明は割愛。元となったCの構造化パラダイムをほぼ全てサポートした上で、クラスの概念を導入したオブジェクト指向をもサポートするマルチパラダイム言語。テンプレートや多重継承、演算子オーバーロードのような「極めて強力だが濫用が混乱を引き起こしかねない」機能をもち、現在もなお標準化仕様が日々追加改訂されるなど、言語仕様の規模が極めて大きいが故に尻込みするプログラマもよく見かける。こんなラフな解説でもこの程度には長くなるのがめんどくさい。

Cの持つ資産をほぼ受け継いでいるため、同様のmalloc()/free()は勿論使えなくはないが、「C++らしい記述」の面からは推奨されない。C++的には、new演算子でオブジェクトの生成、delete で破棄。これらの演算子の動作はオーバーライド可能であり、デフォルトでは内部で malloc()/free()を呼び出す実装になっているケースがほとんどと思われる。

new演算子は指定された型の表現に必要とされるサイズのメモリ領域を確保し、そこにオブジェクトを生成した上でその領域のポインタを返す。返されたポインタ値をdelete演算子に指定すればオブジェクトが破棄されその領域は解放される。ポインタで指定された領域がどれだけ他から参照されていようとも問答無用で解放されるところはCのfree()同様であるため、起こりうる事故も同様となる。

Cのmalloc()/free()と、C++のnew/deleteの明確な違いは、malloc()/free()が領域の確保/解放のみを行うのに対し、new/deleteは領域の確保のみならず、コンストラクタ/デストラクタ呼び出しによるオブジェクトの生成/破棄を伴うという点と、領域の確保やオブジェクト生成に失敗した場合は NULL ポインタを返すのではなく std::bad_alloc 例外を throw するという点。ながらくCを書いて来たプログラマが初めてC++を使った時によくやらかす誤りが、

	// type CClass is class.
	CClass * ptr = malloc(sizeof(CClass));
	if(ptr == NULL) {
		// alloc error
	}
	free(ptr);

のようなもので、malloc()ではコンストラクタによる領域の初期化が行われず、上記例のptrが指す領域はオブジェクトとして正しく生成されたものとはならない。さらにfree()ではデストラクタが実行されないためオブジェクトの破棄に必要な後始末が行われず、このクラスがまた別の領域へのポインタを握るような場合等大変に面倒なことになる(メモリリークなど)。なまじ長い伝統を持つCの資産を継承してしまったため、中途半端すぎる理解ではこうした誤りを生み出しかねないのが、一定以上理解している人材を欠いたままC++を用いる現場の悩ましいところかもしれない。

これは最低でも下記のように書く事が求められると思われる。

	try {
		CClass * ptr = new CClass();
		
		delete ptr;
	} catch(std::bad_alloc& ex) {
		// alloc error
	}

上記 new のタイミングで領域確保とコンストラクタの呼び出しが行われ、deleteのタイミングでデストラクタによる後始末が行われた後、領域が解放される。newが失敗したときはstd::bad_alloc例外がthrowされるので、それをcatchすることでエラー処理を行う。

newによる領域確保からコンストラクタ呼び出しを経てのインスタンス生成シーケンスについてもよく知らないとおかしな実装のコンストラクタになりかねないので、C++を使う人でそのあたりの理解が曖昧な方は別途学んでおくことをお勧めしたい。

C#環境における「破棄」という概念の煩雑さ

さて、ようやく本題のC#の話ができる。

ただし、Unity(iOS/Android)で使われているEmbedding Mono環境に依存する話が多分に含まれている可能性があるので注意されたし。また、「確保」「解放」「生成」「破棄」「回収」など言葉が入り乱れている感があるかもしれないが、ニュアンスの違いで概ね使い分けている。

  • 確保 - 物理的に必要とされるサイズのメモリ領域確保
  • 解放 - 物理的に確保されていた必要メモリ領域の解放
  • 生成 - プログラミング概念上オブジェクトとして意味を成すもの(インスタンス)の生成
  • 破棄 - プログラミング上の概念であるオブジェクトの破棄
  • 回収 - 不要となったオブジェクトに割り当てられていた資源を再利用するための、GCによる回収

「それが物理的にどう表現されているのか」と「それが概念上どういう意味を持つのか」を切り分けて考えるのはプログラマとして大切だと思う。料理は食材から作られるが、料理=食材ではない。


CやC++の領域解放は極めてシンプルで「他の誰がそれを使っていようが、プログラマが解放しろといったら解放」だった。だがC#ではこの事情が異なってくる。オブジェクトは C++ に似て new で生成されるが、明示的なオブジェクト破棄を個別に行う構文は存在しない。


…今「usingブロックやIDisposable.Dispose() は?」と思ったあなた。
あなたの理解は「曖昧かつ怪しい」。


C#において「オブジェクトの破棄」という言葉は、注意深く用いなければ非常に誤解を招きやすい表現かもしれない、漠然と会話していると異なる意味に捉えられることがある。たとえば「C#オブジェクトの破棄」という言葉から、あなたは次の具体的な動作のどれを想像しただろうか? あるいは全く別の何かかもしれないが。

  1. 使用済みオブジェクトへの参照を、スコープ外に出たりプロパティにnullを放り込むなどで切ること
  2. どこからも参照されなくなったインスタンス群の、GCによる領域回収のこと
  3. IDisposableインタフェースを持つクラスインスタンスの using ブロック終了に伴う Dispose() メソッド実行
  4. IDisposableインタフェースを持つクラスインスタンスの、明示的な Dispose() メソッド実行

上記のうち3と4は本質的に同じであるが、他は概念として全く異なる。これまで遭遇したオブジェクト破棄周辺のトラブルは上記が混同されていたり、下記のような曖昧模糊とした認識で様々な憶測と勘違いが生み出したであろうものばかりだ。

  1. C#GCは参照されなくなったオブジェクトを回収するので、参照している変数のスコープ外に出たり、握っているプロパティにnullを放りこめばGCが回収してくれるはず」
  2. 「明示的な破棄が必要なオブジェクトはIDisposableインタフェースを実装し、using()ブロックで用いるか明示的にDispose()を呼ぶ事で破棄する必要がある」
  3. 「オブジェクトが破棄される時にはデストラクタが呼び出される」

…上記のどれも「概要としては」大きく間違っていない。いないが、理解がかなり曖昧な点を多分に含んでいる。それぞれ対応する番号で「但し書き」をつけてみる。

  1. 回収され実際に領域が破棄されるのは「プログラム域からの参照経路を全て失った」オブジェクトまたはそのグループであり、その回収が行われるのは「GCが動作するタイミング」となる。明示的にGC.Collect()を呼び出したりしない限りそれがいつになるかはわからない。そしてその時まで領域それ自体は厳然として存在し続ける。
  2. IDisposable.Dispose()はusingブロックの終了で自動的に呼び出される他は単なるメソッドであり、呼び出したところで領域が破棄されるわけではない。参照経路がプログラム域に繋がっている限り、Dispose()を呼んでも領域そのものは存在し続ける。usingブロックでnewされたものについてはブロック終了とともにスコープ外となり参照が切れる、というだけであり、やはりGCによって回収されるまで領域は存在する。
  3. デストラクタが呼び出される「オブジェクトが破棄される時」とは「GCによって回収されるタイミング」のことであって、参照経路が断たれたタイミングではない。そして、やはりGC.Collect()を呼ぶなどしない限り、それがいつになるかはシステムのみぞ知る。

要点は、C#における「Dispose()による後始末」「オブジェクトが参照を失うこと」「GCによる領域の回収」はそれぞれ独立した事象であり、それらが同期していると考えてはいけないという点だ。この理由により、Dispose() とデストラクタが呼ばれるタイミングは異なるし、Dispose()を呼んだ後もオブジェクトを参照し続けることは可能だ。呼び忘れに備えてデストラクタからDispose()を呼ぶのを忘れてはいけないが、そのためにDispose()の複数回実行にも備える必要がある。後始末〜領域破棄の流れが複雑な上に時間差をもって行われることを理解しておかなければならない。


純粋にC#実装のクラスを書いている限りにおいてはそもそも IDisposable などまず使わないだろうが、これを勘違いしたまま Unmanaged Codeを握るクラスやその周辺を書いたりすると、破棄タイミングが入り乱れて予測/制御できないケースなどで地獄を見る可能性がある。特に Unity の Scene 切り替えタイミングなど。

GCは確かに救いである(※ただし動作を一定以上理解する者に限る)

C#や今回は説明しなかったJavaの持つGCは、確かに一定以上のレベルでメモリ管理からプログラマを解放してくれる。しかしそれは「GCがあるからメモリ管理やオブジェクト寿命について知らなくても大丈夫」というものではない。むしろ「GCの挙動を一定以上理解した上での管理が必要になる」と言える。

場合によっては、C/C++のような「破棄と言ったら問答無用で破棄」のような環境のほうがタイミングを把握しやすく問題発生時の対処も楽かもしれないとも思えるほど、C#のオブジェクト破棄周りは時間差でクライシスが訪れるのが、デバッグの不得手な人には厄介かもしれない。

そしてその度に、俺のような人間が調査にかり出され、仕事が増えてゆくわけだ。そうならないように、出来ればプログラムを書いている時点で「オブジェクト使用後にどう始末をつけるべきか」を理解の上、何がおこるかを予測しながらコードを書いてくれる人ばかりなら、俺も仕事が楽なのだけども。


そういえばJavaVM実装によってGCの動作が異なるとかどこかで聞いたような…(悪寒
…いや、Java屋ではないので、さすがにVM実装まではよく知らんけども。