この記事は、
Go Your Own Way (Part Two: The Heap) – The D Blog
を自分用に翻訳したものを 許可を得て 公開するものである。
今回からソース中にコメントの形で原文を残している。 誤字や誤訳などを見つけたら今すぐ Pull requestだ!
この投稿はD プログラミング言語のガベージコレクションについての 進行中のシリーズの一部であり、 GCの外のメモリのアロケーションに関する2番目の投稿です。 パート11 ではスタックアロケーションについて論じました。 今回は非GCヒープからのメモリのアロケーティングについて見て行きます。
まだシリーズ4番目の投稿なのにも関わらず、 これはGCを回避する方法について話す3番目の投稿です。 早合点しないで欲しいのですが、 この事実はDのガベージコレクタから離れようとするプログラマに対する警告ではありません。 全く逆のものです。 いつ、どのようにGCを回避するか知ることは、 どのようにGCを効率的に受け入れるかを理解するために不可欠なものです。
何度でも力説しますが、効率的なガベージコレクションにはGCへのストレスを減らすことが必要です。 最初2からこのシリーズで続けて強調しているように、 それは必ずしもGCを完全に回避することを意味しません。 どれくらいの頻度で、どれくらいの量のGCメモリをアロケートするかについて考えるということです。 GCアロケーションの回数を減らす事はコレクションが発生する可能性を減らす事を意味します。 GCヒープからアロケートする量を減らす事はスキャンするメモリの量を減らす事を意味します。
どのような種類のアプリケーションがGCの影響を受けるのか、受けないのかを、正確かつ一般的に述べる事はできません。 それはアプリケーションによります。 ただ、多くのアプリケーションでは一時的にGCを回避したり無効化したりする必要はないものの、 必要な時にどうすればいいのか知る事は重要だ、という事は言えます。 スタックからのアロケーティングは明らかなアプローチですが、Dでは非GCヒープからのアロケーティングも可能です。
遍在するC
良くも悪くも、Cはあらゆるところに存在しています。 こんにち書かれるソフトウェアは、ソースの言語にかかわらず殆どがCのAPIといくらかのレベルで交流するものです。 Cの仕様書が標準ABIを定義していないにもかかわらず、プラットフォーム特有の癖や違いはよく理解されており、多くの言語ではCとやりとりができます。 Dも例外ではありません。 実際、全てのD言語のプログラムはデフォルトでCの標準ライブラリにアクセスできます。
DRuntimeの一部である
core.stdc
パッケージは、
Cの標準ライブラリから変換したDのモジュールのコレクションです。
Dの実行ファイルがリンクされる時、Cの標準ライブラリも一緒にリンクされます。
アクセスに必要なことは、適切なモジュールのインポートだけです。
import core.stdc.stdio : puts;
void main()
{
puts("Hello C standard library.");
}
Dに慣れていない人の中には、Cの関数を呼ぶ時はextern(C)
アノテーションをつけたり、
Walter Brightの最新の‘D as a Better C’記事のように、
コマンドラインから-betterC
でコンパイルする必要があるという誤解により無駄な労力を払っている人がいます。
そのどちらも正しくありません。
通常のDの関数は呼ばれる関数のextern(C)
宣言以外何ら特殊な努力なしにCの関数を呼ぶことができます。
上記のスニペットでは、
puts
の宣言
はcore.stdc.stdio
の中にあり、それ以上何もしなくても呼び出しができます。
(extern(C)
や-betterC
が実際に何をしているのか詳しく知りたい場合、
dblog-ext.infoにあるこの投稿の
拡張コンテンツをご覧ください。)
malloc
とゆかいななかまたち
DからCの標準ライブラリにアクセスできるという事は、つまりmalloc
、calloc
、realloc
、free
などの関数にもアクセスできるという事です。
これら全てはcore.stdc.stdlib
をインポートする事で利用できるようになります。
また、Dのスライスマジックのおかげで、これらの関数を非GCメモリ管理戦略の基礎として使う事は非常に簡単になります。
import core.stdc.stdlib;
void main()
{
enum totalInts = 10;
// int 10個分のメモリをアロケート
int* intPtr = cast(int*)malloc(int.sizeof * totalInts);
// assert(0) (と assert(false))は常にバイナリの中に残り、
// アサートが無効の場合でも、mallocの失敗をハンドリングできます
if(!intPtr) assert(0, "Out of memory!");
// 関数を抜ける時に解放。この例では必要ありませんが、
// main以外の関数での一時アロケーション戦略
// において便利です。
scope(exit) free(intPtr);
// より扱いやすい長さとポインタのペアを得るためDのポインタをスライス
int[] intArray = intPtr[0 .. totalInts];
}
これはGCだけでなく、Dのデフォルトの初期化も回避します。
GCによりアロケートされた型T
の配列は全ての要素がT.init
に初期化されます。
int
の場合0
になります。
Dのデフォルトの初期化と同じ振る舞いを望むなら、一手間必要です。
この例ではmalloc
をcalloc
に置き換えることで同じ効果が得られますが、これは整数型の場合だけの話です。
例えばfloat.init
は0.0f
ではなくfloat.nan
です。
これについては後で述べます。
(メモリアロケーションの失敗のハンドリングについての詳細はこの投稿の拡張コンテンツを確認してください。)
もちろん、malloc
とfree
をメモリのスライス用にラップしてよりイディオマティックにできます。
単純な例です:
import core.stdc.stdlib;
// スライスとして管理できる型のないバイト列の
// ブロックをアロケートします。
void[] allocate(size_t size)
{
// malloc(0)の実装は定義されています(nullまたはアドレスを返す)が、
// まず求めるものではありません。
assert(size != 0);
void* ptr = malloc(size);
if(!ptr) assert(0, "Out of memory!");
// メモリブロックのサイズと結びつけられたアドレスである
// ポインタのスライスを返します。
return ptr[0 .. size];
}
T[] allocArray(T)(size_t count)
{
// 配列の要素の型のサイズを考慮に入れてください!
return cast(T[])allocate(T.sizeof * count);
}
// 利便性のため2つあるデアロケート
void deallocate(void* ptr)
{
// freeはnullポインタをよしなに扱ってくれます
free(ptr);
}
void deallocate(void[] mem)
{
deallocate(mem.ptr);
}
void main() {
import std.stdio : writeln;
int[] ints = allocArray!int(10);
scope(exit) deallocate(ints);
foreach(i; 0 .. 10) {
ints[i] = i;
}
foreach(i; ints[]) {
writeln(i);
}
}
void[]
はアロケートされたバイト数をlength
プロパティにもつため、
allocate
はvoid*
ではなくvoid[]
を返します。
今回の場合配列をアロケートするので、スライスを返すallocArray
の代わりに直接ポインタを返すよう書き換えることもできますが、
allocate
を直接呼ぶ人はメモリのサイズを考慮し続けなければなりません。
Cにおいて配列とその長さの乖離は
主要なバグの原因であり、
したがってそれらを結びつけるのは早ければ早いほど望ましいことです。
calloc
やrealloc
のテンプレートを除けば、あなたはCのヒープを基にしたメモリマネージャの基礎を習得しました。
ついでに言うと、前述の3つのスニペット(そう、allocArray
テンプレートもです)は-betterC
があってもなくても動作します。しかし通常のDのコードでは、しだいに機能を制限されてきます。
リークを防ぐ
GCヒープの外からアロケートされたメモリのスライスを直接扱うときは、追加、結合、リサイズに注意してください。
デフォルトでは動的配列とスライス組み込みの追加(~=
)と結合(~
)演算子はGCヒープからアロケートをします。
結合は常に結合した結果の文字列のための新しいメモリブロックをアロケートします。
通常、追加演算子は必要な時のみメモリ拡張のためアロケートをします。
下記の例が示すように、非GCメモリのスライスが与えられた時は常にアロケーションが必要です。
import core.stdc.stdlib : malloc;
import std.stdio : writeln;
void main()
{
int[] ints = (cast(int*)malloc(int.sizeof * 10))[0 .. 10];
writeln("Capacity: ", ints.capacity);
// 比較のため配列ポインタを保存します
int* ptr = ints.ptr;
ints ~= 22;
writeln(ptr == ints.ptr);
}
これは以下のような結果を出力します:
Capacity: 0
false
スライスのキャパシティが0
というのは、次の追加はアロケーション引き起こす、ということを示しています。
GCヒープからアロケートされた配列は通常要求されたよりも多い追加要素のためのスペースを持ち、追加は新しいアロケーションを伴わず起きることがあります。
これは配列そのものよりもその背後のメモリの性質に似ています。
GCからアロケートされたメモリは、メモリブロックがいくつの要素を保持できるかを追跡するために内部で計算と記録を行うため、新しくアロケーションが必要かどうかをいつでも知ることができます。
ここで、ints
のメモリはGCからアロケートされたものでは無いため、実行時に存在するメモリブロックには計算が行われておらず、
次の追加ではアロケートをしなければなりません(より詳しい情報はSteven SchveighofferのD言語のスライス機能3の記事を見てください)。
それが望ましい振る舞いの場合これは必ずしも悪いことではありませんが、そうでなかった場合、
malloc
されたメモリは二度とデアロケートされないがために起きるリークによってメモリ使用量は簡単に膨らんでいきます。
こちらのような2つの関数を考えてみましょう:
void leaker(ref int[] arr)
{
...
arr ~= 10;
...
}
void cleaner(int[] arr)
{
...
arr ~= 10;
...
}
配列は参照型、つまり関数内で配列引数の既存の要素を変更するとオリジナルの配列の要素を変更される、
にもかかわらずこれらは関数パラメータとして値渡しされます。
length
やptr
のような、配列引数の構造を変更するようなアクティビティは関数の中のローカル変数にのみ影響します。
オリジナルは配列が参照渡しされない限り変更されません。
Cのヒープからなる配列がleaker
に渡された場合、追加はGCヒープからの新しい配列のアロケートを引き起こします。
さらに悪いことに、その後free
がオリジナルの配列のプロパティptr
に対して呼ばれると、ptr
がCのヒープではなくGCのヒープを指しているため、未定義動作になります。
一方cleaner
は大丈夫です。
渡された配列は変更されません。
内部では、GCはアロケートをしますが、オリジナルの配列のptr
プロパティはオリジナルのメモリブロックを指したままです。
オリジナルの配列が上書きされない、またはスコープの外側に出て良い場合、問題はありません。
cleaner
のような関数はローカルのスライスを自由にでき、物事は外部でうまく行くでしょう。
そうでなく、オリジナルの配列が破棄される場合、@nogc
の関数がそれを妨げることがあります。
それが不可能か望ましくない場合、オリジナルのmalloc
されたメモリへのポインタのコピーを保持して再アロケーションが行われた後にfree
するために、
カスタムの追加と結合を実装するか、アロケーション戦略を再評価する必要があります。
std.container.array
にはGCに頼らない、全てを手動で管理するのに適したArray
型があります。
その他のAPI
C標準ライブラリはヒープアロケーションの唯一の選択肢ではありません。
他にもたくさんのmalloc
実装が存在し、代わりに利用できます。
それらは手動でコンパイルし、コンパイル結果であるオブジェクトをリンクする必要がありますが、決して大変な仕事ではありません。
ヒープメモリはWindowsのWin32 HeapAlloc
関数(core.sys.windows.windows
をインポートして利用できます)のようなシステムAPIを通してアロケートすることもできます。
ヒープメモリへのポインタを取得する方法がある限り、そのメモリをスライスしてGCメモリのブロックの代わりにDで操作することができます。
集約型
Dで配列をアロケートすること以外考えなくていいなら、次のセクションまで飛ばしてもいいです。
しかし、struct
やclass
型について考える必要もあります。
この議論では前者、構造体にのみ焦点を当てて行きます。
以前の投稿で、私はスタックからクラスをアロケートする例を省略しました。
今回のヒープの議論でもクラスには触れません。
次回の投稿はクラスと、クラスをどのようにGCで、もしくはGCなしで管理するかのみを取り上げます。
struct
型の配列、または単一のインスタンスのアロケーティングは、大抵の場合型がint
の時と変わりません。
struct Point { int x, y; }
Point* onePoint = cast(Point*)malloc(Point.sizeof);
Point* tenPoints = cast(Point*)malloc(Point.sizeof * 10);
おかしくなるのはコンストラクタが入った時です。
malloc
とその仲間はDのオブジェクトのインスタンスのコンストラクティングについて何も知りません。
ありがたいことに、Phobosはそのための関数テンプレートを提供しています。
std.conv.emplace
は型付けされたメモリへのポインタ、
または型のないvoid[]
を任意の長さの引数とともにとり、その型の完全に初期化、構築されたインスタンスへのポインタを返します。
これはmalloc
と上記のallocate
関数テンプレートの使用例です:
struct Vertex4f
{
float x, y, z, w;
this(float x, float y, float z, float w = 1.0f)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
}
void main()
{
import core.stdc.stdlib : malloc;
import std.conv : emplace;
import std.stdio : writeln;
Vertex4f* temp1 = cast(Vertex4f*)malloc(Vertex4f.sizeof);
Vertex4f* vert1 = emplace(temp1, 4.0f, 3.0f, 2.0f);
writeln(*vert1);
void[] temp2 = allocate(Vertex4f.sizeof);
Vertex4f* vert2 = emplace!Vertex4f(temp2, 10.0f, 9.0f, 8.0f);
writeln(*vert2);
}
emplace
のもう一つの機能はデフォルトの初期化をハンドルすることです。
Dではstruct
型はコンストラクタを実装する必要がないことを思い出してください。
Vertex4f
の実装からコンストラクタを削除した時、このようなことが起こります:
struct Vertex4f
{
// x, y, z はデフォルトのfloat.nanへの初期化がされます
float x, y, z;
// w はデフォルトの1.0fへの初期化がされます
float w = 1.0f;
}
void main()
{
import core.stdc.stdlib : malloc;
import std.conv : emplace;
import std.stdio : writeln;
Vertex4f vert1, vert2 = Vertex4f(4.0f, 3.0f, 2.0f);
writeln(vert1);
writeln(vert2);
auto vert3 = emplace!Vertex4f(allocate(Vertex4f.sizeof));
auto vert4 = emplace!Vertex4f(allocate(Vertex4f.sizeof), 4.0f, 3.0f, 2.0f);
writeln(*vert3);
writeln(*vert4);
}
出力は以下のようになります:
Vertex4f(nan, nan, nan, 1)
Vertex4f(4, 3, 2, 1)
Vertex4f(nan, nan, nan, 1)
Vertex4f(4, 3, 2, 1)
コンストラクタがあってもなくても、emplace
はスタックからアロケートされた構造体インスタンスと同じやり方でヒープからアロケートされた構造体インスタンスを初期化することができます。
これはint
やfloat
のような組み込み型でも動作します。
クラスの参照に特殊化されたものもありますが、それは次の投稿で見ていきましょう。
emplace
は単一のインスタンスを初期化、構築するものであり、インスタンスの配列は対象ではないことを忘れないでください。
std.experimental.allocator
上の文章の全体はカスタムメモリマネージャーのビルディングブロックの根幹を説明しています。
多くのケースでは、手作業でまとめる代わりにDの標準ライブラリのstd.experimental.allocator
パッケージを利用するので充分かもしれません。
これは上で説明したような低レベルの技術を
Design by Introspection
とともに使い、配列や型インスタンスのアロケート、初期化、および構築方法を知っているさまざまなタイプのアロケータのアセンブリを容易にする高レベルのAPIです。
Mallocator
や
GCAllocator
のようなアロケータは特殊な振る舞いのために直接メモリのまとまりを取得したり、
他のビルディングブロックとまとめたりできます。
実例はemsi-containers ライブラリをご覧ください。
GCに情報を伝える
GCの完全な無効化が推奨される事は稀なため、GCヒープの外からアロケートするDのプログラムの多くは同時にGCヒープからのメモリも使用します。
そこでGCが適切に動作するために、GCヒープのメモリへの参照を含む、または潜在的に含む可能性がある非GCメモリを通知する必要があります。
例えば、ノードがmalloc
でアロケートされた連結リストはnew
でアロケートされたクラスへの参照を含むことがあります。
GCはGC.addRange
で情報を受け取ります。
import core.memory;
enum size = int.sizeof * 10;
void* p1 = malloc(size);
GC.addRange(p1, size);
void[] p2 = allocate!int(10);
GC.addRange(p2.ptr, p2.length);
メモリブロックが必要なくなった場合は、それに伴ってそのメモリブロックがスキャンされないようにGC.removeRange
を呼べます。
これはメモリブロックをデアロケートしません。
それはfree
かメモリブロックをアロケートしたアロケータインタフェースを使い手動で行う必要があります。
これらの関数を使う前には必ずドキュメントを読んでください。
GCヒープの外からのアロケーティングの目的がGCのスキャンしなければならないメモリの量を減らすことだと言うことを考えると、これは自滅的なように思えます。
一見して間違った方法にも思えます。
非GCメモリがGCメモリへの参照を持つとき、GCはそれを知っていなければなりません。
addRange
はそのような場面のために設計されたツールです。
malloc
された頂点配列のように、非GCメモリブロックへのGCメモリの参照が無いと保証できる場合、addRange
をメモリブロックに対して呼ぶ必要はありません。
忠告
型のあるポインタをaddRange
に渡す際は気をつけてください。
この関数はCのようにメモリのブロックへのポインタとそのブロックが含むバイト数をとるやり方で実装されており、エラーを引き起こす可能性があります。
struct Item { SomeClass foo; }
auto items = (cast(Item*)malloc(Item.sizeof * 10))[0 .. 10];
GC.addRange(items.ptr, items.length);
ここで、GCはメモリブロックを10バイトしかスキャンしません。
length
プロパティはそのスライスが参照している要素の個数を返します。
スライスが参照しているメモリブロックのサイズとlength
プロパティの値が等価になるのは、型が void
(またはbyte
やubyte
のように要素の長さが1バイト)の時のみです。
正しい方法は:
GC.addRange(items.ptr, items.length * Item.sizeof);
DRuntimeが新しくアップデートされるまでは、void[]
パラメータを受け取るラッパーを実装するのが良いでしょう。
void addRange(void[] mem)
{
import core.memory;
GC.addRange(mem.ptr, mem.length);
}
そうしてaddRange(items)
を呼ぶと良いでしょう。
関数呼び出しでのスライスからvoid[]
への暗黙の変換によりmem.length
はitems.length * Item.sizeof
と同じになります。
GCシリーズは進む
この投稿ではDのプログラムにおける非GCヒープの使い方を扱いました。
class
型に加えて、デストラクタについての記述も抜けています。
それは関係の深いクラスについての投稿にとっておきます。
これが次の投稿の予定です。
乞うご期待!
この記事の草稿について価値あるフィードバックをしてくれたWalter Bright、Guillaume Piolat、Adam D. Ruppe、Steven Schveighofferに感謝します。