.NETのChannelを極める:外部ブローカーなしで実現する高性能な非同期Producer-Consumerパターン

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

課題の背景:午前2時14分のOutOfMemory障害

午前2時14分、ベッドサイドでスマートフォンが激しく振動しました。PagerDutyからのアラートを確認すると、主要な決済処理マイクロサービスが未処理のOutOfMemoryExceptionでクラッシュしていました。Grafanaのダッシュボードを開くと、メモリ使用量のグラフがほぼ垂直に跳ね上がり、わずか数分で320MBから6.2GBに達していました。CPU使用率は100%近くに張り付き、スレッドプールの枯渇によって依存するすべてのHTTPエンドポイントの応答が停止していました。

根本原因は極めてシンプルでした。深夜に開始された突発的なマーケティングセールにより、APIに毎秒25,000件ものトランザクションリクエストが殺到したのです。ASP.NET Coreのコントローラーは受信したペイロードをすべて受け入れ、PostgreSQLへレコードを書き出すバックグラウンドタスクを前提に、容量無制限のConcurrentQueue<PaymentEvent>へ即座に投入していました。

しかし、PostgreSQLの書き込み性能は毎秒4,200件が上限でした。わずか8分の間に990万件以上の未消費オブジェクトがヒープ領域を埋め尽くしました。これによりGen-2ガベージコレクションによる停止が頻発し、最終的にKubernetesによってPodが強制終了されました。

このような障害が発生すると、多くのエンジニアは即座にRabbitMQ、AWS SQS、Apache Kafkaといった外部メッセージブローカーの導入を検討しがちです。しかし、分散ブローカーの追加には運用オーバーヘッド、ネットワークのラウンドトリップ、ペイロードのシリアライズ処理、そして毎月のインフラコストといった現実的な負担が伴います。

ログイベントのバッファリング、監査ログの集約、データベースへのバッチ書き込みなど、ワークフローが単一のプロセス内で完結する場合、外部メッセージブローカーは不要です。本当に必要なのは、プロセス内におけるバックプレッシャーです。そこで真価を発揮するのがSystem.Threading.Channelsです。

コアコンセプト:なぜChannel<T>は従来のコレクションより優れているのか

.NETにSystem.Threading.Channelsが導入される前、開発者は通常ConcurrentQueue<T>かBlockingCollection<T>の二者択一を迫られていました。しかし、本番環境の高負荷下では、どちらも致命的な限界に直面します。

  • ConcurrentQueue<T>:容量制限が組み込まれておらず、非同期待機プリミティブも備えていません。コンシューマーは新しいデータを待つためにループ内でスピンまたはスリープせざるを得ず、無駄にCPUサイクルを消費します。
  • BlockingCollection<T>:固定の容量制限はサポートしているものの、Take()による同期的なスレッドブロックが発生します。トラフィック急増時にワーカースレッドがアイドル状態でブロックされ続けると、.NETのThreadPoolがあっという間に枯渇し、HTTPリクエストのレイテンシが跳ね上がります。

Channel<T>はこれら両方の問題を解決します。スレッドセーフかつ完全に非同期な通信を提供し、役割が2つのコントラクトに明確に分離されています。

  • ChannelWriter<T>:プロデューサーがWriteAsync()またはノンブロッキングなTryWrite()を使ってアイテムを追加します。
  • ChannelReader<T>:コンシューマーがWaitToReadAsync()やReadAllAsync()を使ってアイテムを取り出します。

最大の強みは、境界付き(有界)のフロー制御にあります。バッファの上限に達した際、WriteAsync()は実行中のOSスレッドをブロックしません。代わりに、コンシューマーがキューの空きを確保するまで、標準のタスク継続を介して非同期に実行権を譲渡します。

制御不能になったキューがノードをダウンさせる光景を一度でも経験すれば、バックプレッシャーが決して選択肢ではなく必須であることが分かります。これこそが、高スループットなサービスを安定稼働させ続けるための防波堤なのです。

実践:堅牢なインメモリパイプラインの構築

先ほどの決済受付エンジンを再設計してみましょう。有界チャネルを設定し、非同期プロデューサーとなるコントローラーを実装し、BackgroundServiceでキューを処理していきます。

ステップ1:チャネルオプションの設定

まず、有界チャネルをインスタンス化し、バッファが満杯になった際の挙動を指定します。

using System.Threading.Channels;

public static class ChannelFactory
{
    public static Channel<PaymentEvent> CreatePaymentChannel(int capacity = 10_000)
    {
        var options = new BoundedChannelOptions(capacity)
        {
            // ライターを非同期に一時停止させてバックプレッシャーを適用
            FullMode = BoundedChannelFullMode.Wait,
            
            // 複数のWebリクエストスレッドが並行してデータをプッシュ
            SingleWriter = false,
            
            // 単一のバックグラウンドワーカーがキューを消費
            SingleReader = true,
            
            // 書き込みスレッド上での同期継続によるデッドロックを防止
            AllowSynchronousContinuations = false
        };

        return Channel.CreateBounded<PaymentEvent>(options);
    }
}

public record PaymentEvent(Guid TransactionId, decimal Amount, DateTime CreatedAt);

SingleReader = trueを指定すると、チャネルは複数コンシューマー向けの同期ロックをバイパスします。この小さな設定により、コンシューマーループでのアトミック競合が排除され、CPUキャッシュラインのバウンシングが軽減されます。

ステップ2:プロデューサーの実装(APIコントローラー)

APIコントローラーにChannelWriter<PaymentEvent>をDI(依存性の注入)します。決済リクエストを受信したら、バッファに非同期でプッシュします。

using System.Threading.Channels;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class PaymentsController : ControllerBase
{
    private readonly ChannelWriter<PaymentEvent> _writer;
    private readonly ILogger<PaymentsController> _logger;

    public PaymentsController(ChannelWriter<PaymentEvent> writer, ILogger<PaymentsController> logger)
    {
        _writer = writer;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> ProcessPayment([FromBody] PaymentRequest request, CancellationToken ct)
    {
        var paymentEvent = new PaymentEvent(Guid.NewGuid(), request.Amount, DateTime.UtcNow);

        // 10,000件のバッファが満杯の場合は非同期で待機
        await _writer.WriteAsync(paymentEvent, ct);

        return Accepted(new { paymentEvent.TransactionId, Status = "キュー登録済み" });
    }
}

public record PaymentRequest(decimal Amount);

保留中のトランザクションが10,000件に達したときの挙動に注目してください。WriteAsyncはリクエストスレッドを束縛せず、即座にKestrelへ解放します。受信したHTTPリクエストはメモリを肥大化させたりワーカースレッドを枯渇させたりすることなく、順番が来るのを待機します。

ステップ3:コンシューマーとなるバックグラウンドワーカーの実装

続いて、リーダーからアイテムを取り出して下流へ書き出すBackgroundServiceを実装します。

using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public class PaymentProcessorService : BackgroundService
{
    private readonly ChannelReader<PaymentEvent> _reader;
    private readonly ILogger<PaymentProcessorService> _logger;

    public PaymentProcessorService(ChannelReader<PaymentEvent> reader, ILogger<PaymentProcessorService> logger)
    {
        _reader = reader;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("決済処理ワーカーを開始しました。");

        try
        {
            // ReadAllAsyncはアイテムが到着するたびに取り出し、チャネル完了時に終了する
            await foreach (PaymentEvent payment in _reader.ReadAllAsync(stoppingToken))
            {
                await ProcessPaymentAsync(payment, stoppingToken);
            }
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("決済処理ワーカーを正常にシャットダウンしています。");
        }
    }

    private async Task ProcessPaymentAsync(PaymentEvent payment, CancellationToken ct)
    {
        // データベース永続化のレイテンシをシミュレート
        await Task.Delay(2, ct);
        _logger.LogDebug("トランザクション {Id} を処理しました。", payment.TransactionId);
    }
}

ステップ4:依存関係をDIコンテナに登録

Program.csでチャネルをシングルトンとして登録し、責務の境界を明確に保つためにリーダーとライターを個別に公開します。

var builder = WebApplication.CreateBuilder(args);

var paymentChannel = ChannelFactory.CreatePaymentChannel(capacity: 10_000);
builder.Services.AddSingleton(paymentChannel.Reader);
builder.Services.AddSingleton(paymentChannel.Writer);
builder.Services.AddHostedService<PaymentProcessorService>();
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

本番運用のチェックリストと重要なまとめ

無制限のインメモリキューからChannel<T>へ移行したことで、深夜のアラートに悩まされることは完全になくなりました。その後のピークセールでは、流入トラフィックが毎秒28,000リクエストを超えた際も、メモリ使用量はわずか185MBで完全に横ばいを維持しました。

チャネルを本番環境へ導入する前に、以下の4つの原則を確認してください。

  1. 必ずバッファ容量(境界)を設定する: パブリックエンドポイントの背後でChannel.CreateUnbounded<T>を使用してはいけません。無制限バッファは短期的なスループットと引き換えに、最終的なプロセスクラッシュを招きます。
  2. 適切なFullModeを選択する: データの欠落が許されない場合はBoundedChannelFullMode.Waitを使用します。重要度の低いテレメトリデータであれば、DropOldestやDropNewestに切り替えて、古いデータより最新データを優先します。
  3. グレースフルシャットダウン時に残データを処理する: アプリケーションのライフサイクル停止フック内でwriter.Complete()を呼び出します。これにより、ランタイムが終了する前にReadAllAsync()がバッファ内の未処理アイテムを最後まで完了できます。
  4. シングルアクターフラグを活用する: チャネルの片側を処理するワーカーが単一の場合は、必ずSingleReaderまたはSingleWriterを有効にします。ランタイム内部のロック処理が省略され、測定可能なレベルでスループットが向上します。
Share: