メモリ管理
少しでも大きなプログラムなら、 ほぼ確実に、メモリ割り当てと解放が必要になります。 プログラムが複雑に、大きくなるにつれ、メモリ管理技術はより一層重要になります。 Dは、メモリ管理の様々なオプションを提供します。
Dでメモリを割り当てる簡便な方法とは次の3つです:
- 静的データ。デフォルトのデータセグメントに割り当てられる。
- スタックデータ。CPUのプログラムスタックへ割り当てられる。
- ガベージコレクトされたデータ。 GCのヒープから動的に割り当てられる。
この章では、これらを使うテクニックと、 より高度な代替手段について説明します。
- 文字列 (と 配列) の Copy-on-Write
- リアルタイム
- スムーズな操作
- フリーリスト
- 参照カウント
- 明示的なクラスインスタンス割り当て
- Mark/Release
- RAII (Resource Acquisition Is Initialization)
- スタックへのクラスインスタンス割り当て
- スタックへの未初期化配列の割り当て
- 割り込みサービスルーチン
文字列 (と 配列) の Copy-on-Write
配列を受け取って、それを(もしかしたら)変更して返す関数を考えてみましょう。 配列は値ではなく参照渡しされますので、 "この配列の内容の所有者は誰だ?" という重大な問題が持ち上がります。 例えば、配列の内容を大文字に変換する関数:
char[] toupper(char[] s) { int i; for (i = 0; i < s.length; i++) { char c = s[i]; if ('a' <= c && c <= 'z') s[i] = c - (cast(char)'a' - 'A'); } return s; }
呼び出し側のs[]も変更されることに注意してください。これは、 意図しない動作かもしれません。あるいはもっと悪く、s[] はメモリの読み取り専用領域のスライスかもしれません。
しかし、toupper() で常に s[] のコピーを作ると、 既に文字列が大文字だった場合には、 無駄にメモリと時間を消費してしまいます。
解決策は copy-on-write、 つまり文字列を変更する必要があるときだけコピーする、 方式で実装することです。いくつかの文字列処理言語では、 これが実際にデフォルトの挙動となっていますが、これは大きなコストがかかっています。 文字列 "abcdeF" は関数によって5回コピーされることになるでしょう。 最大に効率的に実装するには、copy-on-write を明示的にコードとして書くことです。 以下に、効率的にcopy-on-writeを実装して書き直したtoupperを示します。
char[] toupper(char[] s) { int changed; int i; changed = 0; for (i = 0; i < s.length; i++) { char c = s[i]; if ('a' <= c && c <= 'z') { if (!changed) { char[] r = new char[s.length]; r[] = s; s = r; changed = 1; } s[i] = c - (cast(char)'a' - 'A'); } } return s; }
D の Phobos ランタイムライブラリでは、配列処理関数は copy-on-write で実装されています。
リアルタイム
リアルタイムプログラミングでは、プログラムは、遅れ時間の上限か 操作完了にかかる時間の保証をしなくてはなりません。ほとんどの メモリ割り当て操作、これはmalloc/freeやGCを含みますが、には、 理論的な時間の上限はありません。 レイテンシを保証する最も信頼性のある方法は、 時間制限の厳しい箇所で必要なメモリは全てあらかじめ割り当てておく、 という手です。もしそこにメモリ割り当てのコードがなければ、 GCは走らず、時間制限を超過してしまう原因にもなりません。
スムーズな操作
リアルタイムプログラミングに関連した事項として、 プログラムがGCによって任意の時点で動作中断することなく、 スムーズに切れ目無く動作することが必要な場合があります。 このようなプログラムの例としては、ガンシューティング・ゲームが上げられます。 ゲームが不規則に中断するのは、プログラムにとっては致命的ではありませんが、 ユーザーにとっては迷惑なことでしょう。
いくつか、この挙動を和らげるテクニックがあります:
- スムーズに動作しなくてはならない箇所の前に、 必要なデータを全て割り当てておく。
- 元々プログラムが停止しているような箇所で、 手動でGCを走らせる。 そのような箇所の例としては、 ユーザーにプロンプトを出したけれど ユーザー入力がまだ無い、という状態があります。これによって、 GCが起きて欲しくない箇所でGCが走る確率を減らすことができます。
- スムーズ動作の前に、gc.disable() を呼んでおき、後に gc.enable() を呼び出す。これによって、GCは未使用メモリを回収せずに新しいメモリを 割り当てるようになります。
フリーリスト
フリーリストは、頻繁に割り当て/破棄が行われる型へのアクセスを高速化する 強力な手法です。考え方は簡単で、使い終わったオブジェクトは、解放せずに フリーリストへ積んで置くというだけです。割り当て時は、 まずフリーリストのオブジェクトを1つ取り出してきて使います。
class Foo { static Foo freelist; // フリーリストの先頭 static Foo allocate() { Foo f; if (freelist) { f = freelist; freelist = f.next; } else f = new Foo(); return f; } static void deallocate(Foo f) { f.next = freelist; freelist = f; } Foo next; // FooFreeList で使うため ... } void test() { Foo f = Foo.allocate(); ... Foo.deallocate(f); }このようなフリーリスト方式で、高いパフォーマンスを得ることも可能です。
- 複数のスレッドから使う場合、allocate() と deallocate() 関数には同期処理が必要です。
- allocate() フリーリストから取得されるとき、 Fooのコンストラクタは再実行されません。
- RAIIと組み合わせる必要はありません。 例外が投げられてフリーリストへ 戻されなかったオブジェクトなども、 いずれGCによって回収されます。
参照カウント
参照カウントとは、オブジェクトにカウント変数を含める、 ということです。 オブジェクトを指す参照が増えればカウントを増やし、参照が終われば カウントを減らします。カウントが0になったオブジェクトは削除できます。
D は参照カウントを自動化する方法を提供していません。 明示的に記述する必要があります。
Win32 COM プログラミング では、参照カウントの管理に、 メンバ AddRef() と Release() を使います。
明示的なクラスインスタンス割り当て
D は、クラスのインスタンスのためのカスタムアロケータ/デアロケータを 作る方法を提供しています。通常、インスタンスはGCのヒープから確保され、 GCが走ったときに解放されます。特定の目的に対しては、 NewDeclaration と DeleteDeclaration を書いてメモリ割り当てを管理することができます。 例えば、Cのランタイム関数 malloc と free で割り当てを行う例です:
import std.c.stdlib; import std.outofmemory; import std.gc; class Foo { new(size_t sz) { void* p; p = std.c.stdlib.malloc(sz); if (!p) throw new OutOfMemoryException(); std.gc.addRange(p, p + sz); return p; } delete(void* p) { if (p) { std.gc.removeRange(p); std.c.stdlib.free(p); } } }
new() の重要な特徴は:
- new() には返値型を指定しませんが、 void*として定義されます。 new() は void* を返さなくてはなりません。
- new() がメモリを割り当てられなかった場合、null を返すのではなく、 必ず例外を投げるようにします。
- new() の返すポインタは、 デフォルトのメモリ境界に整列されている 必要があります。Win32 システムでは、8バイト境界です。
- 割り当て関数が Foo から派生したより大きいサイズのクラスから 呼び出された場合のために、 size パラメタが必要です。
- メモリを割り当てられなかった場合、null を返すのではなく、 必ず例外を投げます。どの例外を投げるかはプログラマに任されています。 この例の場合は OutOfMemory でした。
- GCがメモリ中のポインタを探索する時には、 静的データセグメントと スタックは自動的に探索されますが、 C のヒープはそうではありません。 このため、FooやFooから派生したクラスがGCから確保した領域への参照を 保持するときには、そのことをGCに伝える必要があります。 これは、gc.addRange() メソッドによって行います。
- メモリの初期化は必要ありません。インスタンスのメンバを デフォルト初期化をするコードや、(もしあれば) コンストラクタを呼ぶコードが new() の呼び出しの後ろに 自動で挿入されます。
- (もし存在すれば)デストラクタは、 既に 引数p に対して呼び出されています。 従って、pの指すメモリは既にゴミとなっていると仮定すべきです。
- ポインタ p は null である可能性があります。
- GC へ gc.addRange() で通知してあった場合は、deleteでは対応して gc.removeRange() を呼び出す必要があります。
- delete() を作るなら、対応する new() も作成してください。
クラス特有のアロケータを使ってメモリを割り当てる場合は、 '宙ぶらりんポインタ' や 'メモリリーク' が発生しないように 注意深くコードを書かなくてはなりません。例外の存在まで考慮すると、 メモリリークを防ぐためには RAII を使う習慣が重要です。
構造体や共用体についても、 カスタムアロケータを定義して使うすることができます。
Mark/Release
Mark/Release は、スタックを使うのと同等のメモリ割り当て/解放方法です。 「スタック」はメモリ中に作られます。オブジェクトは、メモリ中でポインタを 下に動かすことで作成します。個々のポイントが'mark'され、 その点へポインタを戻すことで、それまでに確保されたオブジェクトを 'release' します。
import std.c.stdlib; import std.outofmemory; class Foo { static void[] buffer; static int bufindex; static const int bufsize = 100; static this() { void *p; p = malloc(bufsize); if (!p) throw new OutOfMemoryException; std.gc.addRange(p, p + bufsize); buffer = p[0 .. bufsize]; } static ~this() { if (buffer.length) { std.gc.removeRange(buffer); free(buffer); buffer = null; } } new(size_t sz) { void *p; p = &buffer[bufindex]; bufindex += sz; if (bufindex > buffer.length) throw new OutOfMemory; return p; } delete(void* p) { assert(0); } static int mark() { return bufindex; } static void release(int i) { bufindex = i; } } void test() { int m = Foo.mark(); Foo f1 = new Foo; // 割り当て Foo f2 = new Foo; // 割り当て ... Foo.release(m); // f1 と f2 を解放 }
割り当て用の buffer[] それ自身は、 GC 対象領域に追加されます。 このため、Foo.new() でその作業をするコードは必要ありません。
RAII (Resource Acquisition Is Initialization)
明示的なアロケータ/デアロケータを使っているときは、 メモリリークを避けるためにRAIIというテクニックが有効です。 そのようなクラスには scope属性 を加えると良いでしょう。
スタックへのクラスインスタンス割り当て
クラスのインスタンスは、通常はガベージコレクタに管理された ヒープに割り当てられます。しかし、以下の条件が満たされていれば…
- 関数のローカルシンボルで
- new で割り当てられていて
- その new にはアロケータ用の引数が指定されておらず(コンストラクタ用の引数は可)
- scope 宣言されている
…インスタンスはスタックに割り当てられます。 これはインスタンスのallocate/freeを行うよりも効率的ですが、 オブジェクトへの参照が関数からのreturn後も生き延びることのないよう、 注意が必要です。
class C { ... } scope c = new C(); // c はスタックに割り当てられる scope c2 = new C(5); // スタックに割り当てられる scope c3 = new(5) C(); // カスタムアロケータで割り当てられる
クラスにデストラクタが定義されていた場合、 そのデストラクタが、オブジェクトがスコープを抜けるタイミングで呼ばれることが保証されます。 例外でスコープを抜けるときも同様です。
スタックへの未初期化配列の割り当て
Dでは、配列は常に初期化されます。例えば、以下の宣言:
void foo() { byte[1024] buffer; fillBuffer(buffer); ... }
は、最初にbuffer[]の中身を初期化するため、 あなたが思うほどは速くないかもしれません。もし注意深くプロファイルを取った結果、 この初期化がスピードに関して問題になっているとわかったならば、 Void初期化子によってこれを除去することができます:
void foo() { byte[1024] buffer = void; fillBuffer(buffer); ... }
スタック上の未初期化データには、 使用する前によく考えておかねばならない危険が伴います:
- スタック上の未初期化データは、参照が残っていないか、 ガベージコレクタによって探索されます。未初期化データの中身は元々 D のスタックフレームだったものでしょうから、たぶんそこには GC のヒープへの参照が残っていて、その部分のGCメモリが解放されなくなります。 この問題は実際に発生するもので、 追跡するのは極めてやっかいです。
- 関数から、スタックフレームに割り当てられたデータへの参照を 外に返すことは可能です。 その後新しいスタックフレームを古いデータの上に割り当て、未初期化で使った場合、 古いデータへのリファレンスはまだ有効であるかのように振る舞います。 このようなプログラムはエラーの要因です。スタックフレーム上の全てのデータの初期化は、 多くのバグを確実に再現性があるものにするのに大きな役に立ちます。
- 未初期化データは、正しく使ってさえ、バグとトラブルの元です。 我々のDの設計の目的は、 未定義動作のもとを除いて信頼性と移植性を高めることでした。 そして、未初期化データは未定義・無移植性・エラー的・予測不能動作の大元です。 したがって、ここにあげたイディオムは、他に最適化の種が尽きて、 ベンチマークが確かに該当箇所が全体のボトルネックになっていることが確認できた時のみ、 使用されるべきです。
割り込みサービスルーチン
ガベージコレクタがメモリ回収を実行するときには、 レジスタやスタックの内容を調べてGCで割り当てたオブジェクトへの参照を集めるために、 いったん全てのスレッドの実行を停止する必要があります。 しかし、ISR (Interrupt Service Routine / 割り込みサービスルーチン) スレッドを停止してしまうと、プログラムの動きがおかしくなってしまいます。
このため、ISR スレッドは停止すべきではありません。 標準ライブラリ std.thread の関数で作ったスレッドは停止しますが、Cの _beginthread() やそれと同等のルーチンで作ったスレッドは、 GCがその存在を知らないため、停止しません。
これを使って適切な動作を得るためには:
- ISR スレッドはGCでメモリを割り当てることはできません。 つまり、グローバルな new を使用できないということです。 さらに、動的配列のリサイズや、 連想配列への要素追加も不可です。Dランタイムライブラリを使うときは、 GCでメモリを割り当てる可能性がないことを確認するか… もっとベターなのは、ISR では D のライブラリ関数を呼ばないことです。
- 「ISR だけがGCで割り付けたオブジェクトへの参照を保持している状態」 を作ってはいけません。そうなると、たとえISRで使用中でも GC がメモリを解放するおそれがあります。解決策としては、 別にダミーの停止スレッドを作ってそこにも参照を置いておくか、 参照をグローバルに保持しておくことです。