101 votes

Horrible performance lors de l'utilisation de méthodes SqlCommand Async

Je vais avoir des grands SQL problèmes de performances lors de l'utilisation d'appels asynchrones. J'ai créé un petit cas pour illustrer le problème.

J'ai créer une base de données sur un Serveur SQL server 2016 qui réside dans notre réseau local (donc pas d'un localDB).

Dans cette base de données, j'ai une table WorkingCopy avec 2 colonnes:

Id (nvarchar(255, PK))
Value (nvarchar(max))

DDL

CREATE TABLE [dbo].[Workingcopy]
(
    [Id] [nvarchar](255) NOT NULL, 
    [Value] [nvarchar](max) NULL, 

    CONSTRAINT [PK_Workingcopy] 
        PRIMARY KEY CLUSTERED ([Id] ASC)
                    WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
                          IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
                          ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

Dans ce tableau, j'ai inséré un seul enregistrement (id='PerfUnitTest', Value est de 1,5 mo chaîne de caractères (un zip d'un plus grand JSON dataset)).

Maintenant, si j'exécute la requête dans SSMS :

SELECT [Value] 
FROM [Workingcopy] 
WHERE id = 'perfunittest'

J'ai immédiatement obtenir le résultat, et je vois dans SQL Servre Profiler que le temps d'exécution était de près de 20 millisecondes. Tout à fait normal.

Lors de l'exécution de la requête .NET (4.6) code à l'aide d'un simple SqlConnection :

// at this point, the connection is already open
var command = new SqlCommand($"SELECT Value FROM WorkingCopy WHERE Id = @Id", _connection);
command.Parameters.Add("@Id", SqlDbType.NVarChar, 255).Value = key;

string value = command.ExecuteScalar() as string;

Le temps d'exécution de cela, c'est aussi autour de 20 à 30 millisecondes.

Mais quand le changement de async code :

string value = await command.ExecuteScalarAsync() as string;

Le temps d'exécution est soudainement 1800 ms ! Également dans SQL Server Profiler, je vois que l'exécution de la requête durée est de plus d'une seconde. Bien que la requête exécutée rapporté par le profileur est exactement le même que le non-Async version.

Mais il y a pire. Si je jouer avec la Taille des Paquets dans la chaîne de connexion, j'obtiens les résultats suivants :

Taille de paquet 32768 : [CALENDRIER]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé : 450 ms

Taille de paquet 4096 : [CALENDRIER]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé : 3667 ms

La taille des paquets de 512 : [CALENDRIER]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé : 30776 ms

De 30 000 ms!! C'est au cours d'une 1000x plus lent que le non-async version. Et SQL Server Profiler les rapports que l'exécution de la requête a pris plus de 10 secondes. Qui n'a même pas expliquer d'où les 20 secondes sont partis!

Puis j'ai changé de revenir à la version synchronisée et aussi joué un peu avec la Taille du Paquet, et bien qu'il n'ait d'impact un peu le temps d'exécution, il n'était pas aussi dramatique qu'avec la version asynchrone.

Au passage, si il mets juste un petit string (< 100bytes) dans la valeur, la async l'exécution de la requête est tout aussi rapide que la version synchronisée (résultat dans 1 ou 2 ms).

Je suis vraiment confus par cela, surtout depuis que je suis en utilisant le haut- SqlConnection, même pas un ORM. Aussi lors de la recherche autour, je n'ai rien trouvé qui pourrait expliquer ce comportement. Des idées?

150voto

Luaan Points 8934

Sur un système sans une charge importante, un appel asynchrone a un peu plus de frais généraux. Alors que l'opération d'e/S en lui-même est asynchrone, peu importe, le blocage peut être plus rapide que le thread du pool de changement de tâche.

Combien dessus? Passons à votre calendrier numéros. 30ms pour un appel bloquant, 450 ms pour un appel asynchrone. 32 kio taille du paquet signifie que vous avez besoin vous aurez besoin d'environ cinquante individu I/O opérations. Cela signifie que nous avons à peu près 8ms de surcharge sur chaque paquet, ce qui correspond assez bien avec vos mesures sur différentes tailles de paquets. Cela ne ressemble pas à la surcharge du asynchrone, même si les versions asynchrones besoin de faire beaucoup plus de travail que la machine synchrone. Il semble que la version synchrone est (simplifié) 1 request -> 50 réponses, tandis que la version asynchrone finit par être 1 request -> 1 réponse -> 1 request -> 1 réponse -> ..., payer plus et plus de nouveau.

D'aller plus loin. ExecuteReader fonctionne tout aussi bien que ExecuteReaderAsync. L'opération suivante est - Read suivie par un GetFieldValue - et une chose intéressante qui s'y passe. Si l'un des deux est asynchrone, l'ensemble de l'opération est lente. Donc, il y a certainement quelque chose de très différent qui se passe une fois que vous commencer à faire des choses vraiment asynchrone - Read sera rapide, et puis la async GetFieldValueAsync sera lente, ou vous pouvez commencer avec la lenteur ReadAsync, puis les deux GetFieldValue et GetFieldValueAsync sont rapides. La première lecture asynchrone à partir du flux est lente, et la lenteur dépend entièrement de la taille de l'ensemble de la ligne. Si j'ajoute d'autres lignes de la même taille, à la lecture de chaque ligne prend la même quantité de temps que si j'ai une seule ligne, il est donc évident que les données est toujours en cours de streaming, ligne par ligne, - il semble juste préfèrent lire l'ensemble de la ligne à la fois, une fois que vous commencez toute de lecture asynchrone. Si j'ai lu la première ligne de manière asynchrone, et la seconde de façon synchrone - la deuxième rangée en cours de lecture sera rapide à nouveau.

Ainsi, nous pouvons voir que le problème est d'une grande taille d'une ligne et/ou colonne. Il n'a pas d'importance combien de données vous disposez au total de lecture d'un million de petites lignes de manière asynchrone est tout aussi rapide que de façon synchrone. Mais ajouter un seul champ qui est trop grand pour tenir dans un seul paquet, et vous mystérieusement encourir un coût à la lecture asynchrone de données - comme si chaque paquet a besoin d'une demande distincte de paquets, et le serveur ne pouvait pas juste envoyer toutes les données à la fois. À l'aide de CommandBehaviour.SequentialAccess permet d'améliorer la performance comme prévu, mais l'énorme fossé entre la synchronisation et async existe toujours.

La meilleure performance que j'ai eu était quand faire tout cela correctement. Cela signifie que l'aide CommandBehaviour.SequentialAccess, ainsi que la diffusion des données de façon explicite:

using (var reader = await cmd.ExecuteReaderAsync(CommandBehaviour.SequentialAccess))
{
  while (await reader.ReadAsync())
  {
    var data = await reader.GetTextReader(0).ReadToEndAsync();
  }
}

Avec cela, la différence entre la synchronisation et asynchrone devient difficile à mesurer, et la modification de la taille de paquet n'est plus engage le ridicule surcharge comme avant.

Si vous voulez une bonne performance dans les cas limites, assurez-vous d'utiliser les meilleurs outils disponibles - dans ce cas, volumineuses de données de la colonne plutôt que de compter sur les aides comme ExecuteScalar ou GetFieldValue.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X