KOTET'S PERSONAL BLOG

#dlang オウン・ウェイ - GCを避けたアロケーション (Part2: ヒープ)【翻訳】

Created: , Last modified:
#dlang #tech #translation #dlang_gc_series #d_blog

これは1年以上前の記事です

ここに書かれている情報、見解は現在のものとは異なっている場合があります。

この記事は、

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の標準ライブラリにアクセスできるという事は、つまりmalloccallocreallocfreeなどの関数にもアクセスできるという事です。 これら全ては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のデフォルトの初期化と同じ振る舞いを望むなら、一手間必要です。 この例ではmalloccallocに置き換えることで同じ効果が得られますが、これは整数型の場合だけの話です。 例えばfloat.init0.0fではなくfloat.nanです。 これについては後で述べます。

(メモリアロケーションの失敗のハンドリングについての詳細はこの投稿の拡張コンテンツを確認してください。)

もちろん、mallocfreeをメモリのスライス用にラップしてよりイディオマティックにできます。 単純な例です:

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プロパティにもつため、 allocatevoid*ではなくvoid[]を返します。 今回の場合配列をアロケートするので、スライスを返すallocArrayの代わりに直接ポインタを返すよう書き換えることもできますが、 allocateを直接呼ぶ人はメモリのサイズを考慮し続けなければなりません。 Cにおいて配列とその長さの乖離は 主要なバグの原因であり、 したがってそれらを結びつけるのは早ければ早いほど望ましいことです。 callocreallocのテンプレートを除けば、あなたは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;
    ...
}

配列は参照型、つまり関数内で配列引数の既存の要素を変更するとオリジナルの配列の要素を変更される、 にもかかわらずこれらは関数パラメータとして値渡しされます。 lengthptrのような、配列引数の構造を変更するようなアクティビティは関数の中のローカル変数にのみ影響します。 オリジナルは配列が参照渡しされない限り変更されません。

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で配列をアロケートすること以外考えなくていいなら、次のセクションまで飛ばしてもいいです。 しかし、structclass型について考える必要もあります。 この議論では前者、構造体にのみ焦点を当てて行きます。 以前の投稿で、私はスタックからクラスをアロケートする例を省略しました。 今回のヒープの議論でもクラスには触れません。 次回の投稿はクラスと、クラスをどのように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はスタックからアロケートされた構造体インスタンスと同じやり方でヒープからアロケートされた構造体インスタンスを初期化することができます。 これはintfloatのような組み込み型でも動作します。 クラスの参照に特殊化されたものもありますが、それは次の投稿で見ていきましょう。 emplace単一のインスタンスを初期化、構築するものであり、インスタンスの配列は対象ではないことを忘れないでください。

std.experimental.allocator

上の文章の全体はカスタムメモリマネージャーのビルディングブロックの根幹を説明しています。 多くのケースでは、手作業でまとめる代わりにDの標準ライブラリのstd.experimental.allocator パッケージを利用するので充分かもしれません。 これは上で説明したような低レベルの技術を Design by Introspection とともに使い、配列や型インスタンスのアロケート、初期化、および構築方法を知っているさまざまなタイプのアロケータのアセンブリを容易にする高レベルのAPIです。 MallocatorGCAllocatorのようなアロケータは特殊な振る舞いのために直接メモリのまとまりを取得したり、 他のビルディングブロックとまとめたりできます。 実例は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(またはbyteubyteのように要素の長さが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.lengthitems.length * Item.sizeofと同じになります。

GCシリーズは進む

この投稿ではDのプログラムにおける非GCヒープの使い方を扱いました。 class型に加えて、デストラクタについての記述も抜けています。 それは関係の深いクラスについての投稿にとっておきます。 これが次の投稿の予定です。 乞うご期待!

この記事の草稿について価値あるフィードバックをしてくれたWalter Bright、Guillaume Piolat、Adam D. Ruppe、Steven Schveighofferに感謝します。