DbContext Levensduur in ASP.NET Core: Performance versus Thread Safety bij Async Operaties

DbContext levensduur optimaal beheren in ASP.NET Core: Scoped versus Transient, thread safety bij async operaties en performance-optimalisatie met DbContext pooling.

DbContext levensduur en thread safety patronen bij ASP.NET Core async operaties

Het beheer van de DbContext-levensduur is een van de meest voorkomende bronnen van bugs in ASP.NET Core applicaties. De DbContext van Entity Framework Core is niet thread-safe, en onjuist gebruik bij async operaties leidt tot corrupte states, race conditions en exceptions die moeilijk te reproduceren zijn.

DbContext is niet thread-safe

Een enkele DbContext-instantie kan niet gelijktijdig over meerdere threads worden gebruikt. Als een async methode niet wordt afgewacht voordat een andere operatie op dezelfde context begint, raakt de interne state corrupt. Deze regel geldt voor alle EF Core versies, inclusief EF Core 9.

Waarom Scoped Lifetime werkt voor Web Requests

De dependency injection van ASP.NET Core registreert DbContext standaard met een scoped levensduur bij gebruik van AddDbContext<T>(). Elke HTTP-request ontvangt zijn eigen DbContext-instantie, en die instantie wordt verwijderd wanneer de request completeert.

Deze aanpak lost twee problemen op: het zorgt ervoor dat elke request een geïsoleerde database-state heeft, en het aligneert de context-levensduur met het Unit of Work pattern waarbij wijzigingen accumuleren tijdens request-verwerking en samen committen aan het einde.

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

De scoped levensduur is geschikt voor standaard request-response flows. Een controller action of Razor Page handler draait op een enkele thread, en zolang alle async calls correct worden afgewacht, blijft de DbContext in een consistente state gedurende de hele request.

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);
    }
}

De belangrijkste beperking: elke async operatie moet voltooid zijn voordat de volgende begint. De bovenstaande code is correct omdat SaveChangesAsync wordt afgewacht voordat de methode retourneert.

Het Thread Safety probleem bij parallelle operaties

Problemen ontstaan wanneer ontwikkelaars proberen database-operaties te paralleliseren binnen een enkele request. Bekijk dit foutieve voorbeeld:

csharp
// FOUT: Niet gebruiken
public async Task<IActionResult> GetDashboard()
{
    var ordersTask = _context.Orders.CountAsync();
    var productsTask = _context.Products.CountAsync();
    var customersTask = _context.Customers.CountAsync();

    // Drie queries op dezelfde DbContext gelijktijdig uitvoeren
    await Task.WhenAll(ordersTask, productsTask, customersTask);

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

Deze code start drie async queries zonder elke individueel af te wachten. Alle drie operaties draaien gelijktijdig op dezelfde DbContext-instantie, wat de thread safety vereisten van EF Core schendt. Het resultaat is onvoorspelbaar: soms werkt het, soms gooit het een InvalidOperationException, en soms retourneert het incorrecte data.

De officiële Microsoft-documentatie stelt expliciet dat async methodes direct moeten worden afgewacht. De interne change tracker, connection state en query compilation cache zijn niet ontworpen voor concurrent access.

Klaar om je .NET gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Gebruik van IDbContextFactory voor parallelle queries

Wanneer parallelle database-operaties werkelijk nodig zijn, biedt IDbContextFactory<T> de oplossing. Deze factory creëert nieuwe DbContext-instanties on demand, elk met hun eigen geïsoleerde state.

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

Met de factory geregistreerd, wordt deze geïnjecteerd in plaats van de DbContext direct:

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

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

    public async Task<DashboardStats> GetStatsAsync()
    {
        // Elke task krijgt zijn eigen DbContext-instantie
        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
        };
    }
}

Elke task creëert, gebruikt en disposed zijn eigen context. Het await using statement zorgt voor correcte cleanup zelfs als er een exception optreedt.

DbContext Pooling voor High-Throughput Applicaties

Het aanmaken van een nieuwe DbContext omvat geheugenallocatie, initialisatie van de change tracker en opzetten van interne caches. Voor high-throughput applicaties die duizenden requests per seconde verwerken, wordt deze overhead meetbaar.

DbContext pooling adresseert dit door context-instanties te hergebruiken. Wanneer een gepoolde context wordt gedisposed, reset EF Core zijn state en retourneert hem naar de pool in plaats van garbage collection.

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

Benchmarks van Dave Callan tonen aan dat pooling de context-aanmaaktijd met meer dan 90% vermindert in microbenchmarks. Echter, de real-world impact hangt af van de query-patronen van de applicatie. Als database-queries de request-verwerkingstijd domineren, is de context-aanmaak overhead verwaarloosbaar in vergelijking.

Pooling introduceert één beperking: de DbContext state reset bij terugkeer naar de pool. Alle aangepaste velden of properties toegevoegd aan de DbContext class verliezen hun waarden. De change tracker wordt geleegd, dus uncommitted wijzigingen verdwijnen.

Combineren van Pooling met IDbContextFactory

Voor applicaties die zowel pooling-performance als ondersteuning voor parallelle queries nodig hebben, is AddPooledDbContextFactory beschikbaar:

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

Deze registratie biedt IDbContextFactory<AppDbContext> waarbij contexts uit de pool komen. Elke CreateDbContext() aanroep haalt een gepoolde instantie op, en disposing retourneert de instantie naar de pool.

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

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

    public async Task ProcessBatchAsync(IEnumerable<OrderUpdate> updates)
    {
        // Parallelle verwerking in chunks met gepoolde contexts
        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's analyse biedt aanvullende benchmarks die gepoolde factory performance tonen in batch-verwerkingsscenario's.

Blazor Server: Een speciaal geval

Blazor Server applicaties vereisen extra zorg met DbContext levensduur. Een Blazor circuit persisteert over meerdere gebruikersinteracties, in tegenstelling tot HTTP-requests die duidelijke grenzen hebben.

De standaard scoped levensduur wordt problematisch: een enkele DbContext-instantie leeft voor de gehele circuit-duur, potentieel uren. Langlevende contexts accumuleren tracked entities, verhogen geheugengebruik en vertragen operaties.

Program.cs voor Blazor Servercsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

In Blazor componenten wordt de factory geïnjecteerd en worden kortlevende contexts gecreëerd:

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();
    }
}

De AsNoTracking() aanroep is bijzonder belangrijk in Blazor Server. Zonder dit blijft elke geladen entity in de change tracker, wat geheugen verbruikt tot het einde van het circuit.

Background Services en Hosted Services

Background services geregistreerd als singletons kunnen geen scoped DbContext direct injecteren. De service overleeft elke scope, wat levensduur-mismatch fouten veroorzaakt.

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);
        }
    }
}

De IServiceScopeFactory creëert een nieuwe scope voor elke iteratie. De scope, en de DbContext erin, wordt gedisposed aan het einde van elke loop-iteratie.

Alternatief kan IDbContextFactory worden gebruikt voor meer granulaire controle:

OrderProcessingService.cs (factory versie)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);
        }
    }
}

Veelvoorkomende fouten die Thread Safety schendingen veroorzaken

Verschillende patronen veroorzaken consistent DbContext thread safety problemen in productiecode:

Opslaan van DbContext in statische velden of singletons: Een DbContext mag nooit zijn beoogde scope overleven. Statische referenties maken dezelfde instantie toegankelijk vanaf meerdere threads.

Fire-and-forget async calls: Het starten van een async operatie zonder af te wachten terwijl de context op de huidige thread wordt gebruikt, creëert concurrent access.

csharp
// FOUT: Niet gebruiken
public void UpdateAndNotify(int orderId)
{
    var order = _context.Orders.Find(orderId);
    order.Status = OrderStatus.Shipped;
    _context.SaveChangesAsync(); // Niet afgewacht!
    _notificationService.SendAsync(order.CustomerId); // Context is mogelijk nog aan het opslaan
}

Injecteren van DbContext in singleton services: De DI container gooit een exception in development, maar sommige configuraties maskeren deze fout.

Gebruik van DbContext over meerdere awaits zonder begrip van de executie-flow: Elke await is een opschortingspunt. Als de hervatte code op een andere thread draait dan verwacht, kan concurrent access optreden met andere code-paden.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Beslissingsgids voor DbContext Levensduur Configuratie

Het kiezen van de juiste DbContext configuratie hangt af van het applicatie-type en performance-vereisten:

  • Standaard web API of MVC applicatie: Gebruik AddDbContext<T>() met standaard scoped levensduur. Dit dekt de meeste scenario's correct af.
  • High-throughput API (duizenden requests/seconde): Gebruik AddDbContextPool<T>() om allocatie-overhead te verminderen.
  • Applicatie die parallelle database queries vereist: Gebruik AddDbContextFactory<T>() of AddPooledDbContextFactory<T>().
  • Blazor Server applicatie: Gebruik AddDbContextFactory<T>() met kortlevende contexts gecreëerd per operatie.
  • Background services: Gebruik IServiceScopeFactory of IDbContextFactory<T>() om contexts binnen de service te creëren.
  • Batch-verwerking met parallellisme: Gebruik AddPooledDbContextFactory<T>() voor optimale throughput.

Voor diepere behandeling van async patronen in ASP.NET Core behandelt de async programmering module gerelateerde concepten. De EF Core geavanceerde module breidt uit op query-optimalisatie en change tracking gedrag dat hier is besproken.

Dagelijkse challenge

Zie jij de bug in .NET?

Een echt codefragment, een verborgen bug, één poging per dag. Zonder account uit te proberen.

Anthony Fillion-Maillet

Geschreven door

Anthony Fillion-Maillet

Oprichter van SharpSkill

Al meer dan 10 jaar fullstack-ontwikkelaar. Hij leidt SharpSkill en staat in voor alles wat hier verschijnt.

Bijgewerkt op 7 september 2026

Tags

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

Delen

Gerelateerde artikelen