c# - method - Desempeño horrible usando métodos Async SqlCommand con datos grandes
fake async c# (1)
Tengo problemas importantes de rendimiento de SQL cuando uso llamadas asíncronas. He creado un pequeño caso para demostrar el problema.
He creado una base de datos en un SQL Server 2016 que reside en nuestra LAN (por lo que no es un localDB).
En esa base de datos, tengo una tabla
WorkingCopy
con 2 columnas:
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]
En esa tabla, he insertado un solo registro (
id
= ''PerfUnitTest'',
Value
es una cadena de 1.5mb (un zip de un conjunto de datos JSON más grande)).
Ahora, si ejecuto la consulta en SSMS:
SELECT [Value]
FROM [Workingcopy]
WHERE id = ''perfunittest''
Inmediatamente obtengo el resultado, y veo en SQL Servre Profiler que el tiempo de ejecución fue de alrededor de 20 milisegundos. Todo normal.
Al ejecutar la consulta desde el código .NET (4.6) usando una
SqlConnection
simple:
// 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;
El tiempo de ejecución para esto también es de alrededor de 20-30 milisegundos.
Pero al cambiarlo a código asíncrono:
string value = await command.ExecuteScalarAsync() as string;
¡El tiempo de ejecución es de repente 1800 ms ! También en SQL Server Profiler, veo que la duración de la ejecución de la consulta es más de un segundo. Aunque la consulta ejecutada informada por el generador de perfiles es exactamente la misma que la versión no asíncrona.
Pero se pone peor. Si juego con el tamaño del paquete en la cadena de conexión, obtengo los siguientes resultados:
Tamaño de paquete 32768: [TIMING]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 450 ms
Tamaño de paquete 4096: [TIEMPO]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 3667 ms
Tamaño de paquete 512: [TIEMPO]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 30776 ms
30,000 ms !! Eso es más de 1000 veces más lento que la versión no asíncrona. Y SQL Server Profiler informa que la ejecución de la consulta tomó más de 10 segundos. ¡Eso ni siquiera explica a dónde se han ido los otros 20 segundos!
Luego volví a la versión de sincronización y también jugué con el Tamaño de paquete, y aunque impactó un poco el tiempo de ejecución, no fue tan dramático como con la versión asíncrona.
Como nota al margen, si coloca solo una pequeña cadena (<100bytes) en el valor, la ejecución de la consulta asíncrona es tan rápida como la versión de sincronización (el resultado es 1 o 2 ms).
Estoy realmente desconcertado por esto, especialmente porque estoy usando el
SqlConnection
incorporado, ni siquiera un ORM.
Además, al buscar, no encontré nada que pudiera explicar este comportamiento.
¿Algunas ideas?
En un sistema sin carga significativa, una llamada asincrónica tiene una sobrecarga ligeramente mayor. Si bien la operación de E / S en sí misma es asíncrona, el bloqueo puede ser más rápido que el cambio de tareas del grupo de subprocesos.
¿Cuánto gasto general? Veamos tus números de tiempo. 30 ms para una llamada de bloqueo, 450 ms para una llamada asincrónica. El tamaño de paquete de 32 kiB significa que necesita unas cincuenta operaciones de E / S individuales. Eso significa que tenemos aproximadamente 8 ms de sobrecarga en cada paquete, lo que corresponde bastante bien con sus mediciones en diferentes tamaños de paquetes. Eso no suena como una sobrecarga simplemente por ser asíncrono, a pesar de que las versiones asíncronas necesitan hacer mucho más trabajo que las síncronas. Parece que la versión síncrona es (simplificada) 1 solicitud -> 50 respuestas, mientras que la versión asíncrona termina siendo 1 solicitud -> 1 respuesta -> 1 solicitud -> 1 respuesta -> ..., pagando el costo una y otra vez otra vez.
Yendo más profundo
ExecuteReader
funciona tan bien como
ExecuteReaderAsync
.
La siguiente operación es
Read
seguida de un
GetFieldValue
, y allí sucede algo interesante.
Si alguno de los dos es asíncrono, toda la operación es lenta.
Entonces, ciertamente sucede algo
muy
diferente una vez que comienzas a hacer las cosas realmente asincrónicas: una
Read
será rápida, y luego el
GetFieldValueAsync
asíncrono será lento, o puedes comenzar con el
ReadAsync
lento, y luego tanto
GetFieldValue
como
GetFieldValueAsync
son rápidos.
La primera lectura asíncrona de la secuencia es lenta, y la lentitud depende completamente del tamaño de toda la fila.
Si agrego más filas del mismo tamaño, leer cada fila lleva la misma cantidad de tiempo que si solo tuviera una fila, por lo que es obvio que los datos todavía se transmiten fila por fila; simplemente parece preferir leer el conjunto fila a la vez una vez que comience
cualquier
lectura asincrónica.
Si leo la primera fila de forma asincrónica, y la segunda sincrónicamente, la segunda fila que se está leyendo volverá a ser rápida.
Entonces podemos ver que el problema es un gran tamaño de una fila y / o columna individual.
No importa cuántos datos tenga en total: leer un millón de pequeñas filas de forma asincrónica es tan rápido como sincrónicamente.
Pero agregue solo un campo que sea demasiado grande para caber en un solo paquete, y misteriosamente incurrirá en un costo al leer asincrónicamente esos datos, como si cada paquete necesitara un paquete de solicitud separado, y el servidor no pudiera simplemente enviar todos los datos a una vez.
El uso de
CommandBehavior.SequentialAccess
mejora el rendimiento como se esperaba, pero aún existe la brecha masiva entre la sincronización y la sincronización.
El mejor rendimiento que obtuve fue cuando hice todo correctamente.
Eso significa usar
CommandBehavior.SequentialAccess
, así como transmitir los datos explícitamente:
using (var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
{
while (await reader.ReadAsync())
{
var data = await reader.GetTextReader(0).ReadToEndAsync();
}
}
Con esto, la diferencia entre sincronización y asíncrono se vuelve difícil de medir, y cambiar el tamaño del paquete ya no incurre en la sobrecarga ridícula como antes.
Si desea un buen rendimiento en casos extremos, asegúrese de utilizar las mejores herramientas disponibles; en este caso, transmita datos de columnas grandes en lugar de confiar en ayudantes como
ExecuteScalar
o
GetFieldValue
.