どうもKutoです。
プログラミングの勉強って底なし沼だな〜と感じるこの頃ですが、AIのおかげでそれも昔よりはマシなのかなとも思いますよね。
ということで、ZStringを読もうの第4回です。
前回は Utf16ValuesStringBuilder が struct である理由、改行コードの扱いについて解説しましたが、まだまだ Utf16ValuesStringBuilder のすべては読みきれていませんでしたね。
今回は引き続き Utf16ValuesStringBuilder の実装を見ていきたいと思います。
scratchBuffer
[ThreadStatic]
static char[] scratchBuffer;
前回も書きましたが、ZStringでは char[] 型の変数 buffer に文字を貯め、最後に ToString() することで文字列を生成するのでした。
buffer に貯めているので無駄なヒープアロケーションは発生しませんが、それでもメモリが食われます。
なんならC#の配列型は参照型なので、bufferを使用するということはヒープアロケーションが発生するということなのでは?
しかしヒープアロケーションは回避したいですよね。
この極力メモリ食べたくないよねという思いから施された工夫が、この scratchBuffer になります。
工夫を見るため、 Init() を確認してみましょう。
internal void Init()
{
var buf = scratchBuffer;
if (buf == null)
{
buf = scratchBuffer = new char[DefaultBufferSize];
}
buffer = buf;
index = 0;
}
Init() は御覧の通り buffer の初期化を行っているですが、ここで scratchBuffer が使用されています。
const int DefaultBufferSize = 32768; // use 32K default buffer.
つまり scratchBuffer で 32KB の char[] をあらかじめヒープに確保しておき、それを ZString を使用する度に使いまわすようにしているということですね。
要はメモリのキャッシュです。毎回アロケーションをせずにずっと確保してあるわけですね。
また scratchBuffer の修飾子に注目して頂けると static 修飾子が付いていることが分かると思います。
static は通常 C# でプログラムを書く際にも使う修飾子だと思いますが、この修飾子が付けられた場合そのデータはメモリの静的領域で確保されます。データ領域、とかとも言う気がしますね。
プログラム内で確保と解放が行われるスタック・ヒープと違い、静的領域はプログラムが生きている限りずっと確保されたままです。解放されないため、必然的に何度アクセスしようともアロケーションは発生しません。
ZStringの思想によく合っていることが分かるかと思います。
よって最初に一回だけヒープアロケーションで確保し、その参照をずっと静的領域で持つことで、以後アロケーションが発生しないということになります。
また scratchBuffer には static 修飾子に加えて、 [ThreadStatic] 属性が付与されていることが分かります。
これにより前述した静的領域へのアロケーションがプログラム単位で行われるのではなく、スレッド単位で行われるようになります。
考えれば分かると思いますが、プログラム単位で一つの場合非同期処理を走らせているときにZStringを同時に使用すると干渉してしまいますからね。それを避けるためにスレッド単位でメモリ確保を分ける必要があります。
このように静的領域のキャッシュを利用してアロケーションを減らしているんですね。
ちなみに話は変わりますが、個人的にこの構文が驚きでした。
C#って2変数に対して同時に代入することが出来るんですね...。
buf = scratchBuffer = new char[DefaultBufferSize];
一応意味としては、
scratchBuffer = new char[DefaultBufferSize];
buf = scratchBuffer;
になるみたいです。
Grow()
では作成したい文字列の大きさが DefaultBufferSize より大きい場合はどうするのでしょうか。
単に scratchBuffer を利用するだけではそれ以上の大きさの string を作成することが出来ません。よって char[] 型の buffer の大きさを動的に増やしてやる必要があります。
その増加処理を行っているのが、 Grow() 関数になります。
void Grow(int sizeHint = 0)
{
var nextSize = buffer.Length * 2;
if (sizeHint != 0)
{
nextSize = Math.Max(nextSize, index + sizeHint);
}
var newBuffer = ArrayPool<char>.Shared.Rent(nextSize);
buffer.CopyTo(newBuffer, 0);
if (buffer.Length != DefaultBufferSize)
{
ArrayPool<char>.Shared.Return(buffer);
}
buffer = newBuffer;
}
ここで必要な大きさ sizeHint を引数に、 buffer の大きさを拡張しています。
(読んでて思いますが変数名や関数名のセンスが良い... Grow() って良いですね...)
基本的にはサイズを2倍に増やし、必要サイズがそれより大きければ追加で拡張する感じですね。
この基本的なロジックは何も問題ないのですが、一人だけ知らない子が混じっています。
そう、 ArrayPool<char> ですね。
これは T 型配列のプールで、これを使用することで高頻度にメモリの解放・確保が行われるような配列データのためにメモリ領域をキャッシュしておくことが出来ます。
buffer の拡張で ArrayPool<char> を利用することにより、ここでも余計なアロケーションを防ぐことが出来ます。勿論1度目はアロケーションが発生しますが、以降はそれ以上大きいサイズを求められない限り発生しなくなるわけです。
注意点としてどうやらArrayPoolに保存したキャッシュは Dispose() されない限りプログラム終了まで残ってしまうようです。
なのでサーバー等のプログラムがずっと稼働しているシステムでメモリが不足気味な場合、大きいサイズの文字列にZStringを利用するとメモリを圧迫してしまうかもしれません。
(実際は内部で過度に確保された場合は返却時にキャッシュされずそのまま処分されるようです)
とことんアロケーションが無いですね...。
Dispose()
メモリにうるさい(誉め言葉)ZStringのことです。
当然 Dispose() も特殊処理が実装されています。
public void Dispose()
{
if (buffer.Length != DefaultBufferSize)
{
ArrayPool<char>.Shared.Return(buffer);
}
buffer = null;
index = 0;
}
ここでは先ほどの Grow() 関数の逆の処理が行われていて、 ArrayPool に対して借りた分のメモリを返却しています。
要らなくなったので、他のスレッドから使用される場合等に備えて解放(ArrayPoolが確保したままなのでGCに投げるという意味ではない)してあげているわけです。
最後に
ということで、string化されるデータ本体である buffer がどう扱われているかを見ました。
scratchBuffer は静的領域。
それをはみ出した分も ArrayPool でキャッシュ。
極限までアロケーションを防ぐ様々工夫が組まれていることが良く分かって頂けたのかなと思います。
それでは次回は実際にstringを組んでいく処理について見ていきたいと思います。