J'ai un site MVC qui utilise Entity Framework 6 pour gérer la base de données et j'ai essayé de le changer pour que tout fonctionne en tant que contrôleurs asynchrones et que les appels à la base de données soient exécutés en tant que leurs homologues asynchrones (par exemple, ToListAsync () au lieu de ToList ())
Le problème que j’éprouve, c’est que le simple fait de changer mes requêtes en asynchrone les a rendues incroyablement lentes.
Le code suivant récupère une collection d'objets "Album" à partir de mon contexte de données et est traduit en une jointure de base de données relativement simple:
// Get the albums
var albums = await this.context.Albums
.Where(x => x.Artist.ID == artist.ID)
.ToListAsync();
Voici le code SQL créé:
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
En l'état actuel des choses, la requête n'est pas extrêmement compliquée, mais le serveur SQL prend presque 6 secondes pour l'exécuter. SQL Server Profiler indique qu'il a fallu 5742 ms pour le terminer.
Si je change de code pour:
// Get the albums
var albums = this.context.Albums
.Where(x => x.Artist.ID == artist.ID)
.ToList();
Ensuite, le même SQL exact est généré, mais cela ne prend que 474ms selon SQL Server Profiler.
La base de données contient environ 3 500 lignes dans la table "Albums", ce qui n’est pas très important, et contient un index dans la colonne "Artist_ID". Elle devrait donc être assez rapide.
Je sais qu’async a des frais généraux, mais ralentir dix fois plus vite me semble un peu raide! Où est-ce que je vais mal ici?
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 donnerait 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 qui traitait de CommandBehavior
, puisque j'ai lu un article intéressant à propos de async
avec Ado, en disant ceci:
"Dans la mesure où le mode d’accès non séquentiel doit stocker les données de la ligne entière, des problèmes peuvent survenir si vous lisez une colonne volumineuse sur le serveur (telle que varbinary (MAX), varchar (MAX), nvarchar (MAX) ou XML. ). "
Je soupçonnais que ToList()
s'appelait _CommandBehavior.SequentialAccess
_ et que les appels asynchrones soient _CommandBehavior.Default
_ (non séquentiels, ce qui peut entraîner des problèmes). J'ai donc téléchargé les sources de EF6 et mis des points d'arrêt partout (où CommandBehavior
a été utilisé, bien sûr).
Résultat: rien . Tous les appels sont passés avec _CommandBehavior.Default
_ ... J'ai donc essayé d'entrer dans le code EF pour comprendre ce qui se passait ... et .. ooouch ... Je ne vois jamais un tel code délégant, tout semble être exécuté paresseux. ..
J'ai donc essayé de faire du profilage pour comprendre ce qui se passe ...
Et je pense avoir quelque chose ...
Voici le modèle pour créer la table référencée avec 3500 lignes et 256 Kb données aléatoires dans chaque 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 le 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);
}
}
_
Pour l'appel EF normal (.ToList()
), le profilage semble "normal" et est facile à lire:
Nous trouvons ici les 8,4 secondes que nous avons avec le chronomètre (le profilage ralentit les perfs). Nous trouvons également HitCount = 3500 le long du chemin d’appel, ce qui est cohérent avec les lignes 3500 du test. Du côté de l’analyseur TDS, les choses commencent à s’aggraver depuis que nous avons lu 118 353 appels sur la méthode TryReadByteArray()
, où la boucle de mise en mémoire tampon se produit. (33,8 appels en moyenne pour chaque _byte[]
_ de 256 ko)
Pour le cas async
, c'est vraiment très différent .... Tout d'abord, l'appel .ToListAsync()
est planifié sur le ThreadPool, puis attendu. Rien d'étonnant ici. Mais maintenant, voici l'enfer async
sur le ThreadPool:
Premièrement, dans le premier cas, nous n'avions que 3 500 coups le long du chemin d'appels, nous en avons ici 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 méthode TryReadByteArray()
, ici nous avons 2 050 210 appels! C'est 17 fois plus ... (sur un test avec un grand tableau de 1Mb, c'est 160 fois plus)
De plus il y a:
Task
instances crééesInterlocked
appelsMonitor
appelsExecutionContext
instances, avec 264 481 capturesSpinLock
appelsMon hypothèse est que la mise en mémoire tampon est faite de manière asynchrone (et pas une bonne), avec des tâches parallèles essayant de lire des données à partir du TDS. Trop de tâches sont créées uniquement pour analyser les données binaires.
Comme conclusion préliminaire, nous pouvons dire qu'Async est génial, EF6 est génial, mais les utilisations asynchrones de EF6 dans sa mise en œuvre actuelle ajoutent un surcoût important, du côté des performances, du côté des threads et du côté du processeur (12% ToList()
et 20% dans le cas ToListAsync
pour un travail de 8 à 10 fois plus long ... je le lance sur un ancien i7 920).
En faisant des tests, je pensais à cet article à nouvea et je remarque quelque chose qui me manque:
"Le comportement des nouvelles méthodes asynchrones dans .Net 4.5 est identique à celui des méthodes synchrones, à l'exception d'une exception notable: ReadAsync en mode non séquentiel."
Quoi ?!!!
J'étend donc mes repères pour inclure Ado.Net dans les appels normaux/async, 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 : il y a un bogue dans la mise en œuvre de EF 6. Il est conseillé de basculer le CommandBehavior
en SequentialAccess
lorsqu'un appel asynchrone est effectué sur une table contenant une colonne binary(max)
. Le problème de créer trop de tâches, ce qui ralentit le processus, se situe du côté Ado.Net. Le problème avec EF est qu’il n’utilise pas Ado.Net comme il se doit.
Maintenant que 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 un _TaskCompletionSource<T>
_ pour renvoyer le résultat de manière asynchrone.
Note 1: J'ai modifié mon message à cause d'une erreur honteuse .... J'ai fait mon premier test sur le réseau, pas localement, et la bande passante limitée a faussé 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 dans le cas ToList()
, c’est les 12% de CPU (1/8 de mes CPU = 1 cœur logique). Quelque chose d'inhabituel est le maximum de 20% pour le cas ToListAsync()
, comme si le planificateur ne pouvait pas utiliser toutes les bandes de roulement. 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 ...