Asíncrono / espera en C #: concepto, diseño interno, trucos útiles

Buen dia Esta vez, hablemos de un tema que todo adherente respetuoso del lenguaje C # comenzó a comprender: la programación asincrónica usando Task o, en la gente común, async / wait. Microsoft hizo un buen trabajo: para utilizar la asincronía en la mayoría de los casos, solo necesita conocer la sintaxis y ningún otro detalle. Pero si profundizas, el tema es bastante voluminoso y complejo. Fue declarado por muchos, cada uno en su propio estilo. Hay muchos artículos interesantes sobre este tema, pero todavía hay muchas ideas falsas al respecto. Intentaremos corregir la situación y masticar el material tanto como sea posible, sin sacrificar ni la profundidad ni la comprensión.



Temas / capítulos cubiertos:

  1. El concepto de asincronía : los beneficios de la asincronía y los mitos sobre un hilo "bloqueado"
  2. TAP. Condiciones de sintaxis y compilación : requisitos previos para escribir un método de compilación
  3. Trabaje con el uso de TAP : la mecánica y el comportamiento del programa en código asincrónico (liberando subprocesos, iniciando tareas y esperando que se completen)
  4. Detrás de escena: la máquina de estado : una descripción general de las transformaciones del compilador y las clases que genera
  5. Los orígenes de la asincronía. El dispositivo de métodos asincrónicos estándar: métodos asincrónicos para trabajar con archivos y la red desde el interior
  6. Las clases y trucos de TAP son trucos útiles que pueden ayudarlo a administrar y acelerar un programa usando TAP

Concepto asincrónico


La asincronía en sí misma está lejos de ser nueva. La asincronía generalmente implica realizar una operación en un estilo que no implica bloquear el subproceso de llamada, es decir, iniciar la operación sin esperar su finalización. El bloqueo no es tan malo como se describe. Uno puede encontrar afirmaciones de que los hilos bloqueados desperdician tiempo de CPU, funcionan más lentamente y causan lluvia. ¿Esto último parece poco probable? De hecho, los 2 puntos anteriores son iguales.

En el nivel del planificador del sistema operativo, cuando un subproceso está en un estado "bloqueado", no se le asignará un tiempo precioso de procesador. Las llamadas programadas, por regla general, corresponden a operaciones que provocan bloqueos, interrupciones del temporizador y otras interrupciones. Es decir, cuando, por ejemplo, el controlador de disco completa la operación de lectura e inicia una interrupción apropiada, se inicia el programador. Él decidirá si iniciar un hilo que fue bloqueado por esta operación, o algún otro con una prioridad más alta.

El trabajo lento parece aún más absurdo. De hecho, de hecho, el trabajo es uno y el mismo. Solo la operación asincrónica agregará un poco más de sobrecarga.

El desafío de la lluvia generalmente no es algo de esta área.

El principal problema de bloqueo es el consumo irrazonable de recursos informáticos. Incluso si nos olvidamos del tiempo para crear un subproceso y trabajar con un grupo de subprocesos, cada subproceso bloqueado consume espacio adicional. Bueno, hay escenarios en los que solo un subproceso puede realizar cierto trabajo (por ejemplo, un subproceso de interfaz de usuario). En consecuencia, no quisiera que él esté ocupado con una tarea que otro hilo puede realizar, sacrificando el desempeño de operaciones exclusivas para él.

La asincronía es un concepto muy amplio y se puede lograr de muchas maneras.
Lo siguiente se puede distinguir en la historia de .NET :

  1. EAP (Patrón asincrónico basado en eventos): como su nombre lo indica, la caminata se basa en eventos que se activan cuando se completa la operación y el método habitual que llama a esta operación
  2. APM (Modelo de programación asincrónica): basado en 2 métodos. El método BeginSmth devuelve la interfaz IAsyncResult. El método EndSmth acepta IAsyncResult (si la operación no se completa cuando se llama a EndSmth, el subproceso está bloqueado)
  3. TAP (Patrón asíncrono basado en tareas) es el mismo asíncrono / espera (estrictamente hablando, estas palabras aparecieron después del enfoque y aparecieron los tipos de Tarea y Tarea <Resultado>, pero asíncrono / espera mejoró significativamente este concepto)

El último enfoque fue tan exitoso que todos olvidaron con éxito los anteriores. Entonces, será sobre él.

Patrón asincrónico basado en tareas. Condiciones de sintaxis y compilación


El método asincrónico de estilo TAP estándar es muy fácil de escribir.

Para hacer esto, necesitas :

  1. Para que el valor de retorno sea Tarea, Tarea <T> o nulo (no recomendado, discutido más adelante). En C # 7 llegaron los tipos de tareas (discutidos en el último capítulo). En C # 8, IAsyncEnumerable <T> e IAsyncEnumerator <T> se agregan a esta lista.
  2. Para que el método esté marcado con la palabra clave asíncrona y contenga esperar dentro. Estas palabras clave están emparejadas. Además, si el método contiene esperar, asegúrese de marcarlo como asíncrono, lo contrario no es cierto, pero no tiene sentido
  3. Para la decencia, cumpla con la convención de sufijo asíncrono. Por supuesto, el compilador no considerará esto como un error. Si es un desarrollador muy decente, puede agregar sobrecargas con un CancellationToken (discutido en el último capítulo)

Para tales métodos, el compilador hace un trabajo serio. Y se vuelven completamente irreconocibles detrás de escena, pero más sobre eso más adelante.

Se mencionó que el método debe contener la palabra clave wait. (La palabra) indica la necesidad de espera asíncrona para que se realice la tarea, que es el objeto de tarea al que se aplica.

El objeto de tarea también tiene ciertas condiciones para que se pueda aplicar la espera:

  1. El tipo esperado debe tener un método público (o interno) GetAwaiter (), también puede ser un método de extensión. Este método devuelve un objeto de espera.
  2. El objeto de espera debe implementar la interfaz INotifyCompletion, que requiere la implementación del método vacío OnCompleted (continuación de acción). También debe tener la propiedad de instancia bool IsCompleted, el método vacío GetResult (). Puede ser una estructura o una clase.

El siguiente ejemplo muestra cómo hacer un int esperado, e incluso nunca ejecutado.

Extensión int
public class Program { public static async Task Main() { await 1; } } public static class WeirdExtensions { public static AnyTypeAwaiter GetAwaiter(this int number) => new AnyTypeAwaiter(); public class AnyTypeAwaiter : INotifyCompletion { public bool IsCompleted => false; public void OnCompleted(Action continuation) { } public void GetResult() { } } } 



Trabajar con TAP


Es difícil entrar a la jungla sin entender cómo algo debería funcionar. Considere TAP en términos de comportamiento del programa.

En terminología: el método asincrónico en cuestión, cuyo código será considerado, llamaré al método asincrónico , y a los métodos asincrónicos llamados dentro de él llamaré la operación asincrónica .

Tomemos el ejemplo más simple, como una operación asincrónica tomamos Task.Delay, que demora el tiempo especificado sin bloquear la transmisión.

 public static async Task DelayOperationAsync() //   { BeforeCall(); Task task = Task.Delay(1000); //  AfterCall(); await task; AfterAwait(); } 

La ejecución del método en términos de comportamiento es la siguiente.

  1. Se ejecuta todo el código que precede a la invocación de la operación asincrónica. En este caso, este es el método BeforeCall
  2. Una llamada de operación asincrónica está en progreso. En esta etapa, el hilo no se libera ni se bloquea. Esta operación devuelve el resultado: el objeto de tarea mencionado (generalmente Tarea), que se almacena en una variable local
  3. El código se ejecuta después de llamar a la operación asincrónica, pero antes de esperar (esperar). En el ejemplo: AfterCall
  4. Esperando la finalización del objeto de tarea (que se almacena en una variable local): espera tarea.

    Si la operación asincrónica se completa en este punto, la ejecución continúa sincrónicamente en el mismo hilo.

    Si la operación asincrónica no se completa, se guarda el código, que deberá llamarse al finalizar la operación asincrónica (la llamada continuación), y la secuencia vuelve al grupo de subprocesos y queda disponible para su uso.
  5. La ejecución de las operaciones después de esperar - AfterAwait - se realiza inmediatamente, en el mismo hilo, cuando se completó la operación en el momento de la espera, o, al finalizar la operación, se toma un nuevo hilo que continuará (guardado en el paso anterior)


Detrás de escena Máquina de estado


De hecho, el compilador transforma nuestro método en un método auxiliar en el que se inicializa la clase generada, la máquina de estado. Luego (la máquina) se inicia y el objeto Tarea utilizado en el paso 2 se devuelve del método.

De particular interés es el método MoveNext de la máquina de estados. Este método hace lo que era antes de la conversión en el método asincrónico. Rompe el código entre cada llamada en espera. Cada parte se realiza en una determinada condición de la máquina. El método MoveNext en sí mismo se adjunta al objeto de espera como una continuación. La preservación del estado garantiza la ejecución de precisamente esa parte del mismo que lógicamente siguió la expectativa.

Como dicen, es mejor ver 1 vez que escuchar 100 veces, por lo que le recomiendo que se familiarice con el siguiente ejemplo. Reescribí el código un poco, mejoré el nombre de las variables y comenté generosamente.

Código fuente
 public static async Task Delays() { Console.WriteLine(1); await Task.Delay(1000); Console.WriteLine(2); await Task.Delay(1000); Console.WriteLine(3); await Task.Delay(1000); Console.WriteLine(4); await Task.Delay(1000); Console.WriteLine(5); await Task.Delay(1000); } 


Método del trozo
 [AsyncStateMachine(typeof(DelaysStateMachine))] [DebuggerStepThrough] public Task Delays() { DelaysStateMachine stateMachine = new DelaysStateMachine(); stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); stateMachine.currentState = -1; AsyncTaskMethodBuilder builder = stateMachine.taskMethodBuilder; taskMethodBuilder.Start(ref stateMachine); return stateMachine.taskMethodBuilder.Task; } 


Máquina de estado
 [CompilerGenerated] private sealed class DelaysStateMachine : IAsyncStateMachine { //  ,     await   //       await'a public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; //   private TaskAwaiter taskAwaiter; //  ,             ""  public int paramInt; private int localInt; private void MoveNext() { int num = currentState; try { TaskAwaiter awaiter5; TaskAwaiter awaiter4; TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; //  await Console.WriteLine(1); //  await awaiter5 = Task.Delay(1000).GetAwaiter(); //  await if (!awaiter5.IsCompleted) //  await. ,    { num = (currentState = 0); // ,      taskAwaiter = awaiter5; //    ,        DelaysStateMachine stateMachine = this; //    taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter5, ref stateMachine); //                 return; } goto Il_AfterFirstAwait; //  ,   ,    case 0: //            ,        .   ,          awaiter5 = taskAwaiter; //   taskAwaiter = default(TaskAwaiter); //   num = (currentState = -1); //  goto Il_AfterFirstAwait; //       case 1: //  ,      ,    ,     . awaiter4 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterSecondAwait; case 2: // ,     . awaiter3 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterThirdAwait; case 3: //    awaiter2 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterFourthAwait; case 4: //    { awaiter = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); break; } Il_AfterFourthAwait: awaiter2.GetResult(); Console.WriteLine(5); //     awaiter = Task.Delay(1000).GetAwaiter(); //   if (!awaiter.IsCompleted) { num = (currentState = 4); taskAwaiter = awaiter; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; Il_AfterFirstAwait: //  ,        awaiter5.GetResult(); //       Console.WriteLine(2); //  ,     await awaiter4 = Task.Delay(1000).GetAwaiter(); //    if (!awaiter4.IsCompleted) { num = (currentState = 1); taskAwaiter = awaiter4; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter4, ref stateMachine); return; } goto Il_AfterSecondAwait; Il_AfterThirdAwait: awaiter3.GetResult(); Console.WriteLine(4); //     awaiter2 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter2.IsCompleted) { num = (currentState = 3); taskAwaiter = awaiter2; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } goto Il_AfterFourthAwait; Il_AfterSecondAwait: awaiter4.GetResult(); Console.WriteLine(3); //     awaiter3 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter3.IsCompleted) { num = (currentState = 2); taskAwaiter = awaiter3; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } goto Il_AfterThirdAwait; } awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); //    ,   ,       } void IAsyncStateMachine.MoveNext() {...} [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) {...} void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) {...} } 


Me concentro en la frase "en este momento no se ha ejecutado sincrónicamente". Una operación asincrónica también puede seguir una ruta de ejecución síncrona. La condición principal para que el método asincrónico actual se ejecute sincrónicamente, es decir, sin cambiar el subproceso, es la finalización de la operación asincrónica en el momento de la verificación IsCompleted .

Este ejemplo demuestra claramente este comportamiento.
 static async Task Main() { Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 Task task = Task.Delay(1000); Thread.Sleep(1700); await task; Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 } 


Sobre el contexto de sincronización. El método AwaitUnsafeOnCompleted utilizado en la máquina finalmente resulta en una llamada al método Task.SetContinuationForAwait . En este método, se recupera el contexto de sincronización actual SynchronizationContext.Current . El contexto de sincronización se puede interpretar como un tipo de secuencia. En caso de que esté allí y sea algo específico (por ejemplo, el contexto del hilo de la interfaz de usuario), la continuación se crea utilizando la clase SynchronizationContextAwaitTaskContinuation . Esta clase para iniciar la continuación llama al método Post en el contexto guardado, lo que garantiza que la continuación se ejecute en el contexto exacto donde se ejecutó el método. La lógica específica para ejecutar la continuación depende del método Post en un contexto que, por decirlo suavemente, no se conoce por su velocidad. Si no hubo contexto de sincronización (o se indicó que no nos importa en qué contexto la ejecución continuará utilizando ConfigureAwait (falso), que se discutirá en el último capítulo), la continuación se realizará por el hilo del grupo.

Los orígenes de la asincronía. Los métodos asíncronos estándar del dispositivo


Observamos cómo se ve un método que utiliza asíncrono y espera y qué sucede detrás de escena. Esta información no es infrecuente. Pero es importante comprender la naturaleza de las operaciones asincrónicas. Porque, como vimos en la máquina de estado, las operaciones asincrónicas se invocan en el código, a menos que su resultado se procese de manera más astuta. Sin embargo, ¿qué sucede dentro de las operaciones asincrónicas mismas? Probablemente lo mismo, pero esto no puede suceder hasta el infinito.

Una tarea importante es comprender la naturaleza de la asincronía. Cuando se trata de entender la asincronía, hay una alternancia de estados "ahora claro" y "ahora nuevamente incomprensible". Y esta alternancia será hasta que se entienda la fuente de asincronía.

Cuando trabajamos con asincronía, operamos en tareas. Esto no es lo mismo que una secuencia. Una tarea puede ser realizada por muchos hilos, y un hilo puede realizar muchas tareas.

La asincronía generalmente comienza con un método que devuelve Tarea (por ejemplo), pero no está etiquetado con asíncrono y, por lo tanto, no utiliza esperar dentro. Este método no tolera ningún cambio del compilador; se ejecuta tal cual.

Entonces, veamos algunas de las raíces de la asincronía.

  1. Task.Run, nueva tarea (..). Start (), Factory.StartNew y similares. La forma más fácil de comenzar la ejecución asincrónica. Estos métodos simplemente crean un nuevo objeto de tarea, pasando un delegado como uno de los parámetros. La tarea se transfiere al planificador, lo que permite que sea ejecutada por uno de los subprocesos del grupo. Se devuelve la tarea final que se puede esperar. Por lo general, este enfoque se usa para comenzar a computar (vinculado a la CPU) en un hilo separado.
  2. TaskCompletionSource. Una clase auxiliar que ayuda a controlar el objeto de la tarea. Diseñado para aquellos que no pueden asignar un delegado para la implementación y utiliza mecanismos más sofisticados para controlar la finalización. Tiene una API muy simple: SetResult, SetError, etc., que actualiza la tarea en consecuencia. Esta tarea está disponible a través de la propiedad Tarea. Quizás dentro de usted cree hilos, tenga una lógica compleja para su interacción o finalización por evento. Un poco más de detalles sobre esta clase estarán en la última sección.

En un párrafo adicional, puede hacer los métodos de las bibliotecas estándar. Estos incluyen leer / escribir archivos, trabajar con una red y similares. Como regla, tales métodos populares y comunes usan llamadas al sistema que varían en diferentes plataformas, y su dispositivo es extremadamente entretenido. Considere trabajar con archivos y la red.

Archivos


Una nota importante: si desea trabajar con archivos, debe especificar useAsync = true al crear FileStream.

Todo está organizado en archivos de manera no trivial y confusa. La clase FileStream se declara como parcial. Además, hay 6 complementos más específicos de la plataforma. Entonces, en Unix, el acceso asíncrono a un archivo arbitrario, como regla, usa una operación síncrona en un hilo separado. En Windows hay llamadas al sistema para la operación asincrónica, que, por supuesto, se utilizan. Esto conduce a diferencias en el trabajo en diferentes plataformas. Fuentes

Unix

El comportamiento estándar al escribir o leer es realizar la operación sincrónicamente, si el búfer lo permite y el flujo no está ocupado con otra operación:

1. Stream no está ocupado con otra operación

La clase Filestream tiene un objeto heredado de SemaphoreSlim con los parámetros (1, 1), es decir, una sección crítica, el fragmento de código protegido por este semáforo puede ser ejecutado por un solo hilo a la vez. Este semáforo se usa tanto para leer como para escribir. Es decir, es imposible producir simultáneamente lectura y escritura. En este caso, no se produce el bloqueo en el semáforo. Se llama al método this._asyncState.WaitAsync (), que devuelve el objeto de tarea (no hay bloqueo ni espera, sería si la palabra clave de espera se aplicara al resultado del método). Si el objeto de tarea dado no se completa, es decir, se captura el semáforo, entonces la continuación (Task.ContinueWith) en la que se realiza la operación se adjunta al objeto de espera devuelto. Si el objeto está libre, debe verificar lo siguiente

2. El buffer permite

Aquí el comportamiento ya depende de la naturaleza de la operación.

Para la grabación: se verifica que el tamaño de los datos para escribir + posición en el archivo es menor que el tamaño del búfer, que por defecto es 4096 bytes. Es decir, debemos escribir 4096 bytes desde el principio, 2048 bytes con un desplazamiento de 2048, y así sucesivamente. Si este es el caso, la operación se lleva a cabo sincrónicamente, de lo contrario se adjunta la continuación (Task.ContinueWith). La secuela utiliza una llamada regular al sistema sincrónico. Cuando el búfer está lleno, se escribe en el disco sincrónicamente.
Para leer: se verifica si hay suficientes datos en el búfer para devolver todos los datos necesarios. Si no, entonces, nuevamente, una continuación (Task.ContinueWith) con una llamada al sistema síncrono.

Por cierto, hay un detalle interesante. Si una pieza de datos ocupa todo el búfer, se escribirán directamente en el archivo, sin la participación del búfer. Al mismo tiempo, hay una situación en la que habrá más datos que el tamaño del búfer, pero todos pasarán por él. Esto sucede si ya hay algo en el búfer. Luego, nuestros datos se dividirán en 2 porciones, una llenará el búfer hasta el final y los datos se escribirán en el archivo, el segundo se escribirá en el búfer si entra en él o directamente en el archivo si no lo hace. Entonces, si creamos una secuencia y le escribimos 4097 bytes, aparecerán inmediatamente en el archivo, sin llamar a Dispose. Si escribimos 4095, entonces no habrá nada en el archivo.

Ventanas

En Windows, el algoritmo para usar el búfer y escribir directamente es muy similar. Pero se observa una diferencia significativa directamente en las llamadas de escritura y lectura del sistema asincrónico. Hablando sin profundizar en las llamadas al sistema, existe una estructura superpuesta. Tiene un campo importante para nosotros: HANDLE hEvent. Este es un evento de reinicio manual que entra en estado de alarma al finalizar una operación. Volver a la implementación. Al escribir directamente, así como al búfer, use llamadas asincrónicas del sistema, que usan la estructura anterior como parámetro. Al grabar, se crea un objeto FileStreamCompletionSource, un heredero de TaskCompletionSource, en el que se especifica IOCallback. Se llama por subproceso libre del grupo cuando finaliza la operación. En la devolución de llamada, la estructura Superpuesta se analiza y el objeto Tarea se actualiza en consecuencia. Eso es todo magia.

Red


Es difícil describir todo lo que vi entendiendo la fuente. Mi camino se extendía desde HttpClient a Socket y a SocketAsyncContext para Unix. El esquema general es el mismo que con los archivos. Para Windows, se utiliza la estructura superpuesta mencionada y la operación se realiza de forma asincrónica. En Unix, las operaciones de red también utilizan funciones de devolución de llamada.

Y una pequeña explicación. Un lector atento notará que cuando se usan llamadas asincrónicas entre una llamada y una devolución de llamada, hay un cierto vacío que de alguna manera funciona con los datos. Aquí vale la pena aclarar la integridad. En el ejemplo de los archivos, el controlador de disco realiza operaciones directas con el disco por el controlador de disco, es él quien da las señales sobre cómo mover las cabezas al sector deseado, etc. El procesador es gratuito en este momento. La comunicación con el disco se produce a través de los puertos de entrada / salida. Indican el tipo de operación, la ubicación de los datos en el disco, etc. A continuación, el controlador y el disco se dedican a esta operación y al finalizar el trabajo generan una interrupción. En consecuencia, una llamada asincrónica del sistema solo aporta información a los puertos de entrada / salida, mientras que la síncrona también espera los resultados, poniendo el flujo en un estado de bloqueo. Este esquema no pretende ser absolutamente preciso (no se trata de este artículo), pero ofrece una comprensión conceptual del trabajo.

Ahora la naturaleza del proceso es clara. Pero alguien puede preguntar, ¿qué hacer con la asincronía? Es imposible escribir asíncrono sobre un método para siempre.

Primero de todo Una solicitud puede hacerse como un servicio. En este caso, el punto de entrada - Principal - está escrito desde cero por usted. Hasta hace poco, Main no podía ser asíncrono; en la versión 7 del lenguaje, se agregó esta característica. Pero no cambia nada fundamentalmente, el compilador simplemente genera el Main habitual, y desde el asíncrono solo se realiza un método estático, que se llama en Main y se espera su finalización sincrónicamente. Entonces, lo más probable es que tengas algunas acciones de larga duración. Por alguna razón, en este momento, muchas personas comienzan a pensar en cómo crear subprocesos para este negocio: a través de Task, ThreadPool o Thread en general de forma manual, porque debería haber una diferencia en algo. La respuesta es simple, por supuesto, Tarea. Si usa el enfoque TAP, no interfiera con la creación manual de hilos. Esto es similar al uso de HttpClient para casi todas las solicitudes, y la POST se realiza de forma independiente a través de Socket.

En segundo lugar. Aplicaciones web. Cada solicitud entrante hace que se extraiga un nuevo hilo de ThreadPool para su procesamiento. La piscina, por supuesto, es grande, pero no infinita. En el caso de que haya muchas solicitudes, es posible que no haya suficientes subprocesos y todas las solicitudes nuevas se colocarán en cola para su procesamiento. Esta situación se llama inanición. Pero en el caso de usar controladores asíncronos, como se discutió anteriormente, la secuencia regresa al grupo y se puede usar para procesar nuevas solicitudes. Por lo tanto, el rendimiento del servidor aumenta significativamente.

Observamos el proceso asincrónico desde el principio hasta el final. Y armados con una comprensión de toda esta asincronía, que contradice la naturaleza humana, consideraremos algunos trucos útiles cuando trabajemos con código asincrónico.

Clases y trucos útiles al trabajar con TAP


La diversidad estática de la clase Task.


La clase Task tiene varios métodos estáticos útiles. Debajo están los principales.

  1. Task.WhenAny (..) es un combinador que toma IEnumerable / params de objetos de tarea y devuelve un objeto de tarea que se completará cuando se complete la primera tarea que se complete. Es decir, le permite esperar una de varias tareas en ejecución.
  2. Task.WhenAll (..) - combinador, acepta IEnumerable / params de objetos de tarea y devuelve un objeto de tarea, que se completará al completar todas las tareas transferidas
  3. Task.FromResult<T>(T value) — , .
  4. Task.Delay(..) —
  5. Task.Yield() — . , . , ,

ConfigureAwait


Naturalmente, la característica "avanzada" más popular. Este método pertenece a la clase Task y le permite especificar si necesitamos continuar en el mismo contexto donde se llamó a la operación asincrónica. Por defecto, sin usar este método, el contexto se recuerda y continúa usando el método Post mencionado. Sin embargo, como dijimos, Post es un placer muy costoso. Por lo tanto, si el rendimiento está en el primer lugar y vemos que la continuación, por ejemplo, no actualizará la interfaz de usuario, puede especificar .ConfigureAwait (falso) en el objeto en espera . Esto significa que no nos importa dónde se realizará la continuación.

Ahora sobre el problema. Como dicen, aterrador no es ignorancia, sino falso conocimiento.

De alguna manera, observé el código de una aplicación web, donde cada llamada asincrónica estaba decorada con este acelerador. Esto no tiene otro efecto que el asco visual. La aplicación web estándar ASP.NET Core no tiene contextos únicos (a menos que los escriba usted mismo, por supuesto). Por lo tanto, el método Post no se llama allí de todos modos.

TaskCompletionSource <T>


Una clase que facilita la administración de un objeto Task. Una clase tiene muchas oportunidades, pero es más útil cuando queremos envolver una tarea con una acción, cuyo final ocurre en un evento. En general, la clase se creó para adaptar viejos métodos asincrónicos a TAP, pero como hemos visto, se usa no solo para esto. Un pequeño ejemplo de trabajar con esta clase:

Ejemplo
 public static Task<string> GetSomeDataAsync() { TaskCompletionSource<string> tcs = new TaskCompletionSource<string>(); FileSystemWatcher watcher = new FileSystemWatcher { Path = Directory.GetCurrentDirectory(), NotifyFilter = NotifyFilters.LastAccess, EnableRaisingEvents = true }; watcher.Changed += (o, e) => tcs.SetResult(e.FullPath); return tcs.Task; } 


Esta clase crea un contenedor asíncrono para obtener el nombre del archivo al que se accedió en la carpeta actual.

CancellationTokenSource


Le permite cancelar una operación asincrónica. El esquema general se asemeja al uso de una TaskCompletionSource. Primero, se crea var cts = new CancellationTokenSource () , que, por cierto, es IDisposable, luego cts.Token se pasa a operaciones asincrónicas . Además, siguiendo alguna lógica suya, bajo ciertas condiciones, se llama al método cts.Cancel () . También puede suscribirse a un evento o cualquier otra cosa.

Usar un CancellationToken es una buena práctica. Al escribir su método asincrónico que hace un trabajo en segundo plano, digamos en un tiempo infinito, simplemente puede insertar una línea en el cuerpo del bucle: cancellationToken.ThrowIfCancellationRequested () , que arrojará una excepciónOperationCanceledException . Esta excepción se trata como una cancelación de la operación y no se guarda como una excepción dentro del objeto de tarea. Además, la propiedad IsCanceled en el objeto Task se convertirá en verdadera.

Longrunning


A menudo hay situaciones, especialmente cuando se escriben servicios, cuando se crean varias tareas que funcionarán durante toda la vida útil del servicio o solo durante mucho tiempo. Como recordamos, usar un grupo de subprocesos es justificadamente la sobrecarga de crear un subproceso. Sin embargo, si rara vez se crea una transmisión (incluso una vez por hora), estos costos se nivelan y puede crear transmisiones separadas de manera segura. Para ello, se crea una tarea, puede especificar una opción especial:

Task.Factory.StartNew (la acción, TaskCreationOptions.LongRunning )

Y, en general, me aconsejan que analiza toda la sobrecarga Task.Factory.StartNew , hay muchas maneras de configurar de forma flexible las necesidades específicas de la tarea.

Excepciones


Debido a la naturaleza no determinista de la ejecución de código asíncrono, la cuestión de las excepciones es muy relevante. Sería una pena si no pudieras atrapar la excepción y se arrojó en el hilo izquierdo, matando el proceso. Se creó una clase ExceptionDispatchInfo para capturar una excepción en un hilo y lanzarlo en él . Para detectar la excepción, se utiliza el método estático ExceptionDispatchInfo.Capture (ex), que devuelve ExceptionDispatchInfo.Se puede pasar un enlace a este objeto a cualquier hilo, que luego llama al método Throw () para tirarlo. El lanzamiento en sí NO ocurre en el lugar de la llamada de operación asincrónica, sino en el lugar de uso del operador en espera. Y como saben, esperar no se puede aplicar al vacío. Por lo tanto, si el contexto existiera, se lo pasará por el método Post. De lo contrario, se emocionará en la secuencia de la piscina. Y esto es casi 100% hola al colapso de la aplicación. Y aquí llegamos a la práctica del hecho de que deberíamos usar Task o Task <T>, pero no anular.

Y una cosa más. El planificador tiene un evento TaskScheduler.UnobservedTaskException que se dispara cuando se lanza una UnobservedTaskException. Esta excepción se produce durante la recolección de basura cuando el GC intenta recopilar un objeto de tarea que tiene una excepción no controlada.

IAsyncEnumerable


Antes de C # 8 y .NET Core 3.0, no era posible usar un iterador de rendimiento en un método asincrónico, lo que complicaba la vida y hacía que devolviera la Tarea <IEnumerable <T>> de este método, es decir no había forma de recorrer la colección hasta que se recibió por completo. Ahora hay tal oportunidad. Obtenga más información al respecto aquí . Para esto, el tipo de retorno debe ser IAsyncEnumerable <T> (o IAsyncEnumerator <T> ). Para atravesar dicha colección, debe usar el bucle foreach con la palabra clave wait. Además, los métodos WithCancellation y ConfigureAwait se pueden invocar en el resultado de la operación , indicando el CancelationToken utilizado y la necesidad de continuar en el mismo contexto.

Como se esperaba, todo se hace de la manera más perezosa posible.
A continuación se muestra un ejemplo y la conclusión que él da.

Ejemplo
 public class Program { public static async Task Main() { Stopwatch sw = new Stopwatch(); sw.Start(); IAsyncEnumerable<int> enumerable = AsyncYielding(); Console.WriteLine($"Time after calling: {sw.ElapsedMilliseconds}"); await foreach (var element in enumerable.WithCancellation(..).ConfigureAwait(false)) { Console.WriteLine($"element: {element}"); Console.WriteLine($"Time: {sw.ElapsedMilliseconds}"); } } static async IAsyncEnumerable<int> AsyncYielding() { foreach (var uselessElement in Enumerable.Range(1, 3)) { Task task = Task.Delay(TimeSpan.FromSeconds(uselessElement)); Console.WriteLine($"Task run: {uselessElement}"); await task; yield return uselessElement; } } } 


Conclusión:

Tiempo después de llamar: 0
Ejecución de tarea: 1
elemento: 1
Tiempo: 1033 Ejecución de
tarea: 2
elemento: 2
Tiempo: 3034 Ejecución de
tarea: 3
elemento: 3
Tiempo: 6035


Threadpool


Esta clase se usa activamente cuando se programa con TAP. Por lo tanto, daré los detalles mínimos de su implementación. En el interior, ThreadPool tiene una serie de colas: una para cada hilo + una global. Al agregar un nuevo trabajo al grupo, se tiene en cuenta el subproceso que inició la adición. En caso de que sea un subproceso del grupo, el trabajo se coloca en su propia cola de este subproceso, si era otro subproceso, en el global. Cuando se selecciona un subproceso para trabajar, primero se ve su cola local. Si está vacío, el hilo toma trabajos del global. Si está vacío, comienza a robar a los demás. Además, nunca debe confiar en el orden del trabajo, porque, de hecho, no hay orden. El número predeterminado de subprocesos en un grupo depende de muchos factores, incluido el tamaño del espacio de direcciones. Si hay más solicitudes de ejecución,que el número de subprocesos disponibles, las solicitudes están en cola.

Los subprocesos en un grupo de subprocesos son subprocesos en segundo plano (propiedad isBackground = true). Este tipo de subproceso no es compatible con la vida del proceso si todos los subprocesos en primer plano se han completado.

El subproceso del sistema supervisa el estado del controlador de espera. Cuando finaliza la operación de espera, la devolución de llamada transferida es ejecutada por el subproceso del grupo (recuerde los archivos en Windows).

Tipo de tarea


Mencionado anteriormente, este tipo (estructura o clase) puede usarse como el valor de retorno del método asincrónico. Un tipo de generador debe asociarse con este tipo utilizando el atributo [AsyncMethodBuilder (..)] . Este tipo debe tener las características mencionadas anteriormente para poder aplicarle la palabra clave de espera. Puede parametrizarse para métodos que no devuelven un valor y parametrizarse para aquellos que devuelven.

El constructor en sí es una clase o estructura cuyo marco se muestra en el siguiente ejemplo. El método SetResult tiene un parámetro de tipo T para un tipo de tarea parametrizada por T. Para los tipos no parametrizados, el método no tiene parámetros.

Interfaz de generador requerida
 class MyTaskMethodBuilder<T> { public static MyTaskMethodBuilder<T> Create(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine; public void SetStateMachine(IAsyncStateMachine stateMachine); public void SetException(Exception exception); public void SetResult(T result); public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine; public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine; public MyTask<T> Task { get; } } 


El principio del trabajo desde el punto de vista de escribir su tipo de tarea se describirá a continuación. La mayor parte de esto ya se ha descrito al analizar el código generado por el compilador.

El compilador utiliza todos estos tipos para generar una máquina de estados. El compilador sabe qué constructores usar para los tipos que conoce, aquí especificamos qué se usará durante la generación del código. Si la máquina de estado es una estructura, se empaquetará al llamar a SetStateMachine , el constructor puede almacenar en caché la copia empaquetada si es necesario. El constructor debe llamar a stateMachine.MoveNext en el método Start o después de que se llame para iniciar la ejecución y avanzar la máquina de estados. Después de llamar a Inicio, la propiedad Task se devolverá del método. Le recomiendo que regrese al método de código auxiliar y vea estos pasos.

Si la máquina de estado se completa con éxito, se llama al método SetResult ; de lo contrario, SetException . Si la máquina de estado llega a esperar, se ejecuta el método GetAwaiter () del tipo de tarea. Si el objeto wait implementa la interfaz ICriticalNotifyCompletion e IsCompleted = false, la máquina de estado usa builder.AwaitUnsafeOnCompleted (ref camarero, ref stateMachine) . El método AwaitUnsafeOnCompleted debería llamar a waititer.OnCompleted (action) , la acción debería llamar a stateMachine.MoveNextcuando se completa el objeto de espera. De manera similar para la interfaz INotifyCompletion y el método builder.AwaitOnCompleted .

Cómo usar esto depende de usted. Pero le aconsejo que piense 514 veces antes de aplicar esto en la producción, y no para mimarlo. A continuación se muestra un ejemplo de uso. Esbocé solo un proxy para un generador estándar que muestra en la consola qué método se llamó y a qué hora. Por cierto, el Main () asíncrono no quiere soportar un tipo de expectativa personalizada (creo que más de un proyecto de producción se corrompió irremediablemente debido a este error de Microsoft). Si lo desea, puede modificar el registrador proxy utilizando un registrador normal y registrando más información.

Tarea Proxy de registro
 public class Program { public static void Main() { Console.WriteLine("Start"); JustMethod().Task.Wait(); //   Console.WriteLine("Stop"); } public static async LogTask JustMethod() { await DelayWrapper(1000); } public static LogTask DelayWrapper(int milliseconds) => new LogTask { Task = Task.Delay(milliseconds)}; } [AsyncMethodBuilder(typeof(LogMethodBuilder))] public class LogTask { public Task Task { get; set; } public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); } public class LogMethodBuilder { private AsyncTaskMethodBuilder _methodBuilder = AsyncTaskMethodBuilder.Create(); private LogTask _task; public static LogMethodBuilder Create() { Console.WriteLine($"Method: Create; {DateTime.Now :O}"); return new LogMethodBuilder(); } public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: Start; {DateTime.Now :O}"); _methodBuilder.Start(ref stateMachine); } public void SetStateMachine(IAsyncStateMachine stateMachine) { Console.WriteLine($"Method: SetStateMachine; {DateTime.Now :O}"); _methodBuilder.SetStateMachine(stateMachine); } public void SetException(Exception exception) { Console.WriteLine($"Method: SetException; {DateTime.Now :O}"); _methodBuilder.SetException(exception); } public void SetResult() { Console.WriteLine($"Method: SetResult; {DateTime.Now :O}"); _methodBuilder.SetResult(); } public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine); } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitUnsafeOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); } public LogTask Task { get { Console.WriteLine($"Property: Task; {DateTime.Now :O}"); return _task ??= new LogTask {Task = _methodBuilder.Task}; } set => _task = value; } } 


Conclusión: Método de

inicio
: Crear; 2019-10-09T17: 55: 13.7152733 + 03: 00
Método: Inicio; 2019-10-09T17: 55: 13.7262226 + 03: 00
Método: AwaitUnsafeOnCompleted; 2019-10-09T17: 55: 13.7275206 + 03: 00
Propiedad: Tarea; 2019-10-09T17: 55: 13.7292005 + 03: 00
Método: SetResult; 2019-10-09T17: 55: 14.7297967 + 03: 00
Stop


Eso es todo, gracias a todos.

Source: https://habr.com/ru/post/470830/


All Articles