J'ai trouvé cette question très intéressante, d'autant plus que j'utilise async
partout avec Ado.Net et EF 6. J'espérais que quelqu'un donne une explication à cette question, mais cela ne s'est pas produit. J'ai donc essayé de reproduire ce problème de mon côté. J'espère que certains d'entre vous trouveront cela intéressant.
Première bonne nouvelle: je l'ai reproduite :) Et la différence est énorme. Avec un facteur 8 ...
D'abord, je soupçonnais quelque chose à propos CommandBehavior
, depuis que j'ai lu un article intéressant sur async
Ado, en disant ceci:
"Étant donné que le mode d'accès non séquentiel doit stocker les données pour la ligne entière, il peut entraîner des problèmes si vous lisez une colonne volumineuse à partir du serveur (comme varbinary (MAX), varchar (MAX), nvarchar (MAX) ou XML ). "
Je soupçonnais que les ToList()
appels étaient CommandBehavior.SequentialAccess
et les appels asynchrones CommandBehavior.Default
(non séquentiels, ce qui peut causer des problèmes). J'ai donc téléchargé les sources d'EF6 et mis des points d'arrêt partout (là CommandBehavior
où ils sont utilisés, bien sûr).
Résultat: rien . Tous les appels sont effectués avec CommandBehavior.Default
.... J'ai donc essayé d'entrer dans le code EF pour comprendre ce qui se passe ... et ... ooouch ... je ne vois jamais un tel code de délégation, tout semble exécuté paresseusement ...
J'ai donc essayé de faire du profilage pour comprendre ce qui se passe ...
Et je pense que j'ai quelque chose ...
Voici le modèle pour créer la table que j'ai comparée, avec 3500 lignes à l'intérieur et 256 Ko de données aléatoires dans chacune 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; }
}
Et voici le code que j'ai utilisé pour créer les données de test et comparer 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);
}
}
Pour l'appel EF normal ( .ToList()
), le profilage semble "normal" et est facile à lire:
Nous retrouvons ici les 8,4 secondes dont nous disposons avec le chronomètre (le profilage ralentit les performances). Nous trouvons également HitCount = 3500 le long du chemin d'appel, ce qui est cohérent avec les 3500 lignes du test. Du côté de l'analyseur TDS, les choses commencent à empirer depuis que nous avons lu 118 353 appels à la TryReadByteArray()
méthode, c'est-à-dire où la boucle de mise en mémoire tampon se produit. (une moyenne de 33,8 appels pour chacun byte[]
de 256 Ko)
Pour le async
cas, c'est vraiment très différent ... Tout d'abord, l' .ToListAsync()
appel est planifié sur le ThreadPool, puis attendu. Rien d'extraordinaire ici. Mais, maintenant, voici l' async
enfer sur le ThreadPool:
Premièrement, dans le premier cas, nous n'avions que 3500 comptages d'appels le long du chemin d'appel complet, ici nous avons 118 371. De plus, vous devez imaginer tous les appels de synchronisation que je n'ai pas mis sur la capture d'écran ...
Deuxièmement, dans le premier cas, nous n'avions "que 118 353" appels à la TryReadByteArray()
méthode, ici nous avons 2 050 210 appels! C'est 17 fois plus ... (sur un test avec une grande baie de 1 Mo, c'est 160 fois plus)
De plus, il y a:
- 120000
Task
instances créées
- 727519
Interlocked
appels
- 290569
Monitor
appels
- 98283
ExecutionContext
instances, avec 264481 captures
- 208733
SpinLock
appels
Je suppose que la mise en mémoire tampon est effectuée de manière asynchrone (et non pas bonne), avec des tâches parallèles essayant de lire les données du TDS. Trop de tâches sont créées uniquement pour analyser les données binaires.
En guise de conclusion préliminaire, nous pouvons dire qu'Async est génial, EF6 est génial, mais l'utilisation de l'async par EF6 dans son implémentation actuelle ajoute une surcharge majeure, du côté des performances, du côté Threading et du côté CPU (12% d'utilisation du processeur dans le ToList()
cas et 20% dans le ToListAsync
cas pour un travail 8 à 10 fois plus long ... je le lance sur un ancien i7 920).
En faisant quelques tests, je repensais à cet article et je remarque quelque chose qui me manque:
"Pour les nouvelles méthodes asynchrones de .Net 4.5, leur comportement est exactement le même que celui des méthodes synchrones, à l'exception d'une exception notable: ReadAsync en mode non séquentiel."
Quelle ?!!!
J'étends donc mes benchmarks pour inclure Ado.Net dans les appels réguliers / asynchrones, et avec CommandBehavior.SequentialAccess
/ CommandBehavior.Default
, et voici une grosse surprise! :
Nous avons exactement le même comportement avec Ado.Net !!! Facepalm ...
Ma conclusion définitive est la suivante : il y a un bogue dans l'implémentation d'EF 6. Il doit basculer sur CommandBehavior
à SequentialAccess
lorsqu'un appel asynchrone est effectué sur une table contenant une binary(max)
colonne. Le problème de la création d'un trop grand nombre de tâches, ralentissant le processus, est du côté d'Ado.Net. Le problème EF est qu'il n'utilise pas Ado.Net comme il se doit.
Maintenant, vous savez qu'au lieu d'utiliser les méthodes asynchrones EF6, vous feriez mieux d'appeler EF de manière non asynchrone normale, puis d'utiliser a TaskCompletionSource<T>
pour renvoyer le résultat de manière asynchrone.
Note 1: J'ai édité mon article à cause d'une erreur honteuse .... J'ai fait mon premier test sur le réseau, pas localement, et la bande passante limitée a déformé les résultats. Voici les résultats mis à jour.
Note 2: Je n'ai pas étendu mon test à d'autres cas d'utilisation (ex: nvarchar(max)
avec beaucoup de données), mais il y a des chances que le même comportement se produise.
Note 3: quelque chose d'habituel pour le ToList()
cas, c'est le CPU à 12% (1/8 de mon CPU = 1 cœur logique). Quelque chose d'inhabituel est le maximum de 20% pour le ToListAsync()
cas, comme si le planificateur ne pouvait pas utiliser toutes les marches. C'est probablement dû au trop grand nombre de tâches créées, ou peut-être à un goulot d'étranglement dans l'analyseur TDS, je ne sais pas ...