ASP.NET CoreにおけるDbContextのライフタイム管理:非同期操作でのパフォーマンスとスレッドセーフティ

ASP.NET CoreでのDbContextライフタイム管理を解説します。スコープ付きライフタイムとトランジェントの使い分け、非同期操作でのスレッドセーフティ、DbContextプーリングによるパフォーマンス最適化について学びます。

ASP.NET CoreでのDbContextライフタイム管理の図解

DbContextのライフタイム管理は、ASP.NET Coreアプリケーションにおいて最も一般的なバグの原因の一つです。Entity Framework CoreのDbContextはスレッドセーフではなく、非同期操作で誤って使用すると、状態の破損、競合状態、再現が困難な例外が発生します。

DbContextはスレッドセーフではありません

単一のDbContextインスタンスを複数のスレッドで同時に使用することはできません。非同期メソッドが次の操作を開始する前にawaitされていない場合、内部状態が破損します。このルールはEF Core 9を含むすべてのEF Coreバージョンに適用されます。

スコープ付きライフタイムがWebリクエストに適している理由

ASP.NET Coreの依存性注入は、AddDbContext<T>()を使用する場合、デフォルトでDbContextをスコープ付きライフタイムで登録します。各HTTPリクエストは独自のDbContextインスタンスを受け取り、そのインスタンスはリクエスト完了時に破棄されます。

このアプローチは2つの問題を解決します。各リクエストが分離されたデータベース状態を持つことを保証し、リクエスト処理中に変更が蓄積され、最後にまとめてコミットされるユニットオブワークパターンとコンテキストのライフタイムを整合させます。

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

スコープ付きライフタイムは、標準的なリクエスト-レスポンスフローに適しています。コントローラーアクションやRazor Pageハンドラーは単一スレッドで実行され、すべての非同期呼び出しが適切にawaitされている限り、DbContextはリクエスト全体を通して一貫した状態を維持します。

OrderController.cscsharp
public class OrderController : ControllerBase
{
    private readonly AppDbContext _context;

    public OrderController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
    {
        var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

重要な制約として、すべての非同期操作は次の操作が開始される前に完了する必要があります。上記のコードは、SaveChangesAsyncがメソッドが戻る前にawaitされているため正しいです。

並列操作におけるスレッドセーフティの問題

開発者が単一リクエスト内でデータベース操作を並列化しようとすると問題が発生します。以下の壊れた例を考えてみましょう。

csharp
// 壊れています:使用しないでください
public async Task<IActionResult> GetDashboard()
{
    var ordersTask = _context.Orders.CountAsync();
    var productsTask = _context.Products.CountAsync();
    var customersTask = _context.Customers.CountAsync();

    // 同じDbContextで3つのクエリを同時に実行
    await Task.WhenAll(ordersTask, productsTask, customersTask);

    return Ok(new { Orders = ordersTask.Result, Products = productsTask.Result, Customers = customersTask.Result });
}

このコードは、各クエリを個別にawaitせずに3つの非同期クエリを開始します。3つの操作すべてが同じDbContextインスタンスで同時に実行され、EF Coreのスレッドセーフティ要件に違反します。結果は予測不可能です。動作することもあれば、InvalidOperationExceptionをスローすることもあり、誤ったデータを返すこともあります。

Microsoftの公式ドキュメントでは、非同期メソッドは即座にawaitする必要があると明示されています。内部の変更トラッカー、接続状態、クエリコンパイルキャッシュは同時アクセス用に設計されていません。

.NETの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

並列クエリのためのIDbContextFactoryの使用

並列データベース操作が本当に必要な場合、IDbContextFactory<T>が解決策を提供します。このファクトリはオンデマンドで新しいDbContextインスタンスを作成し、それぞれが独自の分離された状態を持ちます。

Program.cscsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

ファクトリを登録したら、DbContextを直接注入する代わりにファクトリを注入します。

DashboardService.cscsharp
public class DashboardService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public DashboardService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<DashboardStats> GetStatsAsync()
    {
        // 各タスクが独自のDbContextインスタンスを取得
        var ordersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Orders.CountAsync();
        });

        var productsTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Products.CountAsync();
        });

        var customersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Customers.CountAsync();
        });

        await Task.WhenAll(ordersTask, productsTask, customersTask);

        return new DashboardStats
        {
            Orders = ordersTask.Result,
            Products = productsTask.Result,
            Customers = customersTask.Result
        };
    }
}

各タスクは独自のコンテキストを作成し、使用し、破棄します。await using文は、例外が発生した場合でも適切なクリーンアップを保証します。

高スループットアプリケーション向けのDbContextプーリング

新しいDbContextの作成には、メモリの割り当て、変更トラッカーの初期化、内部キャッシュの設定が含まれます。毎秒数千のリクエストを処理する高スループットアプリケーションでは、このオーバーヘッドが測定可能になります。

DbContextプーリングは、コンテキストインスタンスを再利用することでこれに対処します。プールされたコンテキストが破棄されると、EF Coreはその状態をリセットし、ガベージコレクションする代わりにプールに戻します。

Program.cscsharp
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

Dave Callanによるベンチマークによると、プーリングはマイクロベンチマークでコンテキスト作成時間を90%以上削減します。ただし、実際の影響はアプリケーションのクエリパターンによって異なります。データベースクエリがリクエスト処理時間を支配している場合、コンテキスト作成のオーバーヘッドは比較的無視できます。

プーリングには1つの制約があります。DbContextの状態はプールへの返却時にリセットされます。DbContextクラスに追加されたカスタムフィールドやプロパティは値を失います。変更トラッカーはクリアされるため、コミットされていない変更は消失します。

プーリングとIDbContextFactoryの組み合わせ

プーリングのパフォーマンスと並列クエリサポートの両方が必要なアプリケーションには、AddPooledDbContextFactoryを使用します。

Program.cscsharp
builder.Services.AddPooledDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

この登録は、コンテキストがプールから取得されるIDbContextFactory<AppDbContext>を提供します。各CreateDbContext()呼び出しはプールされたインスタンスを取得し、破棄するとインスタンスをプールに戻します。

BatchProcessor.cscsharp
public class BatchProcessor
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public BatchProcessor(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task ProcessBatchAsync(IEnumerable<OrderUpdate> updates)
    {
        // プールされたコンテキストで並列チャンク処理
        var chunks = updates.Chunk(100);
        var tasks = chunks.Select(async chunk =>
        {
            await using var context = _contextFactory.CreateDbContext();
            foreach (var update in chunk)
            {
                var order = await context.Orders.FindAsync(update.OrderId);
                if (order != null)
                {
                    order.Status = update.NewStatus;
                }
            }
            await context.SaveChangesAsync();
        });

        await Task.WhenAll(tasks);
    }
}

Milan Jovanovicの分析では、バッチ処理シナリオでのプールされたファクトリのパフォーマンスに関する追加のベンチマークを提供しています。

Blazor Server:特殊なケース

Blazor ServerアプリケーションはDbContextのライフタイムに特別な注意が必要です。Blazorサーキットは複数のユーザー操作にわたって持続し、明確な境界を持つHTTPリクエストとは異なります。

デフォルトのスコープ付きライフタイムは問題になります。単一のDbContextインスタンスがサーキット全体の期間(場合によっては数時間)存続します。長寿命のコンテキストは追跡されるエンティティを蓄積し、メモリ使用量を増加させ、操作を遅くします。

Blazor Server用Program.cscsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

Blazorコンポーネントでは、ファクトリを注入し、短寿命のコンテキストを作成します。

OrderList.razor.cscsharp
public partial class OrderList : ComponentBase
{
    [Inject]
    private IDbContextFactory<AppDbContext> ContextFactory { get; set; } = default!;

    private List<Order> _orders = new();

    protected override async Task OnInitializedAsync()
    {
        await using var context = ContextFactory.CreateDbContext();
        _orders = await context.Orders
            .AsNoTracking()
            .OrderByDescending(o => o.CreatedAt)
            .Take(50)
            .ToListAsync();
    }
}

AsNoTracking()呼び出しはBlazor Serverで特に重要です。これがないと、ロードされたすべてのエンティティがサーキットが終了するまで変更トラッカーに残り、メモリを消費します。

バックグラウンドサービスとホステッドサービス

シングルトンとして登録されたバックグラウンドサービスは、スコープ付きDbContextを直接注入できません。サービスはスコープよりも長く存続し、ライフタイムの不一致エラーを引き起こします。

OrderProcessingService.cscsharp
public class OrderProcessingService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessingService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

IServiceScopeFactoryは各イテレーションに新しいスコープを作成します。スコープとその中のDbContextは、各ループイテレーションの最後に破棄されます。

代わりに、より細かい制御のためにIDbContextFactoryを使用することもできます。

OrderProcessingService.cs(ファクトリバージョン)csharp
public class OrderProcessingService : BackgroundService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public OrderProcessingService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var context = _contextFactory.CreateDbContext();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

スレッドセーフティ違反を引き起こす一般的な間違い

本番コードでDbContextのスレッドセーフティの問題を引き起こすパターンがいくつかあります。

DbContextを静的フィールドやシングルトンに格納する:DbContextは意図されたスコープを超えて存続すべきではありません。静的参照により、複数のスレッドから同じインスタンスにアクセス可能になります。

fire-and-forget非同期呼び出し:非同期操作をawaitせずに開始し、現在のスレッドでコンテキストを使い続けると、同時アクセスが発生します。

csharp
// 壊れています:使用しないでください
public void UpdateAndNotify(int orderId)
{
    var order = _context.Orders.Find(orderId);
    order.Status = OrderStatus.Shipped;
    _context.SaveChangesAsync(); // awaitされていない!
    _notificationService.SendAsync(order.CustomerId); // コンテキストはまだ保存中かもしれない
}

シングルトンサービスにDbContextを注入する:DIコンテナは開発環境で例外をスローしますが、一部の設定ではこのエラーがマスクされます。

実行フローを理解せずに複数のawaitをまたいでDbContextを使用する:各awaitは中断ポイントです。再開されたコードが予期したスレッドと異なるスレッドで実行される場合、他のコードパスとの同時アクセスが発生する可能性があります。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

DbContextライフタイム構成の決定ガイド

適切なDbContext構成の選択は、アプリケーションの種類とパフォーマンス要件によって異なります。

  • 標準的なWeb APIまたはMVCアプリケーションAddDbContext<T>()をデフォルトのスコープ付きライフタイムで使用します。これはほとんどのシナリオを正しくカバーします。
  • 高スループットAPI(毎秒数千リクエスト)AddDbContextPool<T>()を使用してアロケーションオーバーヘッドを削減します。
  • 並列データベースクエリが必要なアプリケーションAddDbContextFactory<T>()またはAddPooledDbContextFactory<T>()を使用します。
  • Blazor ServerアプリケーションAddDbContextFactory<T>()を使用し、操作ごとに短寿命のコンテキストを作成します。
  • バックグラウンドサービスIServiceScopeFactoryまたはIDbContextFactory<T>()を使用してサービス内でコンテキストを作成します。
  • 並列処理を伴うバッチ処理AddPooledDbContextFactory<T>()を使用して最適なスループットを実現します。

ASP.NET Coreでの非同期パターンのより詳細なカバレッジについては、非同期プログラミングモジュールで関連概念を扱っています。EF Core上級モジュールでは、ここで説明したクエリ最適化と変更追跡の動作について詳しく説明しています。

今日のチャレンジ

.NET のバグを見つけられますか

実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

Anthony Fillion-Maillet

執筆

Anthony Fillion-Maillet

SharpSkill 創業者

10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。

2026年9月7日 更新

タグ

#dotnet
#entity-framework
#aspnet-core
#async
#performance

共有

関連記事