Start Debugging

AddDbContextPool vs AddDbContextFactory: EF Core-Abfragen parallel ausführen

AddDbContextPool liefert pro DI-Scope genau einen Scoped-DbContext und kann daher nicht zwei Abfragen gleichzeitig ausführen. AddDbContextFactory und AddPooledDbContextFactory liefern pro Aufruf einen Kontext, was parallele Abfragen brauchen. Gemessen mit EF Core 11 RC 1: Die gepoolte Factory erzeugt einen Kontext in 342 ns und 40 B gegenüber 17 us und 44 KB.

Wer EF Core-Abfragen parallel ausführen will, ist mit AddDbContextPool allein falsch bedient: Es registriert Ihren DbContext als Scoped-Service, sodass alles innerhalb einer Anfrage (eines DI-Scopes) dieselbe Instanz teilt, und ein einzelner DbContext kann nicht zwei Operationen gleichzeitig ausführen. AddDbContextFactory registriert eine Singleton-IDbContextFactory<T>, die pro Aufruf einen frischen Kontext liefert, genau die Form, die Task.WhenAll braucht. Wer beides will, registriert AddPooledDbContextFactory: dieselbe Factory-API, gestützt auf denselben Pool wie bei AddDbContextPool, sodass jeder parallele Zweig seine eigene recycelte Instanz ausleiht.

Alles Folgende wurde mit EF Core 11.0.0-rc.1.26425.128, SDK 11.0.100-rc.1.26425.128 und Microsoft.EntityFrameworkCore.Sqlite auf einem Apple M4 gemessen. Dasselbe Testprogramm habe ich mit EF Core 10.0.12 und .NET 10.0.10 erneut ausgeführt: Registrierungen, Wiederverwendungsverhalten und Pool-Größe waren identisch, die Zeiten lagen im selben Bereich (14,3 us und 43 KB pro ungepooltem Kontext, 357 ns und 40 B gepoolt).

Der Vergleich auf einen Blick

AddDbContextPool<T>AddDbContextFactory<T>AddPooledDbContextFactory<T>
Was Sie injizierenT (Scoped)IDbContextFactory<T> (Singleton)IDbContextFactory<T> (Singleton)
Registriert T auch als ScopedJa, es ist der HauptdienstJaJa
Kontexte pro DI-Scope1So viele, wie Sie erzeugenSo viele, wie Sie erzeugen
Sicher für Task.WhenAll in einer AnfrageNeinJaJa
Instanzen nach Dispose wiederverwendetJaNeinJa
Kosten für Erzeugen + Dispose (gemessen)n/a über DI-Scope17.250 ns, 44.888 B342 ns, 40 B
Konstruktor darf Scoped-Dienste annehmenNeinJaNein
OnConfiguring läuftEinmal pro gepoolter InstanzBei jeder InstanzEinmal pro gepoolter Instanz
Standard-Pool-Größe1024n/a1024

Die Zeile “sicher parallel” ist die, um die es in diesem Beitrag geht, und sie wird durch die Lebensdauer entschieden, nicht durch Pooling. Pooling entscheidet nur, wie teuer jeder Kontext ist.

Warum ein DbContext nicht zwei Abfragen gleichzeitig ausführen kann

Ein DbContext besitzt einen Change Tracker, eine Verbindung und während einer Abfrage einen offenen Data Reader. Nichts davon ist Thread-sicher, und EF Core versucht gar nicht erst, das zu ändern. Stattdessen gibt es einen Concurrency-Detektor, der eine Ausnahme auslöst, sobald eine zweite Operation startet, während die erste noch läuft. Diese Ausnahme habe ich ausführlich im Beitrag zu “A second operation was started on this context instance” behandelt, aber die Kurzfassung zählt hier: Parallelität in EF Core bedeutet immer einen Kontext pro gleichzeitiger Operation.

Genau hier stolpern Leute über AddDbContextPool. Es klingt, als müsste es bei Nebenläufigkeit helfen (“ein Pool von Kontexten”), aber der Pool wird über Scopes hinweg geteilt, nicht innerhalb eines Scopes. Das hier ist der tatsächliche Inhalt des Containers nach jedem Aufruf, ausgegeben aus einer EF Core 11 RC 1-ServiceCollection:

--- AddDbContextPool
  Singleton IDbContextOptionsConfiguration<AppDb>
  Singleton DbContextOptions<AppDb>
  Singleton IDbContextPool<AppDb>
  Scoped    IScopedDbContextLease<AppDb>
  Scoped    AppDb
--- AddDbContextFactory
  Singleton IDbContextOptionsConfiguration<AppDb>
  Singleton DbContextOptions<AppDb>
  Singleton IDbContextFactorySource<AppDb>
  Singleton IDbContextFactory<AppDb>
  Scoped    AppDb
--- AddPooledDbContextFactory
  Singleton IDbContextOptionsConfiguration<AppDb>
  Singleton DbContextOptions<AppDb>
  Singleton IDbContextPool<AppDb>
  Singleton IDbContextFactory<AppDb>
  Scoped    AppDb

Bei AddDbContextPool ist AppDb Scoped und wird über IScopedDbContextLease<AppDb> aus dem Pool geleast. Wer es zweimal im selben Scope auflöst, erhält dasselbe Objekt. Im Container gibt es überhaupt keine IDbContextFactory<AppDb>, also lässt sich auch kein zweiter Kontext anfordern.

Die parallele Abfrage, die mit AddDbContextPool fehlschlägt

Das ist die minimale Reproduktion. Ein Controller oder Minimal-API-Endpunkt erhält den Scoped-Kontext und versucht, aufzufächern:

// .NET 11, C# 14, EF Core 11.0.0-rc.1
builder.Services.AddDbContextPool<AppDb>(o => o.UseSqlServer(cs));

app.MapGet("/dashboard", async (AppDb db) =>
{
    // Both queries use the same pooled instance: this throws.
    var ordersTask = db.Orders.CountAsync();
    var customersTask = db.Customers.CountAsync();
    await Task.WhenAll(ordersTask, customersTask);
    return new { Orders = ordersTask.Result, Customers = customersTask.Result };
});

Bei SQL Server oder PostgreSQL, wo die asynchronen Aufrufe beim Warten auf das Netzwerk tatsächlich die Kontrolle abgeben, startet die zweite Abfrage, bevor die erste fertig ist, und EF Core löst eine Ausnahme aus:

InvalidOperationException: A second operation was started on this context instance
before a previous operation completed. This is usually caused by different threads
concurrently using the same instance of DbContext.

Zwei Details aus den Tests. Erstens: Die asynchronen Methoden von SQLite laufen synchron zu Ende, die naive Version oben überlappt sich auf SQLite also nicht und “funktioniert”, weshalb eine SQLite-basierte Testsuite diesen Fehler schlecht erkennt. Ich musste beide Abfragen in Task.Run verpacken, damit sie sich überlappen. Zweitens: Tritt der Wettlauf bei der allerersten Verwendung eines frischen Kontexts auf, erscheint eine andere Meldung, “An attempt was made to use the context instance while it is being configured”, weil beide Threads gleichzeitig versuchen, den Kontext zu initialisieren. Derselbe Fehler, dieselbe Lösung.

Auffächern mit AddDbContextFactory

Die Factory-Variante gibt jedem Zweig eine eigene Instanz:

// .NET 11, C# 14, EF Core 11.0.0-rc.1
builder.Services.AddDbContextFactory<AppDb>(o => o.UseSqlServer(cs));

app.MapGet("/dashboard", async (IDbContextFactory<AppDb> factory, CancellationToken ct) =>
{
    async Task<int> CountOrders()
    {
        await using var db = await factory.CreateDbContextAsync(ct);
        return await db.Orders.CountAsync(ct);
    }

    async Task<int> CountCustomers()
    {
        await using var db = await factory.CreateDbContextAsync(ct);
        return await db.Customers.CountAsync(ct);
    }

    var orders = CountOrders();
    var customers = CountCustomers();
    await Task.WhenAll(orders, customers);
    return new { Orders = orders.Result, Customers = customers.Result };
});

Jede lokale Funktion erzeugt einen Kontext, führt eine Abfrage aus und gibt ihn wieder frei. Es wird nichts geteilt, also gibt es nichts, worum ein Wettlauf entstehen könnte. In meinem Testprogramm lieferte dieselbe Form mit vier Zweigen (Task.WhenAll über vier CountAsync-Aufrufe, einer pro Region) bei jedem Lauf 250,250,250,250.

Beachten Sie, dass AddDbContextFactory auch AppDb als Scoped registriert hat. Bestehender Code, der AppDb direkt injiziert, funktioniert weiter. Sie können also die Registrierung austauschen, ohne jeden Konstruktor der Anwendung anzufassen, und nur die Endpunkte, die auffächern, müssen die Factory annehmen.

AddPooledDbContextFactory: beides zugleich

AddDbContextFactory erzeugt bei jedem Aufruf von CreateDbContext einen brandneuen Kontext. Diese Kosten sind neben einem Datenbank-Roundtrip normalerweise klein, summieren sich aber bei einem stark genutzten Endpunkt, der in fünf oder zehn Abfragen auffächert. AddPooledDbContextFactory behält die Factory-Form bei und leiht Instanzen stattdessen aus einem Pool:

// .NET 11, C# 14, EF Core 11.0.0-rc.1
builder.Services.AddPooledDbContextFactory<AppDb>(o => o.UseSqlServer(cs));

Der aufrufende Code ist identisch mit dem des vorigen Abschnitts, da Sie weiterhin IDbContextFactory<AppDb> injizieren. Dahinter steht PooledDbContextFactory<AppDb> statt DbContextFactory<AppDb>, und Dispose gibt die Instanz an den Pool zurück, statt sie wegzuwerfen. Das habe ich direkt geprüft: Kontext erzeugen, freigeben, einen weiteren erzeugen, und ReferenceEquals liefert mit der gepoolten Factory true und mit der normalen false.

Das bringt es, gemessen mit einer einfachen Schleife (200.000 Iterationen für Erzeugen/Freigeben, 20.000 für Erzeugen/Abfragen/Freigeben, aufgewärmt, ein Thread, GC.GetAllocatedBytesForCurrentThread für die Allokationen):

OperationAddDbContextFactoryAddPooledDbContextFactory
CreateDbContext + Model berühren + Dispose17.250 ns, 44.888 B342 ns, 40 B
Erzeugen + FirstOrDefault per Schlüssel (SQLite, ohne Tracking) + Dispose49,7 us, 62.461 B20,0 us, 11.710 B

Das sind Zahlen gegen eine lokale SQLite-Datei, der Datenbankanteil ist also fast kostenlos und der Kontext-Setup dominiert. Gegen einen echten SQL Server über das Netzwerk überdeckt die Abfragezeit die 17 us, weshalb die offizielle Dokumentation Pooling als etwas für “high-performance scenarios” beschreibt. Der Unterschied bei den Allokationen schrumpft mit der Latenz aber nicht: 44 KB Garbage pro Kontext, mal zehn parallele Zweige, mal Ihre Anfragerate, sind echter GC-Druck. Die EF Core-Dokumentation zu fortgeschrittener Performance nennt dasselbe Bild gegen SQL Server: 50,38 KB allokiert ohne Pooling gegenüber 4,63 KB mit Pooling.

Dieselbe Dokumentation vermerkt außerdem, dass das Auflösen eines gepoolten Kontexts über DI im Vergleich zum direkten Aufruf der gepoolten Factory “incurs a slight overhead”, die Factory ist also auch dann die schnellere der beiden gepoolten Optionen, wenn Sie keine Parallelität brauchen.

Beides registrieren und einen Pool teilen

Sie müssen sich nicht entscheiden. AddDbContextPool<AppDb> gefolgt von AddPooledDbContextFactory<AppDb> mit denselben Optionen funktioniert, und beide teilen sich denselben IDbContextPool<AppDb>. Ich habe das geprüft, indem ich einen Kontext aus der Factory ausgeliehen, freigegeben und dann AppDb aus einem neuen Scope aufgelöst habe: Es war dieselbe Instanz. So kann der Großteil der Anwendung AppDb wie gewohnt injizieren, während die wenigen auffächernden Endpunkte die Factory injizieren, ohne zwei Pools zu bezahlen.

Mit EF Core 11 gibt es außerdem eine parameterlose Überladung AddPooledDbContextFactory<T>(), die die Konfiguration aus dem OnConfiguring des Kontexts liest. Ich habe sie im Beitrag zu EF Core 11 Preview 3 über RemoveDbContext und die gepoolte Factory beschrieben.

Nicht der Pool begrenzt die Parallelität, sondern der Connection Pool

Die Standard-poolSize beträgt sowohl für AddDbContextPool als auch für AddPooledDbContextFactory 1024. Diese Zahl ist die maximale Anzahl an Instanzen, die der Pool behält, nicht die maximale Anzahl gleichzeitig lebender Instanzen. Als ich poolSize: 2 setzte und fünf Kontexte gleichzeitig auslieh, bekam ich fünf verschiedene Instanzen. Nachdem ich alle fünf freigegeben und erneut fünf ausgeliehen hatte, kamen genau zwei aus dem ersten Durchgang zurück. Anders gesagt: Überlauf fällt auf das Erzeugen frischer Kontexte zurück, und die überzähligen werden bei der Rückgabe einfach verworfen. Der Pool blockiert nie.

Die eigentliche Obergrenze für parallele Abfragen ist der darunterliegende ADO.NET Connection Pool. EF Core öffnet eine Verbindung unmittelbar vor jeder Abfrage und schließt sie unmittelbar danach, und jede gleichzeitige Abfrage braucht ihre eigene Verbindung. Microsoft.Data.SqlClient verwendet standardmäßig Max Pool Size=100, und auch Npgsql hat den Standardwert 100. Wer pro Anfrage 20 Abfragen auffächert und 10 gleichzeitige Anfragen hat, wartet bereits auf Verbindungen, was sich als Timeout beim Beschaffen einer Verbindung aus dem Pool zeigt und nicht als EF-Fehler. Wenn Sie über eine Liste von IDs auffächern, begrenzen Sie den Grad der Parallelität mit Parallel.ForEachAsync, statt alles auf einmal an Task.WhenAll zu übergeben. Die Abwägungen stehen in Parallel.ForEach vs Parallel.ForEachAsync vs Task.WhenAll.

// .NET 11, C# 14, EF Core 11.0.0-rc.1
await Parallel.ForEachAsync(regionIds,
    new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
    async (regionId, token) =>
    {
        await using var db = await factory.CreateDbContextAsync(token);
        totals[regionId] = await db.Orders
            .Where(o => o.RegionId == regionId)
            .SumAsync(o => o.Total, token);
    });

totals sollte hier ein ConcurrentDictionary<int, decimal> oder ein vorab dimensioniertes Array sein, da der Schleifenrumpf nebenläufig läuft.

Stolperfallen, die nur die gepoolten Varianten betreffen

Scoped-Konstruktorabhängigkeiten werden vom Root Provider aufgelöst

Das hat mich überrascht. Ein gepoolter Kontext wird einmal erzeugt und über Scopes hinweg wiederverwendet, seine Konstruktorabhängigkeiten können also nicht aus dem Anfrage-Scope stammen. In EF Core 11 RC 1 verhält sich ein gepoolter Kontext mit einem Konstruktor wie TenantDb(DbContextOptions<TenantDb> options, Tenant tenant), wobei Tenant Scoped ist, so:

Der Fehler, den man lokal nie sieht, wird in Produktion also zu einem mandantenübergreifenden Datenleck. Die einfache AddDbContextFactory hat dieses Problem nicht, weil sie jedes Mal einen neuen Kontext baut. Wenn Sie Zustand pro Anfrage mit Pooling brauchen, ist das dokumentierte Muster eine Scoped-Wrapper-Factory, die bei der gepoolten Factory ausleiht und eine Eigenschaft setzt:

// .NET 11, C# 14, EF Core 11.0.0-rc.1
public sealed class TenantDbFactory(
    IDbContextFactory<TenantDb> pooled, ITenant tenant) : IDbContextFactory<TenantDb>
{
    public TenantDb CreateDbContext()
    {
        var db = pooled.CreateDbContext();
        db.TenantId = tenant.Id; // reset on every rent, never trust the previous value
        return db;
    }
}

builder.Services.AddPooledDbContextFactory<TenantDb>(o => o.UseSqlServer(cs));
builder.Services.AddScoped<TenantDbFactory>();

Dieselbe Falle, Scoped in Singleton, gibt es auch außerhalb von EF; der Beitrag zu “Cannot consume scoped service from singleton” erklärt, warum der Container sie ablehnt.

Eigene Felder werden nicht zurückgesetzt

EF Core setzt seinen eigenen Zustand zurück, wenn ein gepoolter Kontext zurückgegeben wird: Der Change Tracker wird geleert (ich habe eine Entität hinzugefügt, freigegeben, erneut ausgeliehen, und ChangeTracker.Entries() war leer). Felder und Eigenschaften, die Sie Ihrer DbContext-Unterklasse hinzugefügt haben, werden nicht angefasst. Ein public string? Note, das ich vor dem Freigeben auf "dirty" gesetzt hatte, war beim nächsten Ausleihen immer noch "dirty". Alles, was pro Anfrage gilt, muss bei jedem Ausleihen zugewiesen werden, wie im Wrapper oben. Dasselbe gilt für eine DbConnection, die Sie manuell geöffnet haben: Schließen Sie sie, bevor der Kontext zurückgeht.

OnConfiguring läuft nur einmal

Da die Instanz wiederverwendet wird, läuft OnConfiguring nur beim ersten Erzeugen einer gepoolten Instanz. Lesen Sie dort nicht den aktuellen Benutzer, Mandanten oder die Kultur.

Dispose gibt die Instanz zurück

Bei der gepoolten Factory wird ein Kontext, den Sie nicht freigeben, nie zurückgegeben. Das ist kein Leck im klassischen Sinn, da der GC ihn weiterhin einsammelt, aber Sie verlieren den Pooling-Vorteil, und der Pool füllt sich unbemerkt mit frischen Instanzen. Verwenden Sie immer await using.

Wann was zu wählen ist

Eine letzte Alternative, falls Sie bereits AddDbContextPool nutzen und keine zweite Registrierung wollen: Erzeugen Sie pro parallelem Zweig mit IServiceScopeFactory.CreateAsyncScope() einen Child-Scope und lösen Sie AppDb daraus auf. Jeder Scope least seine eigene gepoolte Instanz, und mein Test mit vier Zweigen lieferte wieder 250,250,250,250. Das funktioniert, ist aber mehr Aufwand als die Factory zu injizieren, und jeder Zweig löst zudem alles andere in diesem Scope auf.

Die Faustregel: Parallelität braucht einen Kontext pro Operation, und nur die beiden Factories liefern das direkt. Pooling ist eine unabhängige Entscheidung darüber, wie günstig jeder dieser Kontexte ist, und in EF Core 11 macht die gepoolte Factory das Erzeugen etwa 50-mal günstiger, solange Ihr Kontext keinen Zustand pro Anfrage im Konstruktor trägt.

Quellen

Comments

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

< Zurück