はじめに
今回紹介するTipsは、文字列連結のシナリオに関するものです。短い文字列を連結する場面に頻繁に遭遇するかと思いますが、そうした場面でString.Concat、すなわち+=演算子を使用することは強く非推奨です。
現在、公式が最も推奨する方法はStringBuilderを使用して文字列を構築することです。では、より高速でメモリ使用量の少ない方法はあるでしょうか?それが今回ご紹介するValueStringBuilderです。
ValueStringBuilder
ValueStringBuilderは公開APIではありませんが、.NETの基本クラスライブラリで広く使用されています。値型であるため、それ自体がヒープに割り当てられることはなく、GCの負荷もありません。
Microsoftが提供するValueStringBuilderには2つの使用方法があります。1つは、自分であらかじめメモリ領域を用意して文字列構築に使用する方法です。つまり、スタック領域、ヒープ領域、さらにはアンマネージヒープ領域も使用可能であり、GCにとって非常に優しく、高並行環境下でGC負荷を大幅に低減できます。
// コンストラクタ:Span<char>のバッファを受け取る
public ValueStringBuilder(Span<char> initialBuffer);
// 使用例:
// スタック領域
var vsb = new ValueStringBuilder(stackalloc char[512]);
// 通常の配列
var vsb = new ValueStringBuilder(new char[512]);
// アンマネージヒープを使用
var length = 512;
var ptr = NativeMemory.Alloc((nuint)(512 * Unsafe.SizeOf<char>()));
var span = new Span<char>(ptr, length);
var vsb = new ValueStringBuilder(span);
.....
NativeMemory.Free(ptr); // アンマネージヒープは使用後に必ずFree
もう1つの方法は、容量を指定する方法です。この場合、デフォルトのArrayPoolのcharオブジェクトプールからバッファスペースを取得します。オブジェクトプールを使用するため、GCにとっても比較的優しく、プール内のオブジェクトは必ず返却する必要がある点に十分注意してください。
// 予想容量を渡す
public ValueStringBuilder(int initialCapacity)
{
// オブジェクトプールからバッファを取得
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
......
}
それでは、+=、StringBuilder、ValueStringBuilderの各方法のパフォーマンスを比較してみましょう。
// シンプルなクラス
public class SomeClass
{
public int Value1; public int Value2; public float Value3;
public double Value4; public string? Value5; public decimal Value6;
public DateTime Value7; public TimeOnly Value8; public DateOnly Value9;
public int[]? Value10;
}
// Benchmarkクラス
[MemoryDiagnoser]
[HtmlExporter]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class StringBuilderBenchmark
{
private static readonly SomeClass Data;
static StringBuilderBenchmark()
{
var baseTime = DateTime.Now;
Data = new SomeClass
{
Value1 = 100, Value2 = 200, Value3 = 333,
Value4 = 400, Value5 = string.Join('-', Enumerable.Range(0, 10000).Select(i => i.ToString())),
Value6 = 655, Value7 = baseTime.AddHours(12),
Value8 = TimeOnly.MinValue, Value9 = DateOnly.MaxValue,
Value10 = Enumerable.Range(0, 5).ToArray()
};
}
// おなじみのStringBuilderを使用
[Benchmark(Baseline = true)]
public string StringBuilder()
{
var data = Data;
var sb = new StringBuilder();
sb.Append("Value1:"); sb.Append(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.Append(data.Value2);
}
sb.Append(" ,Value3:"); sb.Append(data.Value3);
sb.Append(" ,Value4:"); sb.Append(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
}
sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
sb.Append(" ,Value10:");
if (data.Value10 is null or {Length: 0}) return sb.ToString();
for (int i = 0; i < data.Value10.Length; i++)
{
sb.Append(data.Value10[i]);
}
return sb.ToString();
}
// StringBuilderにCapacityを指定
[Benchmark]
public string StringBuilderCapacity()
{
var data = Data;
var sb = new StringBuilder(20480);
sb.Append("Value1:"); sb.Append(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.Append(data.Value2);
}
sb.Append(" ,Value3:"); sb.Append(data.Value3);
sb.Append(" ,Value4:"); sb.Append(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
}
sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
sb.Append(" ,Value10:");
if (data.Value10 is null or {Length: 0}) return sb.ToString();
for (int i = 0; i < data.Value10.Length; i++)
{
sb.Append(data.Value10[i]);
}
return sb.ToString();
}
// 直接+=で文字列連結
[Benchmark]
public string StringConcat()
{
var str = "";
var data = Data;
str += ("Value1:"); str += (data.Value1);
if (data.Value2 > 10)
{
str += " ,Value2:"; str += data.Value2;
}
str += " ,Value3:"; str += (data.Value3);
str += " ,Value4:"; str += (data.Value4);
str += " ,Value5:"; str += (data.Value5);
if (data.Value6 > 20)
{
str += " ,Value6:"; str += data.Value6.ToString("F2");
}
str += " ,Value7:"; str += data.Value7.ToString("yyyy-MM-dd HH:mm:ss");
str += " ,Value8:"; str += data.Value8.ToString("HH:mm:ss");
str += " ,Value9:"; str += data.Value9.ToString("yyyy-MM-dd");
str += " ,Value10:";
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
str += (data.Value10[i]);
}
}
return str;
}
// スタック上に割り当てたValueStringBuilderを使用
[Benchmark]
public string ValueStringBuilderOnStack()
{
var data = Data;
Span<char> buffer = stackalloc char[20480];
var sb = new ValueStringBuilder(buffer);
sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
}
sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
}
sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
sb.Append(" ,Value10:");
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
sb.AppendSpanFormattable(data.Value10[i]);
}
}
return sb.ToString();
}
// ArrayPoolを使用したヒープ上のValueStringBuilder
[Benchmark]
public string ValueStringBuilderOnHeap()
{
var data = Data;
var sb = new ValueStringBuilder(20480);
sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
if (data.Value2 > 10)
{
sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
}
sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
sb.Append(" ,Value5:"); sb.Append(data.Value5);
if (data.Value6 > 20)
{
sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
}
sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
sb.Append(" ,Value10:");
if (data.Value10 is not null && data.Value10.Length > 0)
{
for (int i = 0; i < data.Value10.Length; i++)
{
sb.AppendSpanFormattable(data.Value10[i]);
}
}
return sb.ToString();
}
}
結果は以下の通りです。

上記の結果から、次の結論が得られます。
StringConcatの使用は最も遅く、この方法は決してお勧めできません。StringBuilderの使用はStringConcatより約6.5倍高速であり、推奨される方法です。- 初期容量を設定した
StringBuilderは、初期容量を設定しない場合より約25%高速です。コレクションには初期サイズを設定すべきという私の記事の通り、初期サイズの設定は非常にお勧めです。 - スタック上に割り当てた
ValueStringBuilderは、StringBuilderより約50%高速で、初期容量を設定したStringBuilderよりも約25%高速であり、さらにGC回数も最も少ないです。 - ヒープ上に割り当てた
ValueStringBuilderは、StringBuilderより約55%高速で、GC回数はスタック割り当てよりもやや多いです。
上記の結論から、ValueStringBuilderのパフォーマンスは非常に優れており、スタック上にバッファを割り当てた場合でも、StringBuilderより25%高速であることがわかります。
ソースコード解析
ValueStringBuilderのソースコードはそれほど長くありません。いくつかの重要なメソッドを紹介します。一部のソースコードは以下の通りです。
// ref structを使用して、このオブジェクトはスタック上にのみ割り当て可能
public ref struct ValueStringBuilder
{
// ArrayPoolからバッファを割り当てた場合、返却のために保存しておく
private char[]? _arrayToReturnToPool;
// 外部から渡されたバッファを一時保存
private Span<char> _chars;
// 現在の文字列長
private int _pos;
// 外部からバッファを受け取る
public ValueStringBuilder(Span<char> initialBuffer)
{
// 外部から渡されたバッファを使用する場合、プールからは取得しない
_arrayToReturnToPool = null;
_chars = initialBuffer;
_pos = 0;
}
public ValueStringBuilder(int initialCapacity)
{
// 容量が指定された場合、ArrayPoolから取得
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
_chars = _arrayToReturnToPool;
_pos = 0;
}
// 文字列のLengthを返す。Lengthは読み書き可能
// そのため、ValueStringBuilderを再利用するにはLengthを0に設定するだけ
public int Length
{
get => _pos;
set
{
Debug.Assert(value >= 0);
Debug.Assert(value <= _chars.Length);
_pos = value;
}
}
......
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(char c)
{
// 文字の追加は非常に効率的。単に該当Span位置に設定するだけ
int pos = _pos;
if ((uint) pos < (uint) _chars.Length)
{
_chars[pos] = c;
_pos = pos + 1;
}
else
{
// バッファの空きが不足している場合、GrowAndAppendを実行
GrowAndAppend(c);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(string? s)
{
if (s == null)
{
return;
}
// 文字列の追加も同様に効率的
int pos = _pos;
// 文字列長が1の場合、文字追加と同様に処理可能
if (s.Length == 1 && (uint) pos < (uint) _chars .Length)
{
_chars[pos] = s[0];
_pos = pos + 1;
}
else
{
// 複数文字の場合は低速メソッドを使用
AppendSlow(s);
}
}
private void AppendSlow(string s)
{
// 文字列追加、空きが足りなければ拡張
// その後Span.CopyToでコピー。非常に効率的
int pos = _pos;
if (pos > _chars.Length - s.Length)
{
Grow(s.Length);
}
s
#if !NETCOREAPP
.AsSpan()
#endif
.CopyTo(_chars.Slice(pos));
_pos += s.Length;
}
// フォーマットが必要なオブジェクト向けの特別処理
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AppendSpanFormattable<T>(T value, string? format = null, IFormatProvider? provider = null)
where T : ISpanFormattable
{
// ISpanFormattableは非常に効率的
if (value.TryFormat(_chars.Slice(_pos), out int charsWritten, format, provider))
{
_pos += charsWritten;
}
else
{
Append(value.ToString(format, provider));
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void GrowAndAppend(char c)
{
// 1文字の拡張後に追加
Grow(1);
Append(c);
}
// 拡張メソッド
[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow(int additionalCapacityBeyondPos)
{
Debug.Assert(additionalCapacityBeyondPos > 0);
Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos,
"Grow called incorrectly, no resize is needed.");
// 同様に2倍に拡張し、デフォルトでオブジェクトプールからバッファを取得
char[] poolArray = ArrayPool<char>.Shared.Rent((int) Math.Max((uint) (_pos + additionalCapacityBeyondPos),
(uint) _chars.Length * 2));
_chars.Slice(0, _pos).CopyTo(poolArray);
char[]? toReturn = _arrayToReturnToPool;
_chars = _arrayToReturnToPool = poolArray;
if (toReturn != null)
{
// 元々オブジェクトプールを使用していた場合、必ず返却
ArrayPool<char>.Shared.Return(toReturn);
}
}
//
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Dispose()
{
char[]? toReturn = _arrayToReturnToPool;
this = default; // 安全のため、解放時に現在のオブジェクトをクリア
if (toReturn != null)
{
// 必ずオブジェクトプールに返却
ArrayPool<char>.Shared.Return(toReturn);
}
}
}
上記のソースコードから、ValueStringBuilderのいくつかの特徴をまとめることができます。
StringBuilderと比較して、実装方法が非常にシンプル。- すべてが高性能を目的としており、
Spanの多用、各種インラインパラメータ、オブジェクトプールの使用など。 - メモリ使用量が非常に少ない。それ自体が構造体型であり、さらに
ref structであるため、ボックス化されることがなく、ヒープに割り当てられることはない。
適用シナリオ
ValueStringBuilderは高性能な文字列生成方法であり、シナリオに応じて異なる使用方法があります。
- 非常に高頻度で文字列を連結するが、文字列長が小さい場合:スタック上に割り当てた
ValueStringBuilderを使用します。
周知の通り、ASP.NET Coreのパフォーマンスは非常に優れています。その内部ライブラリUrlBuilderでは、スタック割り当てが使用されています。現在のメソッドが終了するとスタック割り当ては解放されるため、GC負荷は一切発生しません。

- 非常に高頻度で文字列を連結するが、文字列長が予測不能な場合:ArrayPoolを使用して容量を指定した
ValueStringBuilderを使用します。例えば、.NET BCLライブラリ内の多くのシナリオで使用されており、動的メソッドのToString実装などがあります。プールからの割り当てはスタック割り当てほど効率的ではありませんが、同様にメモリ使用量とGC負荷を低減できます。

- 非常に高頻度で文字列を連結するが、文字列長が制御可能な場合:スタック割り当てとArrayPool割り当てを併用します。例えば、正規表現解析クラスでは、文字列長が短い場合はスタック領域、長い場合はArrayPoolを使用します。

注意すべきシナリオ
async\await内ではValueStringBuilderを使用できません。その理由は、ValueStringBuilderはref structであり、スタック上にのみ割り当て可能なためです。async\awaitは状態機械にコンパイルされ、await前後のメソッドを分割するため、ValueStringBuilderをメソッド内で受け渡しするのが困難です。ただし、コンパイラが警告を発します。

ValueStringBuilderを戻り値として返すことはできません。現在のスタック上に割り当てられているため、メソッド終了時に解放され、戻り値が未知のアドレスを指すことになります。これもコンパイラが警告します。

ValueStringBuilderを他のメソッドに渡す場合は、refで渡す必要があります。そうしないと値のコピーが発生し、複数のインスタンスが存在することになります。コンパイラは警告しませんが、十分に注意する必要があります。

- スタック割り当てを使用する場合、バッファサイズは5KB以内に抑えるのが安全です。その理由については、また機会があれば説明します。
まとめ
今回は、高性能かつほぼメモリを消費しない文字列連結構造体ValueStringBuilderをご紹介しました。ほとんどのシナリオでは使用をお勧めしますが、上記の注意点をよく守る必要があります。条件に合わない場合は、引き続き効率的なStringBuilderを使用して文字列連結を行ってください。
記事のソースコードリンク: https://github.com/InCerryGit/BlogCode-Use-ValueStringBuilder