Encontré esta pregunta muy interesante, especialmente porque la estoy usando en async
todas partes con Ado.Net y EF 6. Esperaba que alguien me diera una explicación, pero no sucedió. Así que intenté reproducir este problema de mi lado. Espero que algunos de ustedes lo encuentren interesante.
Primeras buenas noticias: lo reproduje :) Y la diferencia es enorme. Con un factor 8 ...
Primero sospechaba algo relacionado con esto CommandBehavior
, ya que leí un artículo interesante sobre async
Ado y decía esto:
"Dado que el modo de acceso no secuencial tiene que almacenar los datos para toda la fila, puede causar problemas si está leyendo una columna grande del servidor (como varbinary (MAX), varchar (MAX), nvarchar (MAX) o XML ) ".
Sospechaba que las ToList()
llamadas eran CommandBehavior.SequentialAccess
y las asíncronas eran CommandBehavior.Default
(no secuenciales, lo que puede causar problemas). Así que descargué las fuentes de EF6 y puse puntos de interrupción en todas partes ( CommandBehavior
donde se usó, por supuesto).
Resultado: nada . Todas las llamadas se hacen con CommandBehavior.Default
... Así que traté de entrar en el código EF para entender lo que sucede ... y ... ooouch ... Nunca veo un código tan delegante, todo parece perezoso ejecutado ...
Así que traté de hacer algunos perfiles para entender lo que sucede ...
Y creo que tengo algo ...
Aquí está el modelo para crear la tabla que comparé, con 3500 líneas dentro de ella y datos aleatorios de 256 Kb en cada uno 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; }
}
Y aquí está el código que usé para crear los datos de prueba, y el punto de referencia 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);
}
}
Para la llamada EF normal ( .ToList()
), el perfil parece "normal" y es fácil de leer:
Aquí encontramos los 8.4 segundos que tenemos con el cronómetro (el perfil ralentiza los resultados). También encontramos HitCount = 3500 a lo largo de la ruta de llamada, que es consistente con las 3500 líneas en la prueba. En el lado del analizador TDS, las cosas comienzan a empeorar ya que leemos 118 353 llamadas en el TryReadByteArray()
método, que es donde ocurre el bucle de almacenamiento en búfer. (un promedio de 33.8 llamadas por cada byte[]
256kb)
Para el async
caso, es realmente muy diferente ... Primero, la .ToListAsync()
llamada se programa en ThreadPool y luego se espera. Nada asombroso aquí. Pero, ahora, aquí está el async
infierno en ThreadPool:
Primero, en el primer caso teníamos solo 3500 recuentos de visitas a lo largo de la ruta de llamada completa, aquí tenemos 118 371. Además, debes imaginar todas las llamadas de sincronización que no puse en la captura de pantalla ...
En segundo lugar, en el primer caso, teníamos "solo 118 353" llamadas al TryReadByteArray()
método, ¡aquí tenemos 2 050 210 llamadas! Es 17 veces más ... (en una prueba con una gran matriz de 1Mb, es 160 veces más)
Además hay:
- 120 000
Task
instancias creadas
- 727519
Interlocked
llamadas
- 290 569
Monitor
llamadas
- 98 283
ExecutionContext
instancias, con 264 481 capturas
- 208 733
SpinLock
llamadas
Supongo que el almacenamiento en búfer se realiza de forma asíncrona (y no es buena), con Tareas paralelas que intentan leer datos del TDS. Se crean demasiadas tareas solo para analizar los datos binarios.
Como conclusión preliminar, podemos decir que Async es excelente, EF6 es excelente, pero los usos de EF6 de asíncrona en su implementación actual agrega una sobrecarga importante, en el lado del rendimiento, el lado de subprocesos y el lado de la CPU (12% de uso de CPU en el ToList()
caso y 20% en el ToListAsync
caso para un trabajo de 8 a 10 veces más largo ... lo ejecuto en un viejo i7 920).
Mientras hacía algunas pruebas, estaba pensando en este artículo nuevamente y noto algo que extraño:
"Para los nuevos métodos asincrónicos en .Net 4.5, su comportamiento es exactamente el mismo que con los métodos síncronos, excepto por una notable excepción: ReadAsync en modo no secuencial".
Qué ?!!!
Así que extiendo mis puntos de referencia para incluir Ado.Net en llamadas regulares / asíncronas, y con CommandBehavior.SequentialAccess
/ CommandBehavior.Default
, ¡y aquí hay una gran sorpresa! :
Tenemos exactamente el mismo comportamiento con Ado.Net !!! Facepalm ...
Mi conclusión definitiva es : hay un error en la implementación de EF 6. Debe alternar CommandBehavior
a SequentialAccess
cuando se realiza una llamada asincrónica sobre una tabla que contiene una binary(max)
columna. El problema de crear demasiadas Tareas, ralentizar el proceso, está en el lado de Ado.Net. El problema de EF es que no usa Ado.Net como debería.
Ahora sabe que en lugar de utilizar los métodos asíncronos EF6, será mejor que llame a EF de una manera no asíncrona regular y luego use a TaskCompletionSource<T>
para devolver el resultado de forma asíncrona.
Nota 1: edité mi publicación debido a un error vergonzoso ... Hice mi primera prueba en la red, no localmente, y el ancho de banda limitado ha distorsionado los resultados. Aquí están los resultados actualizados.
Nota 2: No extendí mi prueba a otros casos de uso (por ejemplo, nvarchar(max)
con muchos datos), pero hay posibilidades de que ocurra el mismo comportamiento.
Nota 3: Algo habitual para el ToList()
caso, es el 12% de CPU (1/8 de mi CPU = 1 núcleo lógico). Algo inusual es el máximo del 20% para el ToListAsync()
caso, como si el Programador no pudiera usar todas las pisadas. Probablemente se deba a la demasiada Tarea creada, o tal vez a un cuello de botella en el analizador TDS, no lo sé ...