Start Debugging

.NET 11 のバックグラウンドジョブにおける BackgroundService vs IHostedService vs Hangfire

インプロセスのループには BackgroundService、ライフサイクルの細かな制御が必要なときは素の IHostedService、ジョブが再起動を生き延びる必要があるときは Hangfire を選びます。コード付きの決定マトリクスと、あなたの代わりに決めてくれる一点を示します。

.NET 11 アプリでのバックグラウンド処理について、短い答えはこうです。継続的なインプロセスのループやキューのコンシューマーには BackgroundService を使い、明示的で順序づけられた起動や終了が必要なときだけ素の IHostedService まで降り、ジョブがプロセスの再起動を生き延びる必要があるとき、あるいは「来週の火曜午前 2 時」にスケジュールする必要があるときは Hangfire に手を伸ばします。最初の 2 つは同じホスティングのプリミティブを異なる高度で見たもので、追加コストはゼロです。Hangfire は背後にデータベースを持つ別個の依存関係であり、そのデータベースこそがあなたが支払っている対価です。この記事では決定マトリクスを組み立て、それぞれの最小限のコードを示し、たいてい代わりに決めてくれる唯一の要件、つまり永続性を指し示します。

すべての例は .NET 11 と C# 14 を対象とします。Hangfire の例は Hangfire 1.8.x(Hangfire.AspNetCoreHangfire.SqlServer)を使います。

機能マトリクス

これがあなたの目当ての表です。まず「再起動を生き延びる」の行を読んでください。これが選択肢を二分する行です。

機能IHostedServiceBackgroundServiceHangfire
.NET 11 に組み込みはいはいいいえ(NuGet + ストレージ)
追加インフラなしなしSQL Server / Redis / Postgres
ライフサイクルの面StartAsync/StopAsync1 つの ExecuteAsyncなし(あなたがジョブを投入する)
最適な用途起動/終了のステップ長時間実行のループ単発およびスケジュールされたジョブ
再起動を生き延びるいいえいいえはい
失敗時のリトライ自分で書く自分で書く自動、設定可能
スケジューリング(cron、遅延)自分で書く自分で書く組み込み
複数インスタンスでの実行各インスタンスで実行各インスタンスで実行1 つの worker が各ジョブを取る
ダッシュボード / 可視性なしなし組み込みの Web ダッシュボード
コスト無料無料OSS コア、一部 Pro ライセンス

BackgroundServiceIHostedService の代替ではありません。それを実装する抽象クラスです。したがって本当の選択は二択です。インプロセスのホスティングサービス(その 2 つの形態のいずれか)か、外部の永続的なジョブシステムか。順番に見ていきましょう。

IHostedService: 素のライフサイクル契約

IHostedService は、.NET の汎用ホストが起動時と終了時に呼び出す低レベルのインターフェースです。メソッドはちょうど 2 つあります。

// .NET 11, C# 14
public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

ホストは、最初のリクエストを処理する前に、登録された各サービスの StartAsync を登録順に待機(await)し、プロセスが終了する前に StopAsync を(HostOptions.ShutdownTimeout、デフォルトで 30 秒まで)待機します。この順序の保証こそ、素のインターフェースを使う理由です。トラフィックが到着する前に完了していなければならない処理(キャッシュのウォームアップ、一度きりのマイグレーションチェック、長寿命の接続のオープン)にふさわしい場所です。

// .NET 11, C# 14
public sealed class CacheWarmer(IMemoryCache cache, IProductRepository repo) : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        // Runs to completion BEFORE the app starts serving requests.
        var hot = await repo.GetHotProductsAsync(ct);
        cache.Set("hot-products", hot);
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

素の IHostedService の落とし穴は、StartAsync の内部で長時間実行の処理を行うことです。そこで無限ループを開始して await で待機すると、ホストは起動を終えられません。ループを待機せずに発火させ、Task を自分で追跡し、StopAsync でキャンセルして待機する必要があります。まさにこの帳簿付けを取り除くために BackgroundService は存在します。

さらに細かい制御が必要な場合(すべてのホスティングサービスが起動したに走るフック、あるいは終了が始まる直前に走るフック)、.NET 8 は IHostedLifecycleService を追加しました。これは IHostedServiceStartingAsync/StartedAsyncStoppingAsync/StoppedAsync で拡張します。.NET 11 でも現役であり、Steve Gordon によるインターフェースの解説が説明するように、「今やすべてが立ち上がった」というサービス横断の検証を行う、ドキュメント化された場所です。

BackgroundService: あなたが本当に欲しいループ

BackgroundService は、テンプレートメソッドパターンを使って IHostedService をあなたの代わりに実装する抽象基底クラスです。オーバーライドするメソッドは 1 つだけです。

// .NET 11, C# 14
public sealed class QueuePump(IServiceScopeFactory scopeFactory, ILogger<QueuePump> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
                await processor.DrainOnceAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break; // normal shutdown
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Order pump iteration failed; retrying");
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

フレームワークは自身の StartAsync の内部から ExecuteAsync を呼び出し、ホストが停止するときに stoppingToken をシグナルし、終了時にあなたが返した Taskawait で待機します。十分な頻度で人を噛む 2 つの細部があるので挙げておきます。

どちらの形態も同じ方法で登録します。

// .NET 11, C# 14 -- Program.cs
builder.Services.AddHostedService<QueuePump>();      // BackgroundService
builder.Services.AddHostedService<CacheWarmer>();    // raw IHostedService

境界のある System.Threading.Channel と組み合わせた BackgroundService は、典型的なインプロセスのジョブキューです。プロデューサーが作業項目を書き込み、サービスがそれを排出します。コントローラーから Task.Run に手を伸ばしたことがあるなら、それこそが本当に欲しかったパターンです。BackgroundService で fire-and-forget の処理を安全に実行する方法と、BlockingCollection の代わりに Channels を使うというより広い論拠を参照してください。

インプロセスの選択肢を選ぶとき

次のとき BackgroundService を選びます。

次のとき素の IHostedService(または IHostedLifecycleService)を選びます。

どちらもアプリのすべてのインスタンスで実行されます。3 つのレプリカにスケールすると、BackgroundService は調整なしで並列に 3 回実行されます。ステートレスなポーラーならそれで構いません。「夜間の請求書メールを 1 回送る」のためなら、それはバグです。

Hangfire を選ぶとき

次のいずれかが当てはまるとき Hangfire を選びます。

.NET 11 での最小限のセットアップ:

// .NET 11, C# 14 -- Program.cs
builder.Services.AddHangfire(cfg => cfg
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));

builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard("/jobs");  // lock this down in production

// Fire-and-forget, durable:
BackgroundJob.Enqueue<IInvoiceService>(s => s.SendAsync(orderId, CancellationToken.None));

// Recurring (cron):
RecurringJob.AddOrUpdate<IReportService>(
    "nightly-report",
    s => s.BuildAsync(CancellationToken.None),
    Cron.Daily(2));

今何が変わったかに注目してください。Hangfire が管理する一連のデータベーステーブル、接続文字列、Hangfire のアップグレードをまたいだそのスキーマのマイグレーション、そして認可しなければならないダッシュボードのエンドポイントを、あなたが所有することになりました。これは実在する運用上の重みです。永続性とスケジューリング、さもなければ自分で下手に組み上げるものと引き換えに、それを意図的に引き受けるのです。

スループットの全体像、実際の数値とともに

ここでパフォーマンスが決定的な軸であることはまれですが、永続性のコストについて正直である価値はあります。Channel を排出するインプロセスの BackgroundService は、あなた自身の処理を超える I/O を項目ごとには行いません。ディスパッチのオーバーヘッドは事実上メソッド呼び出しであり、処理そのものに対して計測できるほどではありません。対照的に Hangfire は、ジョブごとにデキューのために少なくとも 1 回、完了をマークするためにもう 1 回、ストレージへのラウンドトリップを行います。

Hangfire 自身のドキュメントはストレージの選択を定量化しています。Redis ガイドによると、SQL Server から Redis に切り替えると、空のジョブで 4 倍を超えるスループットが得られます。絶対的な数値はストレージのレイテンシ次第ですが、形は固定です。Hangfire の下限は「データベースへのラウンドトリップ」であり、インプロセスキューの下限は「ゼロ」です。1 秒あたり数万件の些細な項目を処理しているなら、その差は重要で、インプロセスの Channel キューが圧勝します。1 分あたり数千件のジョブを処理し、それぞれが実際の処理(API の呼び出し、PDF のレンダリング)を行うなら、ジョブごとのストレージコストはノイズに埋もれ、永続性は実質的に無料です。

そこから導かれるルール: そこにあるからといって、高頻度で損失に寛容な処理を Hangfire に通してはいけません。1 秒ごとにキューをチェックするポーラーは BackgroundService であって、1 日 86,400 件の Hangfire ジョブではありません。

あなたの代わりに決めてくれる一点

2 つの要件が、好みが入り込む前に議論を終わらせます。

  1. 「アプリが再起動しても、これは失われてはならない。」 ジョブがデプロイで破棄され、それが本物のバグである場合(決済のキャプチャ、確認メール、webhook の配信)、永続ストレージが必要であり、それは Hangfire(または本物のメッセージブローカー)を意味します。StopAsync でどれだけ排出しても、BackgroundServicekill -9 やノード障害を生き延びることはありません。インプロセスの選択肢は処理をメモリに保持します。メモリはプロセスとともに死にます。

  2. 「これはレプリカ全体でちょうど 1 回実行されなければならない。」 BackgroundService は各インスタンスで実行されます。水平にスケールしてジョブが冪等でなければ、重複した処理が発生します。Hangfire の共有ストレージによる worker モデルは、単一の実行を無料で与えてくれます。インプロセスでの同等物は、自分で構築して正しく作る必要のある分散ロックです。

2 つの要件のいずれも当てはまらない場合(処理がインプロセスで、損失に寛容で、かつ単一インスタンスで動かすので 1 回だけ実行されるか、本質的に冪等である場合)、Hangfire を追加するのは何の見返りもなくデータベース税を払うことです。BackgroundService を使ってください。

よくある、そして正しいハイブリッド: 永続的なスケジュールとリトライは Hangfire に保ちつつ、繰り返しジョブの本体はインプロセスの Channel に投入するだけにし、それを BackgroundService が排出します。Hangfire はジョブが 1 回発火し再起動を生き延びることを保証します。Channel は高速で背圧を意識したインプロセスのスループットを与えます。すべての項目をストレージに通すことを強制せずに、両方の特性を得られます。

推奨、改めて

インプロセスでループするものはすべて、デフォルトで BackgroundService を選びます。起動順序や終了前後のフックが特に必要なときだけ、素の IHostedServiceIHostedLifecycleService に手を伸ばします。ジョブが再起動を生き延びる、スケジュールで動く、自動でリトライする、あるいは複数インスタンスにまたがってちょうど 1 回実行される必要が出た瞬間に Hangfire を採用し、それがもたらすデータベースをそれらの保証の対価として受け入れます。「念のため」に Hangfire へ手を伸ばす本能はたいてい逆向きです。インプロセスで始め、具体的な永続性やスケジューリングの要件があなたをより重いツールへ引き寄せるに任せましょう。組み込みのプリミティブの上で動かすときは、盲目飛行にならないようにヘルスチェックとメトリクスでそれらのバックグラウンドジョブを監視し、終了時にループがデッドロックなしできれいにキャンセルされることを確認してください。

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る