SpanとMemoryによるゼロアロケーションC#:実践的メモリ最適化Tips

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

ヒープ割り当ての隠れたコストとGCプレッシャー

数年前、私たちのチームは毎秒25,000リクエストを処理できるように設計された高スループットの決済処理システムをリリースしました。ステージング環境でのテストは見事にパスしていました。しかし、本番環境でブラックフライデーのトラフィックが急増した途端、レスポンスタイムの99パーセンタイル(p99)が12msから一気に180ms超へと跳ね上がったのです。スレッドダンプを確認すると、原因はすぐに判明しました。Stop-the-World型のガベージコレクション(GC)が発生し、ワーカースレッドが日常的に10〜45ミリ秒間も停止していたのです。

問題はデータベースのデッドロックでも、ダウンストリームの低速なHTTP呼び出しでもありませんでした。マネージドヒープ上での「塵も積もれば山となる」過剰な割り当てが原因だったのです。ホットループ内で実行されるdata.Substring(0, 10)やnew byte[1024]のようなありふれた呼び出しが、毎秒1.2GBもの一時オブジェクトを生み出していました。.NETのGarbage Collectorはこれに追いつくためにGeneration 0とGeneration 1の回収を絶え間なくトリガーせざるを得ず、不要になったメモリを破棄するためだけに貴重なCPUサイクルを浪費していたのです。

従来のC# APIでは、テキストを分割したり、ペイロードの一部を確認したり、バッファをコピーしたりするたびに、新しい文字列や配列のインスタンスが割り当てられます。メモリの許容量をオーバーすることなくスループットを毎秒数万件のレベルまで引き上げるには、ヒープ割り当てを発生させずに連続したメモリ領域へアクセスする手法が必要です。Span<T>とMemory<T>は、まさにそれを実現してくれます。

Span<T>とMemory<T>の違いを理解する

Span<T>:スタック限定の高速メモリスライシング

Span<T>は、連続した任意のメモリブロックに対する安全で型が保証されたウィンドウとして機能します。内部的には、参照ポインタと整数の長さという2つのフィールドのみを持つref structとして定義されています。ref structであるため、ランタイムによってSpan<T>は実行スタック上にしか存在できないよう厳格に制限されています。ボックス化したり、通常のクラスのフィールドとして保持したり、スレッド境界を越えて渡したりすることはできません。

スタック上のライフサイクルが予測可能であるため、JITコンパイラはベアメタルのC言語ポインタ演算に匹敵するマシンコードを生成し、割り当てオーバーヘッドゼロかつ高速でベクトル化された境界チェックを実現します。1つのSpan<T>で、以下の全く異なる3種類のバッキングストアを指し示すことが可能です。

  • ヒープ上のマネージド配列(byte[]、int[])
  • 短命なスタックバッファ(stackalloc byte[128])
  • ネイティブ相互運用を介して割り当てられたアンマネージドネイティブメモリバッファ

Memory<T>:非同期コードにも適したヒープセーフなスライシング

Span<T>の「スタック限定」という制約には、1つ大きな落とし穴があります。非同期処理の境界を越えることができない点です。asyncメソッドがawaitに到達すると、コンパイラはローカル変数を自動生成されたヒープ上のステートマシンへと持ち上げます。その中断ポイントをまたいでSpan<T>を保持しようとすると、コンパイルエラーCS4013が発生します。

そこで登場するのがMemory<T>です。Span<T>とは異なり、配列、文字列、またはプールされたメモリマネージャーを背後に持つ標準的なreadonly structです。そのため、ヒープ上に配置したり、クラスフィールド内に格納したり、awaitポイントをまたいで存続させたりすることができます。同期的に元のバッファの解析や変換を行う準備ができたら、.Spanプロパティを呼び出すだけで、アロケーションなしでスタック上のスライスを取得できます。

文字列操作のためのReadOnlySpan<char>

.NETにおける文字列(string)はイミュータブル(不変)なヒープオブジェクトです。string.Substring()を呼び出すたびに、ランタイムは新しい文字列を割り当てて文字をコピーします。string.AsSpan()を使ってReadOnlySpan<char>に切り替えることで、既存のメモリに対する仮想ウィンドウとして部分文字列をスライスでき、割り当ても文字列のコピーもゼロで処理できます。

実践チュートリアル:過剰なアロケーションからゼロアロケーションへ

Tip 1:部分文字列を割り当てずに区切りテキストを解析する

TIMESTAMP|SEVERITY|MESSAGEという形式の構造化ログから各フィールドを抽出するような一般的な処理を考えてみましょう。素朴な実装では、無駄な割り当てが発生します。

// 呼び出しごとに1つの配列と3つの文字列オブジェクトを割り当ててしまう
public (string Level, string Message) ParseLogLegacy(string rawLog)
{
    string[] parts = rawLog.Split('|');
    return (parts[1], parts[2]);
}

このヘルパーメソッドに毎秒100,000件のログイベントを通すと、GCは毎秒400,000個ものヒープオブジェクトをクリーンアップしなければならなくなります。以下は、ReadOnlySpan<char>を使用したゼロアロケーション版の実装です。

// ヒープ割り当てゼロ、純粋なポインタスライシング
public bool TryParseLog(ReadOnlySpan<char> rawLog, out ReadOnlySpan<char> level, out ReadOnlySpan<char> message)
{
    level = default;
    message = default;

    int firstSeparator = rawLog.IndexOf('|');
    if (firstSeparator == -1) return false;

    ReadOnlySpan<char> remainder = rawLog.Slice(firstSeparator + 1);
    int secondSeparator = remainder.IndexOf('|');
    if (secondSeparator == -1) return false;

    level = remainder.Slice(0, secondSeparator);
    message = remainder.Slice(secondSeparator + 1);
    return true;
}

マイクロベンチマークでは、このリファクタリングによって実行時間が42nsから9nsへと短縮され、1行あたり128バイト発生していたヒープ割り当てを完全に排除できました。

Tip 2:Utf8Parserによる数値の直接パース

受信するHTTPヘッダー、JSONペイロード、IoTテレメトリストリームなどは、未加工のUTF-8バイトストリームとして届きます。多くの開発者は、まずこれらのバイト列をUTF-16文字列にデコードしてから、その文字列をint.Parse()に渡しています。その中間ステップは省略可能です。System.Buffers.Text.Utf8Parserを使えば、バイトスライスから直接数値をパースできます。

using System;
using System.Buffers.Text;

ReadOnlySpan<byte> payload = "1048576"u8;

if (Utf8Parser.TryParse(payload, out int parsedValue, out int bytesConsumed))
{
    Console.WriteLine($"パースされた整数値: {parsedValue} ({bytesConsumed} バイト処理)");
}

UTF-8リテラルを直接処理することで、中間的な文字列デコードを排除し、CPUサイクルとメモリの両方を節約できます。

Tip 3:小さな中間バッファにはstackallocを使用する

チェックサムの計算、コンパクトなフレーミングヘッダーのフォーマット、HMACトークンの生成などを行う際、短命なバイト配列をヒープに割り当てるのは純粋な無駄です。代わりに、stackallocを使ってスレッドのスタックから直接メモリを確保しましょう。

using System;
using System.Security.Cryptography;

public static void ComputePayloadToken(ReadOnlySpan<byte> data, Span<byte> destinationHash)
{
    // CPUスタックフレーム上に割り当てられた64バイトの作業用バッファ
    Span<byte> scratchBuffer = stackalloc byte[64];

    int bytesToCopy = Math.Min(data.Length, scratchBuffer.Length);
    data.Slice(0, bytesToCopy).CopyTo(scratchBuffer);
    
    SHA256.HashData(scratchBuffer, destinationHash);
}

スレッドスタックのサイズは小さく、LinuxやWindowsの64ビットシステムでは通常1MB程度です。サイズが動的で上限のないユーザー入力に対してstackallocを使用してはいけません。致命的なStackOverflowExceptionによるクラッシュを防ぐため、割り当ては1KB未満に抑えるようにしてください。

Tip 4:Memory<T>とArrayPoolで非同期パイプラインを処理する

ソケットやネットワークストリームでは非同期読み取りが必要となるため、スタック限定のSpanは使用できません。非同期パイプラインをアロケーションフリーに保つには、Memory<T>とArrayPool<T>を組み合わせます。

using System.Buffers;
using System.IO;
using System.Threading.Tasks;

public async Task ProcessStreamAsync(Stream networkStream)
{
    // 新しい配列を作成するのではなく、4KBのチャンクをプールから借用
    byte[] rentedArray = ArrayPool<byte>.Shared.Rent(4096);
    Memory<byte> memoryBuffer = rentedArray.AsMemory(0, 4096);

    try
    {
        int bytesRead = await networkStream.ReadAsync(memoryBuffer);

        // 同期的な解析のためにSpanへ切り替える
        ReadOnlySpan<byte> packetSlice = memoryBuffer.Slice(0, bytesRead).Span;
        ProcessPacket(packetSlice);
    }
    finally
    {
        // 他の呼び出し元が再利用できるように配列をプールへ返却
        ArrayPool<byte>.Shared.Return(rentedArray);
    }
}

private static void ProcessPacket(ReadOnlySpan<byte> packet)
{
    // パース処理はヒープに触れることなく実行される
}

データ取り込みパイプラインをこのパターンに移行したところ、Gen 0のGC発生頻度は毎分350回からほぼゼロへと激減し、全体のCPU負荷も32%削減されました。

各型を使い分ける指針

すべてのコードベースをSpanで書き直す必要はありません。一般的なビジネスロジック、管理ダッシュボード、バッチレポートスクリプトなどがボトルネックになるほどの負荷を生むことは稀です。テレメトリ収集、カスタムシリアライザー、ミドルウェア、大量のメッセージを処理するコンシューマーなど、ホットパスに集中して最適化を行いましょう。

以下は使い分けのチートシートです。

  • Span<T> / ReadOnlySpan<T>:awaitをまたがない同期的な解析、CPU負荷の高い文字列操作、バッファに対するインプレース変換。
  • Memory<T> / ReadOnlyMemory<T>:非同期I/O操作(Stream.ReadAsyncなど)をまたぐバッファのスライシングや、スライスされたセグメントをヒープクラス内に保持する必要があるデータモデル。
  • ArrayPool<T>.Shared:リクエスト間で繰り返し借用・格納され、すぐに返却される大きめの一時バッファ(1KB〜64KB以上)。

パフォーマンスが重視されるクリティカルパスにSpan<T>とMemory<T>を導入することで、C#の強みである型安全性とメモリ保護を犠牲にすることなく、生ポインタと同等のゼロコピー効率を手に入れることができます。

Share: