listas c# ado.net async-await generator yield-return

foreach en listas c#



¿Cómo puedo hacer que `espere...` funcione con `rendimiento?`(Es decir, dentro de un método de iterador)? (3)

Tengo un código existente que se parece a:

IEnumerable<SomeClass> GetStuff() { using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn) { conn.Open(); SqlDataReader reader = cmd.ExecuteReader(); while (reader.Read()) { SomeClass someClass = f(reader); // create instance based on returned row yield return someClass; } } }

Parece que podría beneficiarme utilizando reader.ReadAsync() . Sin embargo, si solo modifico una línea:

while (await reader.ReadAsync())

el compilador me informa que await solo se puede usar en métodos marcados con async , y sugiere que modifique la firma del método para que sea:

async Task<IEnumerable<SomeClass>> GetStuff()

Sin embargo, hacer eso hace que GetStuff() inutilizable porque:

El cuerpo de GetStuff() no puede ser un bloque de iterador porque la Task<IEnumerable<SomeClass>> no es un tipo de interfaz de iterador.

Estoy seguro de que me estoy perdiendo un concepto clave con el modelo de programación asíncrono.

Preguntas:

  • ¿Puedo usar ReadAsync() en mi iterador? ¿Cómo?
  • ¿Cómo puedo pensar en el paradigma asíncrono de manera diferente para entender cómo funciona en este tipo de situación?

El problema es que lo que estás preguntando no tiene mucho sentido. IEnumerable<T> es una interfaz síncrona, y devolver la Task<IEnumerable<T>> no te va a ayudar mucho, porque algún hilo tendría que bloquear la espera de cada elemento, sin importar qué.

Lo que realmente desea devolver es una alternativa asíncrona a IEnumerable<T> : algo como IObservable<T> , bloque de flujo de datos de TPL Dataflow o IAsyncEnumerable<T> , que se planea agregar a C # 8.0 / .Net Core 3.0. (Y mientras tanto, hay some libraries que lo contienen).

Usando TPL Dataflow, una manera de hacer esto sería:

ISourceBlock<SomeClass> GetStuff() { var block = new BufferBlock<SomeClass>(); Task.Run(async () => { using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { await conn.OpenAsync(); SqlDataReader reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { SomeClass someClass; // Create an instance of SomeClass based on row returned. block.Post(someClass); } block.Complete(); } }); return block; }

Probablemente querrá agregar el manejo de errores al código anterior, pero de lo contrario, debería funcionar y será completamente asíncrono.

El resto de su código luego consumiría elementos del bloque devuelto también de forma asíncrona, probablemente utilizando ActionBlock .


Hablando estrictamente para asíncrono del iterador (o existe la posibilidad) dentro del contexto de un SqlCommand en mi experiencia, he notado que la versión síncrona del código supera ampliamente a su contraparte async . Tanto en velocidad como en consumo de memoria.

Tal vez, tome esta observación con un poco de sal, ya que el alcance de las pruebas se limitó a mi máquina y a la instancia local de SQL Server.

No me malinterpretes, el paradigma async/await dentro del entorno .NET es extraordinariamente simple, poderoso y útil dadas las circunstancias correctas. Sin embargo, después de mucho esfuerzo, no estoy convencido de que el acceso a la base de datos sea un caso de uso adecuado para ello. Por supuesto, a menos que necesite ejecutar varios comandos simultáneamente, en cuyo caso simplemente puede usar TPL para ejecutar los comandos al unísono.

Mi enfoque preferido es tomar las siguientes consideraciones:

  • Mantenga las unidades de trabajo de SQL pequeñas, simples y compilables (es decir, haga que sus ejecuciones de SQL sean "baratas").
  • Evite realizar trabajos en el servidor SQL que se puedan enviar al nivel de la aplicación. Un ejemplo perfecto de esto es la clasificación.
  • Lo más importante es probar su código SQL a escala y revisar el plan de salida / ejecución de Statistics IO. Una consulta que se ejecuta rápidamente en un registro de 10k, puede (y probablemente lo hará) comportarse de manera totalmente diferente cuando hay un registro de 1M.

Podría argumentar que en ciertos escenarios de informes, algunos de los requisitos anteriores simplemente no son posibles. Sin embargo, en el contexto de los servicios de informes, ¿es realmente necesaria la asincronía (es una palabra?)

Hay un article fantástico del evangelista de Microsoft Rick Anderson sobre este tema. Ten en cuenta que es viejo (desde 2009) pero sigue siendo muy relevante.


No, actualmente no puede usar async con un bloque iterador. Como dice svick, necesitarías algo como IAsyncEnumerable para hacer eso.

Si tiene el valor de retorno Task<IEnumerable<SomeClass>> significa que la función devuelve un solo objeto Task que, una vez completado, le proporcionará un IEnumerable completamente formado (no hay espacio para la asincronía de Task en este enumerable). Una vez que se completa el objeto de la tarea, la persona que llama debe poder recorrer de forma sincrónica todos los elementos que devolvió en el enumerable.

Aquí hay una solución que devuelve la Task<IEnumerable<SomeClass>> . Podría obtener una gran parte del beneficio de async haciendo algo como esto:

async Task<IEnumerable<SomeClass>> GetStuff() { using (SqlConnection conn = new SqlConnection("")) { using (SqlCommand cmd = new SqlCommand("", conn)) { await conn.OpenAsync(); SqlDataReader reader = await cmd.ExecuteReaderAsync(); return ReadItems(reader).ToArray(); } } } IEnumerable<SomeClass> ReadItems(SqlDataReader reader) { while (reader.Read()) { // Create an instance of SomeClass based on row returned. SomeClass someClass = null; yield return someClass; } }

... y un ejemplo de uso:

async void Caller() { // Calls get-stuff, which returns immediately with a Task Task<IEnumerable<SomeClass>> itemsAsync = GetStuff(); // Wait for the task to complete so we can get the items IEnumerable<SomeClass> items = await itemsAsync; // Iterate synchronously through the items which are all already present foreach (SomeClass item in items) { Console.WriteLine(item); } }

Aquí tiene la parte del iterador y la parte asíncrona en funciones separadas que le permite utilizar la sintaxis asíncrona y de rendimiento. La función GetStuff adquiere los datos de forma asíncrona y, a continuación, ReadItems lee los datos de forma síncrona en forma de enumerable.

Tenga en cuenta la llamada ToArray() . Algo así es necesario porque la función del enumerador se ejecuta de forma perezosa y, por lo tanto, su función asíncrona puede disponer la conexión y el comando antes de que se lean todos los datos. Esto se debe a que los bloques que using cubren la duración de la ejecución de la Task , pero la iteraría una after la tarea se haya completado.

Esta solución no usa ReadAsync , pero usa OpenAsync y ExecuteReaderAsync , lo que probablemente le brinda la mayor parte del beneficio. En mi experiencia, es el ExecuteReader el que llevará más tiempo y tendrá el mayor beneficio al ser asíncrono. Cuando he leído la primera fila, el SqlDataReader tiene todas las demás filas y ReadAsync simplemente regresa de forma sincrónica. Si este también es su caso, entonces no obtendrá un beneficio significativo al cambiar a un sistema basado en push como IObservable<T> (que requerirá modificaciones significativas en la función de llamada).

Para ilustrar, considere un enfoque alternativo al mismo tema:

IEnumerable<Task<SomeClass>> GetStuff() { using (SqlConnection conn = new SqlConnection("")) { using (SqlCommand cmd = new SqlCommand("", conn)) { conn.Open(); SqlDataReader reader = cmd.ExecuteReader(); while (true) yield return ReadItem(reader); } } } async Task<SomeClass> ReadItem(SqlDataReader reader) { if (await reader.ReadAsync()) { // Create an instance of SomeClass based on row returned. SomeClass someClass = null; return someClass; } else return null; // Mark end of sequence }

... y un ejemplo de uso:

async void Caller() { // Synchronously get a list of Tasks IEnumerable<Task<SomeClass>> items = GetStuff(); // Iterate through the Tasks foreach (Task<SomeClass> itemAsync in items) { // Wait for the task to complete. We need to wait for // it to complete before we can know if it''s the end of // the sequence SomeClass item = await itemAsync; // End of sequence? if (item == null) break; Console.WriteLine(item); } }

En este caso, GetStuff regresa inmediatamente con un enumerable, donde cada elemento del enumerable es una tarea que presentará un objeto SomeClass cuando se complete. Este enfoque tiene algunas fallas. En primer lugar, el número se devuelve de manera sincrónica, por lo que en el momento en que se devuelve no sabemos cuántas filas hay en el resultado, por lo que hice una secuencia infinita. Esto es perfectamente legal pero tiene algunos efectos secundarios. Necesitaba usar null para señalar el final de los datos útiles en la secuencia infinita de tareas. En segundo lugar, tienes que tener cuidado de cómo iterarlo. Debe iterarlo hacia delante y esperar cada fila antes de pasar a la siguiente. También debe desechar el iterador solo después de que se hayan completado todas las tareas para que el GC no recopile la conexión antes de que se termine de usar. Por estas razones, esta no es una solución segura, y debo enfatizar que la incluyo como ilustración para ayudar a responder su segunda pregunta.