Il completamento dell'operazione asincrona di Entity Framework richiede dieci volte di più


139

Ho un sito MVC che utilizza Entity Framework 6 per gestire il database e ho provato a modificarlo in modo che tutto funzioni come controller asincrono e le chiamate al database vengano eseguite come controparti asincrone (ad esempio ToListAsync () invece di ToList ())

Il problema che sto riscontrando è che la semplice modifica delle mie query in asincrono le ha rese incredibilmente lente.

Il codice seguente ottiene una raccolta di oggetti "Album" dal mio contesto di dati e viene tradotto in un join di database abbastanza semplice:

// Get the albums
var albums = await this.context.Albums
    .Where(x => x.Artist.ID == artist.ID)
    .ToListAsync();

Ecco l'SQL creato:

exec sp_executesql N'SELECT 
[Extent1].[ID] AS [ID], 
[Extent1].[URL] AS [URL], 
[Extent1].[ASIN] AS [ASIN], 
[Extent1].[Title] AS [Title], 
[Extent1].[ReleaseDate] AS [ReleaseDate], 
[Extent1].[AccurateDay] AS [AccurateDay], 
[Extent1].[AccurateMonth] AS [AccurateMonth], 
[Extent1].[Type] AS [Type], 
[Extent1].[Tracks] AS [Tracks], 
[Extent1].[MainCredits] AS [MainCredits], 
[Extent1].[SupportingCredits] AS [SupportingCredits], 
[Extent1].[Description] AS [Description], 
[Extent1].[Image] AS [Image], 
[Extent1].[HasImage] AS [HasImage], 
[Extent1].[Created] AS [Created], 
[Extent1].[Artist_ID] AS [Artist_ID]
FROM [dbo].[Albums] AS [Extent1]
WHERE [Extent1].[Artist_ID] = @p__linq__0',N'@p__linq__0 int',@p__linq__0=134

A conti fatti, non è una query estremamente complicata, ma ci vogliono quasi 6 secondi per eseguirla con SQL Server. SQL Server Profiler lo segnala impiegando 5742ms per il completamento.

Se cambio il mio codice in:

// Get the albums
var albums = this.context.Albums
    .Where(x => x.Artist.ID == artist.ID)
    .ToList();

Quindi viene generato esattamente lo stesso SQL, ma questo viene eseguito in soli 474ms secondo SQL Server Profiler.

Il database ha circa 3500 righe nella tabella "Album", che non è davvero molto, e ha un indice sulla colonna "Artist_ID", quindi dovrebbe essere piuttosto veloce.

So che l'asincrono ha spese generali, ma far andare le cose dieci volte più lentamente mi sembra un po 'ripido! Dove sto sbagliando qui?


non mi sembra giusto. Se si esegue la stessa query con gli stessi dati, il tempo di esecuzione riportato da SQL Server Profiler dovrebbe essere più o meno lo stesso perché l'asincronizzazione è ciò che accade in c #, non in Sql. Il server SQL non è nemmeno a conoscenza del fatto che il codice c # è asincrono
Khanh AL

quando si esegue la query generata per la prima volta, potrebbe essere necessario più tempo per compilare la query (compilare il piano di esecuzione, ...), dalla seconda volta, la stessa query potrebbe essere più veloce (il server SQL memorizza nella cache la query), ma non ci dovrebbe essere troppo diverso.
Khanh AL

3
Devi determinare cosa è lento. Esegui la query in un ciclo infinito. Metti in pausa il debugger 10 volte. Dove si ferma più spesso? Pubblica lo stack incluso il codice esterno.
usr

1
Sembra che il problema sia legato alla proprietà Image, che avevo completamente dimenticato. È una colonna VARBINARY (MAX), quindi è destinata a causare lentezza, ma è ancora un po 'strano che la lentezza diventi solo un problema con l'esecuzione asincrona. Ho ristrutturato il mio database in modo che le immagini facciano ora parte di una tabella collegata e ora tutto sia molto più veloce.
Dylan Parry,

1
Il problema potrebbe essere che EF sta emettendo tonnellate di letture asincrone su ADO.NET per recuperare tutti quei byte e righe. In questo modo l'overhead viene ingrandito. Dato che non hai eseguito la misurazione, ti ho chiesto che non lo sapremo mai. Il problema sembra essere risolto.
usr

Risposte:


286

Ho trovato questa domanda molto interessante, soprattutto perché sto usando asyncovunque con Ado.Net ed EF 6. Speravo che qualcuno desse una spiegazione per questa domanda, ma non è successo. Quindi ho provato a riprodurre questo problema dalla mia parte. Spero che alcuni di voi lo trovino interessante.

Prima buona notizia: l'ho riprodotta :) E la differenza è enorme. Con un fattore 8 ...

primi risultati

Innanzitutto sospettavo di avere a che fare con qualcosa CommandBehavior, dato che ho letto un articolo interessante su asyncAdo, dicendo questo:

"Poiché la modalità di accesso non sequenziale deve memorizzare i dati per l'intera riga, può causare problemi se stai leggendo una colonna di grandi dimensioni dal server (come varbinary (MAX), varchar (MAX), nvarchar (MAX) o XML )."

Sospettavo che le ToList()chiamate fossero CommandBehavior.SequentialAccesse quelle asincrone CommandBehavior.Default(non sequenziali, che possono causare problemi). Quindi ho scaricato le fonti di EF6 e messo punti di interruzione ovunque ( CommandBehaviordove usato, ovviamente).

Risultato: niente . Tutte le chiamate vengono fatte con CommandBehavior.Default.... Quindi ho provato ad entrare nel codice EF per capire cosa succede ... e ... ooouch ... Non vedo mai un codice di delega simile, tutto sembra essere eseguito in modo pigro ...

Quindi ho provato a fare un po 'di profilazione per capire cosa succede ...

E penso di avere qualcosa ...

Ecco il modello per creare la tabella che ho confrontato, con 3500 linee al suo interno e 256 Kb di dati casuali in ciascuno varbinary(MAX). (EF 6.1 - CodeFirst - CodePlex ):

public class TestContext : DbContext
{
    public TestContext()
        : base(@"Server=(localdb)\\v11.0;Integrated Security=true;Initial Catalog=BENCH") // Local instance
    {
    }
    public DbSet<TestItem> Items { get; set; }
}

public class TestItem
{
    public int ID { get; set; }
    public string Name { get; set; }
    public byte[] BinaryData { get; set; }
}

Ed ecco il codice che ho usato per creare i dati di test e benchmark EF.

using (TestContext db = new TestContext())
{
    if (!db.Items.Any())
    {
        foreach (int i in Enumerable.Range(0, 3500)) // Fill 3500 lines
        {
            byte[] dummyData = new byte[1 << 18];  // with 256 Kbyte
            new Random().NextBytes(dummyData);
            db.Items.Add(new TestItem() { Name = i.ToString(), BinaryData = dummyData });
        }
        await db.SaveChangesAsync();
    }
}

using (TestContext db = new TestContext())  // EF Warm Up
{
    var warmItUp = db.Items.FirstOrDefault();
    warmItUp = await db.Items.FirstOrDefaultAsync();
}

Stopwatch watch = new Stopwatch();
using (TestContext db = new TestContext())
{
    watch.Start();
    var testRegular = db.Items.ToList();
    watch.Stop();
    Console.WriteLine("non async : " + watch.ElapsedMilliseconds);
}

using (TestContext db = new TestContext())
{
    watch.Restart();
    var testAsync = await db.Items.ToListAsync();
    watch.Stop();
    Console.WriteLine("async : " + watch.ElapsedMilliseconds);
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
        while (await reader.ReadAsync())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReaderAsync SequentialAccess : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = await cmd.ExecuteReaderAsync(CommandBehavior.Default);
        while (await reader.ReadAsync())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReaderAsync Default : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess);
        while (reader.Read())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReader SequentialAccess : " + watch.ElapsedMilliseconds);
    }
}

using (var connection = new SqlConnection(CS))
{
    await connection.OpenAsync();
    using (var cmd = new SqlCommand("SELECT ID, Name, BinaryData FROM dbo.TestItems", connection))
    {
        watch.Restart();
        List<TestItem> itemsWithAdo = new List<TestItem>();
        var reader = cmd.ExecuteReader(CommandBehavior.Default);
        while (reader.Read())
        {
            var item = new TestItem();
            item.ID = (int)reader[0];
            item.Name = (String)reader[1];
            item.BinaryData = (byte[])reader[2];
            itemsWithAdo.Add(item);
        }
        watch.Stop();
        Console.WriteLine("ExecuteReader Default : " + watch.ElapsedMilliseconds);
    }
}

Per la normale chiamata EF ( .ToList()), la profilazione sembra "normale" ed è facile da leggere:

Traccia ToList

Qui troviamo gli 8,4 secondi che abbiamo con il cronometro (la profilatura rallenta i perf). Troviamo anche HitCount = 3500 lungo il percorso della chiamata, che è coerente con le 3500 linee nel test. Dal punto di vista del parser TDS, le cose iniziano a peggiorare da quando abbiamo letto il TryReadByteArray()metodo 118 353 chiamate , ovvero dove si verifica il ciclo di buffering. (una media di 33,8 chiamate per ciascuno byte[]di 256kb)

Per il asynccaso, è davvero molto diverso .... Innanzitutto, la .ToListAsync()chiamata è programmata sul ThreadPool e quindi è attesa. Niente di straordinario qui. Ma ora ecco l' asyncinferno sul ThreadPool:

ToListAsync hell

Innanzitutto, nel primo caso avevamo solo 3500 conteggi degli hit lungo l'intero percorso della chiamata, qui ne abbiamo 118 371. Inoltre, devi immaginare tutte le chiamate di sincronizzazione che non ho inserito nello screenshot ...

In secondo luogo, nel primo caso, avevamo "solo 118 353" chiamate al TryReadByteArray()metodo, qui abbiamo 2 050 210 chiamate! È 17 volte di più ... (in un test con array da 1 Mb di grandi dimensioni, è 160 volte di più)

Inoltre ci sono:

  • 120.000 Taskistanze create
  • 727 519 Interlockedchiamate
  • 290 569 Monitorchiamate
  • 98283 ExecutionContextistanze, con 264 481 catture
  • 208 733 SpinLockchiamate

La mia ipotesi è che il buffering sia fatto in modo asincrono (e non buono), con attività parallele che cercano di leggere i dati dal TDS. Troppe attività vengono create solo per analizzare i dati binari.

Come conclusione preliminare, possiamo dire che Async è eccezionale, EF6 è eccezionale, ma gli usi EF6 di asincrono nella sua attuale implementazione aggiungono un notevole sovraccarico, dal lato delle prestazioni, dal lato Threading e dal lato CPU (12% di utilizzo CPU nel ToList()caso e 20% nel ToListAsynccaso di un lavoro 8-10 volte più lungo ... lo eseguo su un vecchio i7 920).

Durante alcuni test, ho pensato di nuovo a questo articolo e noto qualcosa che mi manca:

"Per i nuovi metodi asincroni in .Net 4.5, il loro comportamento è esattamente lo stesso dei metodi sincroni, fatta eccezione per un'eccezione notevole: ReadAsync in modalità non sequenziale."

Che cosa ?!!!

Quindi estendo i miei parametri di riferimento per includere Ado.Net nella chiamata normale / asincrona e con CommandBehavior.SequentialAccess/ CommandBehavior.Default, ed ecco una grande sorpresa! :

con indugi

Abbiamo lo stesso identico comportamento con Ado.Net !!! Facepalm ...

La mia conclusione definitiva è : c'è un bug nell'implementazione di EF 6. Dovrebbe attivare il CommandBehaviorto SequentialAccessquando viene effettuata una chiamata asincrona su una tabella contenente una binary(max)colonna. Il problema di creare troppe attività, rallentando il processo, è dal lato Ado.Net. Il problema EF è che non utilizza Ado.Net come dovrebbe.

Ora sai invece di usare i metodi asincroni EF6, è meglio che tu chiami EF in modo normale non asincrono, quindi usa a TaskCompletionSource<T>per restituire il risultato in modo asincrono.

Nota 1: ho modificato il mio post a causa di un vergognoso errore .... Ho eseguito il mio primo test in rete, non localmente, e la larghezza di banda limitata ha distorto i risultati. Ecco i risultati aggiornati.

Nota 2: Non ho esteso il mio test ad altri casi d'uso (es: nvarchar(max)con molti dati), ma ci sono possibilità che si verifichi lo stesso comportamento.

Nota 3: Qualcosa di solito per il ToList()caso, è la CPU del 12% (1/8 della mia CPU = 1 core logico). Qualcosa di insolito è il massimo del 20% per il ToListAsync()caso, come se lo Scheduler non potesse usare tutti i gradini. Probabilmente è dovuto alle troppe attività create o forse a un collo di bottiglia nel parser TDS, non lo so ...


2
Ho aperto un problema su codeplex, spero che faranno qualcosa al riguardo. entityframework.codeplex.com/workitem/2686
rducom,

3
Ho aperto un problema sul nuovo repository di codici EF ospitato su github: github.com/aspnet/EntityFramework6/issues/88
Korayem,

5
Purtroppo il problema su GitHub è stato chiuso con il consiglio di non utilizzare l'asincronizzazione con varbinary. In teoria varbinary dovrebbe essere il caso in cui l'asincrono ha più senso in quanto il thread verrà bloccato più a lungo durante la trasmissione del file. Quindi cosa facciamo ora se vogliamo salvare i dati binari nel DB?
Stilgar,

8
Qualcuno sa se questo è ancora un problema in EF Core? Non sono stato in grado di trovare alcuna informazione o benchmark.
Andrew Lewis,

2
@AndrewLewis Non ho alcuna scienza dietro, ma sto avendo ripetuti timeout del pool di connessioni con EF Core in cui si trovano le due query che causano problemi .ToListAsync()e .CountAsync()... A chiunque altro trovi questo thread di commenti, questa query può essere d'aiuto. Godspeed.
Scott,

2

Poiché ho ricevuto un link a questa domanda un paio di giorni fa, ho deciso di pubblicare un piccolo aggiornamento. Sono stato in grado di riprodurre i risultati della risposta originale utilizzando la versione più recente di EF (6.4.0) e .NET Framework 4.7.2. Sorprendentemente questo problema non è mai stato migliorato.

.NET Framework 4.7.2 | EF 6.4.0 (Values in ms. Average of 10 runs)

non async : 3016
async : 20415
ExecuteReaderAsync SequentialAccess : 2780
ExecuteReaderAsync Default : 21061
ExecuteReader SequentialAccess : 3467
ExecuteReader Default : 3074

Ciò ha posto la domanda: c'è un miglioramento nel core dotnet?

Ho copiato il codice dalla risposta originale a un nuovo progetto dotnet core 3.1.3 e ho aggiunto EF Core 3.1.3. I risultati sono:

dotnet core 3.1.3 | EF Core 3.1.3 (Values in ms. Average of 10 runs)

non async : 2780
async : 6563
ExecuteReaderAsync SequentialAccess : 2593
ExecuteReaderAsync Default : 6679
ExecuteReader SequentialAccess : 2668
ExecuteReader Default : 2315

Sorprendentemente c'è molto miglioramento. Sembra che ci sia ancora un po 'di tempo perché viene chiamato il pool di thread ma è circa 3 volte più veloce dell'implementazione di .NET Framework.

Spero che questa risposta aiuti altre persone che verranno inviate in questo modo in futuro.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.