Warum dein Entity Framework langsam ist Teil 3

In Teil 1 haben wir uns den generellen Aufbau angeschaut und mit IQueryable, IEnumerable und ToList die Basis gelegt.
In Teil 2 haben wir anschließend erste Performanceoptimierung mit Include und einzelnen Abfragen durchgeführt.
In diesem Teil werden wir nun die Entity Framework Abfragen noch parallelisieren.

Voraussetzung für Paralelität: Eigene Contexte

Da es das Entity Framework nicht erlaubt, auf einem Context gleichzeitig mehrere Abfragen auszuführen, müssen wir als erstes dafür sorgen, dass jede Abfrage in einem eigenen Context ausgeführt wird und nicht alle Abfragen auf dem selben Context. Im Entity Framework ist das Erstellen eines context Objektes eine sehr schnelle Operation. Man sollte also möglichst für jede Abfrage einen Context erstellen, sodass zum einen die Anzahl der Objekte im Context klein bleibt, was zu schnellerer materialisierung führt, zum anderen Parallelisierung damit möglich wird.

IDictionary<int, Customer> customers;
using (var context = GetContextForGet(counter))
{
    customers = context.Customer.ToDictionary(c => c.CustomerID);
}

IDictionary<int, SalesOrderHeader> headers;
using (var context = GetContextForGet(counter))
{
    headers = context.SalesOrderHeader.ToDictionary(h => h.SalesOrderID);
}

IDictionary<int, SalesOrderDetail> details ;
using (var context = GetContextForGet(counter))
{
    details = context.SalesOrderDetail.ToDictionary(d => d.SalesOrderDetailID);
}

IDictionary<int, Product> products ;
using (var context = GetContextForGet(counter))
{
    products = context.Product.ToDictionary(p => p.ProductID);
}

RelationshipFixup(customers, headers, details, products);

Sum is 353.504,37
Used 4 SQL SELECT Statements
Get took 1285ms
Output took 0ms

Wir sind nicht schneller geworden, da es einfach noch nicht genug Daten sind, haben aber den Grundstein für die Parallelisierung gelegt.

Ohne CPU Zeit zu verschwenden: Async

In .Net können wir Aufrufe, die IO-Bound sind, also keine Berechnung auf der CPU erfordern asynchron machen. Wer das noch nicht kennt, möge sich ersteinmal ein Tutorial zu async/await durchlesen, bevor er hier weiterliest. Wir können also die Entity Framework Abfragen async machen und somit der CPU Zeit für andere Dinge verschaffen. Gleichzeitig lagern wir die Datenbankaufrufe in eigene Funktionen aus, damit der Code übersichtlich bleibt.

var customers = await GetCustomersAsync();
var headers = await GetHeadersAsync();
var details = await GetDetailsAsync();
var products = await GetProductsAsync();

RelationshipFixup(customers, headers, details, products);
private async Task<IDictionary<int, Customer> customers> GetCustomersAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.Customer.ToDictionaryAsync(c => c.CustomerID);
    }
}
private async Task<IDictionary<int, SalesOrderHeader> customers> GetSalesOrderHeadersAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.SalesOrderHeader.ToDictionaryAsync(h => h.SalesOrderID);
    }
}
private async Task<IDictionary<int, SalesOrderDetail> customers> GetSalesOrderDetailsAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.SalesOrderDetail.ToDictionaryAsync(d => d.SalesOrderDetailID);
    }
}
private async Task<IDictionary<int, Product> customers> GetProductsAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.Product.ToDictionary(p => p.ProductID);
    }
}

Sum is 353.504,37
Used 4 SQL SELECT Statements
Get took 1866ms
Output took 0ms

Wir sehen, dass die asynchronen Abfragen länger (wenn auch nicht viel) dauern, als die vorherigen synchronen Abfragen, obwohl wir eigentlich erwarten würden, dass diese nun schneller sind, als vorher. Das liegt daran, dass jedes await einen gewissen Overhead hat und unsere Abfragen war asynchron, aber nicht parallel, sondern weiterhin eine nach der anderen abgearbeitet werden.

Gleichzeitig: Parallel und Asynchron

Da die Abfragen nun asynchron sind, können wir sie auch sehr schön parallelisieren, sodass sie gleichzeitig ausgeführt werden. Wennd ie Datenbank auf dem selben rechner läuft, wie unser Programm, wird man dadurch keinen Performancegewinn merken, da unsere Datenbank allerdings in der Cloud liegt, kann man damit schon noch etwas an Geschwindigkeit herausholen.

var customersTask = GetCustomersAsync();
var headersTask = GetHeadersAsync();
var detailsTask = GetDetailsAsync();
var productsTask = GetProductsAsync();

await Task.WhenAll(customersTask, headersTask, detailsTask, productsTask);

var customers = customersTask.Result;
var headers = headersTask.Result;
var details = detailsTask.Result;
var products = productsTask.Result;

RelationshipFixup(customers, headers, details, products);

Sum is 353.504,37
Used 4 SQL SELECT Statements
Get took 1308ms
Output took 0ms

Durch die Parallelisierung sind wir also wieder schneller geworden und sind ungefähr auf dem Niveau von vorher. Task.WhenAll ist eine Methode, die mehrere Tasks nimt und daraus einen Task erstellt, der fertig ist, wenn alle anderen Tasks fertig sind. Dadurch werden unsere Tasks in kurzen Abständen nacheinander gestartet und im Anschluss wird gleichzeitig auf alle asynchron gewartet. Sind dann alle Abfragen fertig, können wir weiter rechnen.

Bonus: ConfigureAwait(false)

Standardmäßg wird bei jedem await ein sog. Context Capturing durchgeführt. Der aktuelle Ausführungscontext vor dem await wird zwischengespeichert und nach dem await wieder hergestellt. Vereinfacht kann man es sich so vorstellen, dass dafür gesorgt wird, dass nach dem await im gleichen Thread weitergearbeitet wird, wie vor dem await. Dies ist wichtig für Frontend Anwendungen, wie WPF, da nur der GUI Thread Dinge an der GUI verändern darf. Bei Datenbankabfragen ist dies jedoch nicht nötig und wir können explizit sagen, dass wir das Context Capturing ausschalten wollen und somit nocheinmal Performance gewinnen können.

var customersTask = GetCustomersAsync();
var headersTask = GetHeadersAsync();
var detailsTask = GetDetailsAsync();
var productsTask = GetProductsAsync();

await Task.WhenAll(customersTask, headersTask, detailsTask, productsTask).ConfigureAwait(false);

var customers = customersTask.Result;
var headers = headersTask.Result;
var details = detailsTask.Result;
var products = productsTask.Result;

RelationshipFixup(customers, headers, details, products);
private async Task<IDictionary<int, Customer> customers> GetCustomersAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.Customer.ToDictionaryAsync(c => c.CustomerID).ConfigureAwait(false);
    }
}
private async Task<IDictionary<int, SalesOrderHeader> customers> GetSalesOrderHeadersAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.SalesOrderHeader.ToDictionaryAsync(h => h.SalesOrderID).ConfigureAwait(false);
    }
}
private async Task<IDictionary<int, SalesOrderDetail> customers> GetSalesOrderDetailsAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.SalesOrderDetail.ToDictionaryAsync(d => d.SalesOrderDetailID).ConfigureAwait(false);
    }
}
private async Task<IDictionary<int, Product> customers> GetProductsAsync()
{
    using (var context = GetContextForGet(counter))
    {
        return await context.Product.ToDictionary(p => p.ProductID).ConfigureAwait(false);
    }
}

Sum is 353.504,37
Used 4 SQL SELECT Statements
Get took 1180ms
Output took 0ms

Durch das Ausschalten des Context Capturings haben wir also nochmal ca 200ms dazu gewonnen. Als Faustregel kann man sich merken, dass man immer, wenn man Library Code schreibt, hinter jedes await ein ConfigureAwait(false) schreiben sollte. Ich würde sogar soweit gehen und sagen, mann sollte immer ein ConfigureAwait(false) nutzen und erst, wenn man Probleme bemerkt, ConfigureAwait(true) hinschreiben. Dadurch ist außerdem sichergestellt, dass mann es nirgends vergisst.

Fazit

Wir haben eine Entity Framework Abfrage von ursprünglich 52s auf fast 1s herunter gedrückt. Dies haben wir durch folgende Methoden erreicht:
– Nutzen von ToList, bzw. ToDictionary
– Kein Include nutzen, dafür das Join in Memory machen
– Die Abfragen parallel und asynchron ausführen

Es geht sicherlich noch schneller, mit rohem SQL, Stored Procedures, etc., wodurch man allerdings den Komfort vom Entity Framework gröstenteils verliert. Auch sollte man bedenken, dass dies lediglich ein Beispeil ist und es natürlich schneller wäre, die Summe direkt auf dem SQl Server berechnen zu lassen (Sofern man wirklich nur die Summe benötigt). Oft ist es jedoch so, dass man mehr, als einfach nur die Summe benötigt und genau dafür sind diese Optimierungen wichtig.

zum Abschluss gibt es hier noch die Solution als Download. Bitte bedenkt dabei, dass diese für die Performance Messungen noch einige Hilfsfunktionen eingebaut hat, der grundlegende Aufbau ist jedoch der Selbe. Vor dem Ausführen muss noch der Connection String in der App.config angepasst werden (Ihr benötigt eine eigene Datenbank mit dem Adventure Works Datensatz)
Solution

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.