Ho trovato questa domanda molto interessante, soprattutto perché sto usando async
ovunque 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 ...
Innanzitutto sospettavo di avere a che fare con qualcosa CommandBehavior
, dato che ho letto un articolo interessante su async
Ado, 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.SequentialAccess
e quelle asincrone CommandBehavior.Default
(non sequenziali, che possono causare problemi). Quindi ho scaricato le fonti di EF6 e messo punti di interruzione ovunque ( CommandBehavior
dove 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:
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 async
caso, è davvero molto diverso .... Innanzitutto, la .ToListAsync()
chiamata è programmata sul ThreadPool e quindi è attesa. Niente di straordinario qui. Ma ora ecco l' async
inferno sul ThreadPool:
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
Task
istanze create
- 727 519
Interlocked
chiamate
- 290 569
Monitor
chiamate
- 98283
ExecutionContext
istanze, con 264 481 catture
- 208 733
SpinLock
chiamate
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 ToListAsync
caso 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! :
Abbiamo lo stesso identico comportamento con Ado.Net !!! Facepalm ...
La mia conclusione definitiva è : c'è un bug nell'implementazione di EF 6. Dovrebbe attivare il CommandBehavior
to SequentialAccess
quando 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 ...