.NET: Herramientas para trabajar con subprocesos múltiples y asincronía - Parte 1

Originalmente publiqué este artículo en el blog CodingSight
La segunda parte del artículo está disponible aquí.

La necesidad de hacer las cosas de forma asincrónica, es decir, dividir grandes tareas entre múltiples unidades de trabajo, estaba presente mucho antes de la aparición de las computadoras. Sin embargo, cuando aparecieron, esta necesidad se hizo aún más obvia. Ahora es 2019, y estoy escribiendo este artículo en una computadora portátil alimentada por una CPU Intel Core de 8 núcleos que, además de esto, está trabajando simultáneamente en cientos de procesos, con un número de subprocesos aún mayor. A mi lado, hay un teléfono inteligente un poco anticuado que compré hace un par de años, y también alberga un procesador de 8 núcleos. Los recursos web especializados contienen una amplia variedad de artículos que alaban los teléfonos inteligentes emblemáticos de este año equipados con CPU de 16 núcleos. Por menos de $ 20 por hora, MS Azure puede darle acceso a una máquina virtual de 128 núcleos con 2 TB de RAM. Pero, desafortunadamente, no puede aprovechar al máximo este poder a menos que sepa cómo controlar la interacción entre los hilos.

Contenido




Terminología


Proceso : un objeto del sistema operativo que representa un espacio de direcciones aislado que contiene subprocesos.

Subproceso : un objeto del sistema operativo que representa la unidad de ejecución más pequeña. Los hilos son partes constitutivas de los procesos, dividen la memoria y otros recursos entre sí en el alcance de un proceso.

Multitarea : una característica del sistema operativo que representa la capacidad de ejecutar múltiples procesos simultáneamente.

Multi-core : una función de CPU que representa la capacidad de usar múltiples núcleos para el procesamiento de datos

Multiprocesamiento : la característica de una computadora que representa la capacidad de trabajar físicamente con múltiples CPU.

Multi-threading : la característica de un proceso que representa la capacidad de dividir y difundir el procesamiento de datos entre múltiples subprocesos.

Paralelismo : ejecución física simultánea de múltiples acciones en una unidad de tiempo

Asincronía : ejecutar una operación sin esperar a que se procese por completo, dejando el cálculo del resultado para un momento posterior.


Una metáfora


No todas las definiciones son efectivas y algunas de ellas requieren elaboración, así que permítanme proporcionar una metáfora de cocina para la terminología que acabo de presentar.

Preparar el desayuno representa un proceso en esta metáfora.

Al hacer el desayuno en la mañana, yo ( CPU ) voy a la cocina ( Computadora ). Tengo dos manos ( núcleos ). En la cocina, hay una variedad de dispositivos ( IO ): estufa, tetera, tostadora, nevera. Enciendo la estufa, pongo una sartén y vierto un poco de aceite vegetal. Sin esperar a que el aceite se caliente ( asincrónicamente, sin bloqueo, IO-Wait ), saco algunos huevos del refrigerador, los rompo en un tazón y luego los azoto con una mano ( Hilo # 1 ). Mientras tanto, la segunda mano (Hilo # 2) está sosteniendo el tazón en su lugar ( Recurso compartido ). Me gustaría encender el hervidor, pero no tengo suficientes manos libres en este momento ( Thread Starvation ). Mientras estaba batiendo los huevos, la sartén se calentó lo suficiente (procesamiento de resultados), así que vertí los huevos batidos en ella. Me acerco al hervidor, lo enciendo y miro el agua hirviendo ( Blocking-IO-Wait ), pero podría haber usado este tiempo para lavar el tazón.

Solo usé 2 manos para hacer la tortilla (porque no tengo más), pero se ejecutaron 3 operaciones simultáneas: batir los huevos, sostener el tazón y calentar la sartén. La CPU es la parte más rápida de la computadora y IO es la parte que requiere esperar con mayor frecuencia, por lo que es bastante efectivo cargar la CPU con algo de trabajo mientras espera los datos de IO.

Para extender la metáfora:

  • Si también intentara cambiarme de ropa mientras preparaba el desayuno, entonces habría estado haciendo múltiples tareas . Las computadoras son mucho mejores en esto que los humanos.
  • Una cocina con múltiples cocineros, por ejemplo, en un restaurante, es una computadora de múltiples núcleos .
  • Un patio de comidas en un centro comercial con muchos restaurantes representaría un centro de datos .



Herramientas .NET


.NET es realmente bueno cuando se trata de trabajar con hilos, así como en muchas otras cosas. Con cada nueva versión, proporciona más herramientas para trabajar con subprocesos y nuevas capas de abstracción de subprocesos del sistema operativo. Al trabajar con abstracciones, los desarrolladores que trabajan con el marco utilizan un enfoque que les permite bajar una o más capas mientras usan abstracciones de alto nivel. En la mayoría de los casos, no hay una necesidad real de hacer esto (y esto puede introducir la posibilidad de dispararte en el pie), pero a veces esta puede ser la única forma de resolver un problema que no se puede resolver en el nivel de abstracción actual.

Cuando dije herramientas anteriormente, me refería a las interfaces de programa (API) proporcionadas por el marco o los paquetes de terceros y las soluciones de software completas que simplifican el proceso de búsqueda de problemas relacionados con el código de subprocesos múltiples.


Comenzando un hilo


La clase Thread es la clase .NET más básica para trabajar con hilos. Su constructor acepta uno de estos dos delegados:

  • ThreadStart: sin parámetros
  • ParametrizedThreadStart: un parámetro de tipo de objeto.


El delegado se ejecutará en un hilo recién creado después de llamar al método de Inicio. Si el delegado ParametrizedThreadStart se pasó al constructor, se debe pasar un objeto al método Start. Este proceso es necesario para pasar cualquier información local al hilo. Debo señalar que se necesitan muchos recursos para crear un subproceso y el subproceso en sí es un objeto pesado, al menos porque requiere interacción con la API del sistema operativo y se asigna 1 MB de memoria a la pila.

new Thread(...).Start(...); 

La clase ThreadPool representa el concepto de un grupo. En .NET, el grupo de subprocesos es una obra de arte de ingeniería y los desarrolladores de Microsoft invirtieron mucho esfuerzo para que funcione de manera óptima en todo tipo de escenarios.

El concepto general:
Cuando se inicia, la aplicación crea algunos subprocesos en segundo plano, lo que permite acceder a ellos cuando sea necesario. Si los hilos se usan con frecuencia y en grandes cantidades, el grupo se expande para satisfacer las necesidades del código de llamada. Si el grupo no tiene suficientes subprocesos libres en el momento adecuado, esperará a que uno de los subprocesos activos quede desocupado o creará uno nuevo. En base a esto, se deduce que el grupo de subprocesos es perfecto para acciones cortas y no funciona tan bien para procesos que funcionan como servicios durante toda la operación de la aplicación.

El método QueueUserWorkItem permite usar subprocesos del grupo. Este método toma el delegado de tipo WaitCallback . Su firma coincide con la firma de ParametrizedThreadStart, y el parámetro que se le pasa cumple la misma función.

 ThreadPool.QueueUserWorkItem(...); 

El método de grupo de subprocesos RegisterWaitForSingleObject menos conocido se usa para organizar operaciones de E / S sin bloqueo. El delegado que se pasa a este método se llamará cuando se suelte WaitHandle después de pasar al método.

 ThreadPool.RegisterWaitForSingleObject(...) 


Hay un temporizador de subprocesos en .NET, y se diferencia de los temporizadores WinForms / WPF en que su controlador se llama en el subproceso tomado del grupo.

 System.Threading.Timer 


También hay una forma bastante inusual de enviar al delegado a un subproceso del grupo: el método BeginInvoke.

 DelegateInstance.BeginInvoke 


También me gustaría echar un vistazo a la función a la que se reducen muchos de los métodos que mencioné anteriormente: CreateThread desde la API de Kernel32.dll Win32. Hay una manera de llamar a esta función con la ayuda del mecanismo externo de los métodos. Solo he visto que esto se usa una vez en un caso particularmente malo de código heredado, y todavía no entiendo cuáles fueron las razones de su autor.
 Kernel32.dll CreateThread 



Ver y depurar hilos


Todos los subprocesos, ya sean creados por usted, componentes de terceros o el grupo .NET, se pueden ver en la ventana de subprocesos de Visual Studio. Esta ventana solo mostrará la información sobre los subprocesos cuando la aplicación se esté depurando en el modo de interrupción. Aquí, puede ver los nombres y prioridades de cada hilo y enfocar el modo de depuración en hilos específicos. La propiedad Prioridad de la clase Thread le permite establecer la prioridad del hilo. Esta prioridad se tendrá en cuenta cuando el sistema operativo y el CLR estén dividiendo el tiempo del procesador entre subprocesos.




Biblioteca paralela de tareas


La Biblioteca Paralela de Tareas (TPL) apareció por primera vez en .NET 4.0. Actualmente, es la herramienta principal para trabajar con asincronía. Cualquier código que utilice enfoques anteriores se considerará código heredado. La unidad principal de TPL es la clase Task del espacio de nombres System.Threading.Tasks. Las tareas representan la abstracción de hilos. Con la última versión de C #, adquirimos una nueva forma elegante de trabajar con Tareas: los operadores asíncronos / en espera. Estos permiten que el código asincrónico se escriba como si fuera simple y sincrónico, por lo que aquellos que no están bien versados ​​en la teoría de los hilos ahora pueden escribir aplicaciones que no tengan problemas con las operaciones largas. Usar async / await es realmente un tema para un artículo separado (o incluso algunos artículos), pero trataré de resumir los conceptos básicos en unas pocas oraciones:

  • async es un modificador de un método que devuelve una tarea o anula
  • await es un operador de una tarea de espera sin bloqueo.


Una vez más: el operador de espera generalmente (hay excepciones) dejar ir el hilo actual y, cuando la tarea se ejecutará y el hilo (en realidad, el contexto, pero volveremos a ello más adelante) será libre como un resultado, continuará ejecutando el método. En .NET, este mecanismo se implementa de la misma manera que el rendimiento de rendimiento: un método se convierte en una clase de máquina de estados finitos que se puede ejecutar en partes separadas según su estado. Si esto suena interesante, recomendaría escribir cualquier fragmento de código simple basado en async / await, compilarlo y mirar su compilación con la ayuda de JetBrains dotPeek con el código generado por el compilador habilitado.

Veamos las opciones que tenemos cuando se trata de comenzar y usar una tarea. En el siguiente ejemplo, creamos una nueva tarea que en realidad no hace nada productivo (Thread.Sleep (10000)). Sin embargo, en casos reales deberíamos sustituirlo por un trabajo complejo que utiliza recursos de la CPU.

 using TCO = System.Threading.Tasks.TaskCreationOptions; public static async void VoidAsyncMethod() { var cancellationSource = new CancellationTokenSource(); await Task.Factory.StartNew( // Code of action will be executed on other context () => Thread.Sleep(10000), cancellationSource.Token, TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness, scheduler ); // Code after await will be executed on captured context } 


Se crea una tarea con las siguientes opciones:

  • LongRunning: esta opción sugiere el hecho de que la tarea no se puede realizar rápidamente. Por lo tanto, posiblemente sea mejor crear un hilo separado para esta tarea en lugar de tomar uno existente del grupo para minimizar el daño a otras tareas.
  • AttachedToParent: las tareas se pueden organizar jerárquicamente. Si se utiliza esta opción, la tarea estará esperando que sus tareas secundarias se ejecuten después de ejecutarse.
  • PreferFairness: esta opción especifica que la tarea debe ejecutarse mejor antes que las tareas que se crearon más tarde. Sin embargo, es más una sugerencia, por lo que el resultado no siempre está garantizado.


El segundo parámetro que se pasó al método es CancellationToken. Para que la operación se cancele correctamente después de que ya se inició, el código ejecutable debe contener comprobaciones de estado de CancellationToken. Si no hay tales comprobaciones, el método de cancelación invocado en el objeto CancellationTokenSource solo podría detener la ejecución de la tarea antes de que la tarea se inicie realmente.

Para el último parámetro, enviamos un objeto de tipo TaskScheduler llamado Scheduler. Esta clase, junto con sus clases secundarias, se usa para controlar cómo se distribuyen las tareas entre los subprocesos. Por defecto, una tarea se ejecutará en un subproceso seleccionado aleatoriamente del grupo

El operador de espera se aplica a la tarea creada. Esto significa que el código escrito después (si existe dicho código) se ejecutará en el mismo contexto (a menudo, esto significa 'en el mismo hilo') que el código escrito antes de esperar.

Este método está etiquetado como vacío asíncrono, lo que significa que el operador de espera se puede utilizar en él, pero el código de llamada no podría esperar a la ejecución. Si se necesita dicha posibilidad, el método debe devolver una Tarea. Los métodos etiquetados como vacío asíncrono se pueden ver con bastante frecuencia: generalmente son controladores de eventos u otros métodos que operan bajo el principio de fuego y olvido. Si es necesario esperar a que finalice la ejecución y devolver el resultado, entonces debe usar Tarea.

Para las tareas que devuelven el método StartNew, podemos llamar a ConfigureAwait con el parámetro falso; luego, la ejecución después de la espera continuará en un contexto aleatorio en lugar de uno capturado. Esto siempre debe hacerse si el código escrito después de esperar no requiere un contexto de ejecución específico. Esto también es una recomendación de MS cuando se trata de escribir código provisto como una biblioteca.

Veamos cómo podemos esperar a que termine una tarea. A continuación, puede ver un fragmento de código de ejemplo con comentarios que indican cuando la espera se implementa de una manera relativamente buena o mala.

 public static async void AnotherMethod() { int result = await AsyncMethod(); // good result = AsyncMethod().Result; // bad AsyncMethod().Wait(); // bad IEnumerable<Task> tasks = new Task[] { AsyncMethod(), OtherAsyncMethod() }; await Task.WhenAll(tasks); // good await Task.WhenAny(tasks); // good Task.WaitAll(tasks.ToArray()); // bad } 

En el primer ejemplo, estamos esperando que la tarea se ejecute sin bloquear el hilo de llamada, por lo que volveremos a procesar el resultado cuando esté listo. Antes de que eso suceda, el hilo de llamada se deja solo.

En el segundo intento, estamos bloqueando el hilo de llamada hasta que se calcule el resultado del método. Este es un mal enfoque por dos razones. En primer lugar, estamos desperdiciando un hilo, un recurso muy valioso, en una simple espera. Además, si el método al que llamamos contiene una espera mientras que el contexto de sincronización pretende un retorno al hilo de la llamada después de esperar, obtendremos un punto muerto. Esto sucede porque el subproceso de llamada estará esperando el resultado de un método asincrónico, y el método asincrónico en sí mismo intentará infructuosamente continuar su ejecución en el subproceso de llamada.

Otra desventaja de este enfoque es la mayor complejidad del manejo de errores. En realidad, los errores se pueden manejar con bastante facilidad en el código asíncrono si se usa async / await; el proceso en este caso es idéntico al del código síncrono. Sin embargo, cuando se aplica una espera sincrónica a una tarea, la excepción inicial se envuelve en AggregateException. En otras palabras, para manejar la excepción, necesitaríamos explorar el tipo InnerException y escribir manualmente una cadena if en un bloque catch o, alternativamente, usar la estructura catch when en lugar de la cadena más habitual de bloques catch.

Los dos últimos ejemplos también están etiquetados como relativamente malos por las mismas razones y ambos contienen los mismos problemas.

Los métodos WhenAny y WhenAll son muy útiles cuando se trata de esperar un grupo de tareas: agrupan estas tareas en una sola y se ejecutará cuando se inicie una tarea del grupo o cuando todas estas tareas se ejecuten con éxito.


Detener hilos


Por varias razones, puede ser necesario detener un hilo después de que se haya iniciado. Hay algunas formas de hacer esto. La clase Thread tiene dos métodos con nombres apropiados: Abortar e Interrumpir . Me desaconsejaría usar el primero ya que, después de que se llama, se generará una ThreadAbortedException en cualquier momento aleatorio mientras se procesa cualquier instrucción elegida arbitrariamente. No espera que se encuentre una excepción de este tipo cuando se incrementa una variable entera, ¿verdad? Bueno, cuando se usa el método Abortar, esto se convierte en una posibilidad real. En caso de que necesite negar la capacidad del CLR de crear tales excepciones en una parte específica del código, puede envolverlo en las llamadas Thread. BeginCriticalRegion y Thread.EndCriticalRegion . Cualquier código escrito en el bloque finalmente está envuelto en estas llamadas. Es por eso que puede encontrar bloques con un intento vacío y uno no vacío finalmente en las profundidades del código marco. A Microsoft no le gusta este método hasta el punto de no incluirlo en el núcleo de .NET.

El método Interrrupt funciona de una manera mucho más predecible. Puede interrumpir un hilo con una ThreadInterruptedException solo cuando el hilo está en el modo de espera. Se mueve a este estado cuando se suspende mientras espera WaitHandle, un bloqueo o después de que se llama Thread.Sleep.

Ambas formas tienen la desventaja de la imprevisibilidad. Para escapar de este problema, debemos usar la estructura CancellationToken y la clase CancellationTokenSource . La idea general es esta: se crea una instancia de la clase CancellationTokenSource, y solo aquellos que la poseen pueden detener la operación llamando al método Cancel . Solo CancellationToken se pasa a la operación. Cancelación Los propietarios de Tomen no pueden cancelar la operación ellos mismos, solo pueden verificar si la operación ha sido cancelada. Esto se puede lograr mediante el uso de una propiedad booleana IsCancellationRequested y el método ThrowIfCancelRequested . El último generará una TaskCancelledException si se ha llamado al método Cancel en la instancia CancellationTokenSource que creó CancellationToken. Este es el método que recomiendo usar. Su ventaja sobre los métodos descritos anteriormente radica en el hecho de que proporciona un control total sobre los casos de excepción exactos en los que se puede cancelar una operación.

La forma más brutal de detener un hilo sería llamar a una función API Win32 llamada TerminateThread. Después de invocar esta función, el comportamiento del CLR puede ser bastante impredecible. En MSDN , se escribe lo siguiente sobre esta función: “TerminateThread es una función peligrosa que solo debe usarse en los casos más extremos. "


Convertir una API heredada en una basada en tareas mediante FromAsync


Si tuvo la suerte de trabajar en un proyecto que se inició después de que se introdujeron las tareas (y cuando ya no incitan el horror existencial en la mayoría de los desarrolladores), no tendrá que lidiar con las API antiguas, ambas de terceros. los que tu equipo trabajó en el pasado. Afortunadamente, el equipo de desarrollo de .NET Framework nos lo hizo más fácil, pero esto podría haber sido autocuidado, por lo que sabemos. En cualquier caso, .NET tiene algunas herramientas que ayudan a llevar a la perfección el código escrito con enfoques antiguos a la asincronía en un formulario actualizado. Uno de estos es el método TaskFactory llamado FromAsync. En el siguiente ejemplo, estoy envolviendo los viejos métodos asincrónicos de la clase WebRequest en una Tarea usando FromAsync.

 object state = null; WebRequest wr = WebRequest.CreateHttp("http://github.com"); await Task.Factory.FromAsync( wr.BeginGetResponse, we.EndGetResponse ); 

Es solo un ejemplo, y probablemente no harás algo de este tipo con los tipos incorporados. Sin embargo, los proyectos antiguos están repletos de métodos BeginDoSomething que devuelven los métodos IAsyncResult y EndDoSomething que los reciben.


Convertir una API heredada en una basada en tareas mediante TaskCompletionSource


Otra herramienta que vale la pena explorar es la clase TaskCompletionSource . En su funcionalidad, propósito y principio de operación, se parece al método RegisterWaitForSingleObject de la clase ThreadPool que mencioné anteriormente. Esta clase nos permite envolver fácilmente antiguas API asincrónicas en tareas.

Es posible que desee decir que ya le conté sobre el método FromAsync de la clase TaskFactory que sirvió para estos fines. Aquí, deberíamos recordar la historia completa de los modelos asincrónicos que Microsoft proporcionó en los últimos 15 años: antes de los patrones asincrónicos basados ​​en tareas (TAP), había patrones de programación asincrónicos (APP). Las aplicaciones tenían que ver con Begin DoSomething que devuelve IAsyncResult y el método End DoSomething que lo acepta, y el método FromAsync es perfecto para el legado de estos años. Sin embargo, a medida que pasaba el tiempo, esto se reemplazó con Patrones asincrónicos basados ​​en eventos (EAP) que especificaban que se llamaba a un evento cuando una operación asincrónica se ejecutaba con éxito.

TaskCompletionSource es perfecto para envolver API heredadas creadas alrededor del modelo de evento en Tareas. Así es como funciona: los objetos de esta clase tienen una propiedad pública llamada Tarea, cuyo estado puede controlarse mediante varios métodos de la clase TaskCompletionSource (SetResult, SetException, etc.). En los lugares donde se aplicó el operador de espera a esta Tarea, se ejecutará o se bloqueará con una excepción dependiendo del método aplicado a TaskCompletionSource. Para entenderlo mejor, veamos este ejemplo de código. Aquí, algunas API antiguas de la era EAP se envuelven en una Tarea con la ayuda de TaskCompletionSource: cuando se activa un evento, la Tarea se cambiará al estado Completado mientras el método que aplicó el operador de espera a esta Tarea continuará su ejecución después de recibir un objeto de resultado .

 public static Task<Result> DoAsync(this SomeApiInstance someApiObj) { var completionSource = new TaskCompletionSource<Result>(); someApiObj.Done += result => completionSource.SetResult(result); someApiObj.Do(); result completionSource.Task; } 


TaskCompletionSource Consejos y trucos


TaskCompletionSource puede hacer más que simplemente envolver API obsoletas. Esta clase abre una posibilidad interesante de diseñar varias API basadas en tareas que no ocupan subprocesos. Un hilo, como recordamos, es un recurso costoso limitado principalmente por RAM. Podemos alcanzar fácilmente este límite al desarrollar una aplicación web robusta con una lógica empresarial compleja. Echemos un vistazo a las capacidades que mencioné en acción mediante la implementación de un buen truco conocido como Long Polling.

En resumen, así es como funciona Long Polling:
Necesita obtener información de una API sobre los eventos que ocurren de forma paralela, pero la API, por alguna razón, solo puede devolver un estado en lugar de informarle sobre el evento. Un ejemplo de esto sería cualquier API construida sobre HTTP antes de que apareciera WebSocket o en circunstancias en las que esta tecnología no se pueda utilizar. El cliente puede preguntarle al servidor HTTP. El servidor HTTP, por otro lado, no puede iniciar contacto con el cliente por sí mismo. La solución más simple sería pedirle al servidor periódicamente que use un temporizador, pero esto crearía una carga adicional para el servidor y un retraso general que equivale aproximadamente a TimerInterval / 2. Para evitar esto, se inventó Long Polling. Implica retrasar la respuesta del servidor hasta que caduque el tiempo de espera o suceda un evento. Si ocurre un evento, será manejado; si no, la solicitud se enviará nuevamente.

 while(!eventOccures && !timeoutExceeded) { CheckTimout(); CheckEvent(); Thread.Sleep(1); } 

Sin embargo, la efectividad de esta solución disminuirá radicalmente si aumenta el número de clientes que esperan el evento: cada cliente que espera ocupa un hilo completo. Además, obtenemos un retraso adicional de 1 ms para la activación de eventos. A menudo, no es realmente tan crucial, pero ¿por qué haríamos nuestro software peor de lo que podría ser? Por otro lado, si eliminamos Thread.Sleep (1), uno de los núcleos de la CPU se cargará al 100% sin hacer nada en un ciclo inútil. Con la ayuda de TaskCompletionSource, podemos transformar fácilmente nuestro código para resolver todos los problemas que mencionamos:

 class LongPollingApi { private Dictionary<int, TaskCompletionSource<Msg>> tasks; public async Task<Msg> AcceptMessageAsync(int userId, int duration) { var cs = new TaskCompletionSource<Msg>(); tasks[userId] = cs; await Task.WhenAny(Task.Delay(duration), cs.Task); return cs.Task.IsCompleted ? cs.Task.Result : null; } public void SendMessage(int userId, Msg m) { if (tasks.TryGetValue(userId, out var completionSource)) completionSource.SetResult(m); } } 

Tenga en cuenta que este código es solo un ejemplo, y de ninguna manera está listo para la producción. Para usarlo en casos reales, al menos deberíamos agregar una forma de manejar situaciones en las que se recibe un mensaje cuando nada lo estaba esperando: en este caso, el método AcceptMessageAsync debería devolver una tarea ya finalizada. Si este caso es el más común, podemos considerar usar ValueTask.

Al recibir una solicitud de mensaje, creamos una TaskCompletionSource, la colocamos en un diccionario y luego esperamos uno de los siguientes eventos: se pasa el intervalo de tiempo especificado o se recibe un mensaje.


ValueTask: por qué y cómo


Los operadores asíncronos / en espera, al igual que el operador de retorno de rendimiento, generan una máquina de estados finitos a partir de un método, lo que significa crear un nuevo objeto; esto realmente no importa la mayor parte del tiempo, pero aún puede crear problemas en algunos casos raros. Uno de estos casos puede ocurrir con métodos frecuentemente llamados: estamos hablando de decenas y cientos de miles de llamadas por segundo. Si dicho método está escrito de una manera que hace que devuelva el resultado mientras omite todos los métodos de espera en la mayoría de los casos, .NET proporciona una herramienta de optimización para esto: la estructura ValueTask. Para entender cómo funciona, veamos un ejemplo. Supongamos que hay un caché al que accedemos regularmente. Si hay valores en él, simplemente los devolvemos; si no hay valores, intentamos obtenerlos de alguna IO lenta. Lo último idealmente debería hacerse de forma asíncrona, por lo que todo el método será asíncrono. Entonces, la forma más obvia de implementar este método será la siguiente:

 public async Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return val; return await RequestById(id); } 

Con el deseo de optimizarlo un poco y la preocupación por lo que generará Roslyn al compilar este código, podríamos reescribir el método de esta manera:

 public Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return Task.FromResult(val); return RequestById(id); } 

Sin embargo, la mejor solución en este caso sería optimizar la ruta activa, específicamente, obtener valores de diccionario sin asignaciones innecesarias y sin carga en el GC. Mientras tanto, en esos casos poco frecuentes en los que necesitamos obtener datos de IO, las cosas seguirán siendo casi las mismas:

 public ValueTask<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return new ValueTask<string>(val); return new ValueTask<string>(RequestById(id)); } 

Miremos este fragmento de código más de cerca: si hay un valor presente en el caché, crearemos una estructura; de lo contrario, la tarea real se envolverá en una ValueTask. La ruta por la que se ejecuta este código no es importante para el código de llamada: desde la perspectiva de la sintaxis de C #, una ValueTask se comportará como una tarea habitual.


TaskScheduler: Control de estrategias de ejecución de tareas


La próxima API de la que me gustaría hablar es la clase TaskScheduler y las derivadas de ella. Ya mencioné que TPL proporciona la capacidad de controlar cómo se distribuyen exactamente las tareas entre los subprocesos. Estas estrategias se definen en clases que heredan de TaskScheduler. Casi cualquier estrategia que podamos necesitar se puede encontrar en la biblioteca ParallelExtensionsExtras . Microsoft desarrolla esta biblioteca, pero no forma parte de .NET, sino que se distribuye como un paquete Nuget. Echemos un vistazo a algunas de las estrategias:

  • CurrentThreadTaskScheduler: ejecuta tareas en el hilo actual
  • LimitedConcurrencyLevelTaskScheduler: limita el número de tareas ejecutadas simultáneamente utilizando el parámetro N que acepta en el constructor
  • OrderedTaskScheduler: se define como LimitedConcurrencyLevelTaskScheduler (1), por lo que las tareas se ejecutarán secuencialmente.
  • WorkStealingTaskScheduler: implementa el enfoque de robo de trabajo para la ejecución de tareas. Esencialmente, se puede ver como un ThreadPool separado. Esto ayuda con el problema de que ThreadPool es una clase estática en .NET: si se sobrecarga o se usa incorrectamente en una parte de la aplicación, pueden producirse efectos secundarios desagradables en un lugar diferente. Las causas reales de tales defectos pueden ser difíciles de localizar, por lo que es posible que necesite usar WorkStealingTaskSchedulers por separado en aquellas partes de la aplicación donde el uso de ThreadPool puede ser agresivo e impredecible.
  • QueuedTaskScheduler: permite ejecutar tareas en función de una cola priorizada
  • ThreadPerTaskScheduler: crea un hilo separado para cada tarea que se ejecuta en él. Esto puede ser útil para tareas cuyo tiempo de ejecución no se puede estimar.

Hay un muy buen artículo sobre TaskSchedulers en el blog de Microsoft, así que no dude en consultarlo.

En Visual Studio, hay una ventana de Tareas que puede ayudar a depurar todo lo relacionado con Tareas. En esta ventana, puede ver el estado de la tarea y saltar a la línea de código ejecutada actualmente.



PLinq y la clase paralela


Además de las tareas y todo lo relacionado con ellas, hay dos herramientas adicionales en .NET que podemos encontrar interesantes: PLinq (Linq2Parallel) y la clase Parallel . El primero promete la ejecución paralela de todas las operaciones de Linq en todos los hilos. El número de subprocesos se puede configurar mediante un método de extensión WithDegreeOfParallelism. Desafortunadamente, en la mayoría de los casos, PLinq en el modo predeterminado no tendrá suficiente información sobre la fuente de datos para proporcionar un aumento significativo en la velocidad. Por otro lado, el costo de intentarlo es muy bajo: solo necesita llamar a AsParallel antes de la cadena de métodos de Linq y realizar pruebas de rendimiento. Además, puede pasar información adicional sobre la naturaleza de su fuente de datos a PLinq utilizando el mecanismo de Particiones. Puede encontrar más información aquí y aquí .

La clase estática paralela proporciona métodos para enumerar colecciones en paralelo a través de Foreach, ejecutar el ciclo For y ejecutar varios delegados en paralelo a Invoke. La ejecución del hilo actual se detendrá hasta que se calculen los resultados. Puede configurar el número de subprocesos pasando ParallelOptions como último argumento. TaskScheduler y CancellationToken también se pueden configurar con la ayuda de opciones.


Resumen


Cuando comencé a escribir este artículo basado en mi tesis y en el conocimiento que obtuve mientras trabajaba, no pensé que habría tanta información. Ahora, con el editor de texto diciéndome con reproche que he escrito casi 15 páginas, me gustaría sacar una conclusión intermedia. En el próximo artículo veremos otras técnicas, API, herramientas visuales y riesgos ocultos.

Conclusiones:

  • Para utilizar eficazmente los recursos de las PC modernas, necesitaría conocer herramientas para trabajar con hilos, asincronía y paralelismo.
  • Hay muchas herramientas como esta en .NET
  • No todos se crearon al mismo tiempo, por lo que a menudo puede encontrar algún código heredado, pero hay formas de transformar las API antiguas con poco esfuerzo.
  • En .NET, las clases Thread y ThreadPool se usan para trabajar con hilos
  • El método Thread.Abort y Thread.Interrupt, junto con la función Win32 API TerminateThread, son peligrosos y no se recomienda su uso. En cambio, es mejor usar CancellationTokens
  • Los hilos son un recurso valioso y su número es limitado. Debe evitar los casos en que los hilos están ocupados esperando eventos. La clase TaskCompletionSource puede ayudar a lograr esto.
  • Las tareas son la herramienta más poderosa y robusta que tiene .NET para trabajar con paralelismo y asincronía.
  • Los operadores asíncronos / en espera C # implementan el concepto de una espera sin bloqueo
  • Puede controlar cómo se distribuyen las tareas entre subprocesos con la ayuda de clases derivadas de TaskScheduler
  • La estructura ValueTask se puede utilizar para optimizar las rutas activas y el tráfico de memoria
  • Las ventanas Tareas e hilos en Visual Studio proporcionan mucha información útil para depurar código multiproceso o asincrónico
  • PLinq es una herramienta increíble, pero puede que no tenga toda la información requerida sobre su fuente de datos, que aún se puede arreglar con el mecanismo de partición

Continuará ...

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


All Articles