.NET: Herramientas para trabajar con paralelismo y asincronía. Parte 1

Publico en Habr el original del artículo, cuya traducción se encuentra en la empresa blog.

La necesidad de hacer algo de manera asíncrona, sin esperar resultados aquí y ahora, o dividir un gran trabajo entre varias unidades que lo realizan, existía incluso antes de la llegada de las computadoras. Con su aparición, esta necesidad se volvió muy evidente. Ahora, en 2019, escribiendo este artículo en una laptop con un procesador Intel Core de 8 núcleos, en la que no solo están funcionando unos cientos de procesos, sino que hay hasta más hilos. A mi lado, hay un teléfono ya un poco desgastado, comprado hace un par de años, que cuenta con un procesador de 8 núcleos. En recursos temáticos abundan artículos y videos donde sus autores se maravillan con los smartphones insignia de este año que incorporan procesadores de 16 núcleos. MS Azure ofrece una máquina virtual con un procesador de 128 núcleos y 2 TB de RAM por menos de 20$/hora. Lamentablemente, es imposible aprovechar al máximo y domar este poder sin saber gestionar la interacción de los hilos.

Terminología

Proceso (Process) — objeto del sistema operativo, espacio de direcciones aislado, contiene hilos.
Hilo (Thread) — objeto del sistema operativo, la unidad más pequeña de ejecución, parte del proceso, los hilos comparten memoria y otros recursos entre sí dentro del proceso.
Multiprocesamiento — propiedad del sistema operativo, la posibilidad de ejecutar varios procesos simultáneamente
Multicore — propiedad del procesador, la posibilidad de utilizar varios núcleos para procesar datos
Multiprocesador — propiedad de la computadora, la posibilidad de trabajar simultáneamente con varios procesadores físicamente
Multihilo — propiedad del proceso, la posibilidad de distribuir el procesamiento de datos entre varios hilos.
Paralelismo — ejecución de varias acciones físicamente al mismo tiempo en un instante
Asincronía — ejecución de una operación sin esperar a que finalice la finalización de este procesamiento, el resultado de la ejecución puede ser procesado más tarde.

Metáfora

No todas las definiciones son buenas y algunas necesitan una explicación adicional, por lo que a la terminología formal introducida agregaré una metáfora sobre la preparación del desayuno. La preparación del desayuno en esta metáfora es un proceso.

Al preparar el desayuno por la mañana yo (CPU) llego a la cocina (Computadora). Tengo 2 manos (Núcleos). En la cocina hay una serie de dispositivos (IO): horno, tetera, tostadora, refrigerador. Enciendo el gas, coloco una sartén y vierto aceite sin esperar a que se caliente (asincrónicamente, Non-Blocking-IO-Wait), saco los huevos del refrigerador y los rompo en un plato, después de lo cual los bato con una mano (Thread#1), mientras con la otra (Thread#2) sostengo el plato (Recurso Compartido). Ahora solo me falta encender la tetera, pero no tengo suficientes manos (Thread Starvation) Mientras tanto, la sartén se calienta (Procesamiento de Resultados) donde vierto lo que he batido. Me estiro hacia la tetera y la enciendo, y simplemente miro cómo el agua hierve en ella (Blocking-IO-Wait), aunque podría haber aprovechado ese tiempo para lavar el plato donde batí la omelet.

Estuve preparando una omelet usando solo 2 manos, que es todo lo que tengo, pero mientras batía la omelet, ocurrían 3 operaciones simultáneamente: batir la omelet, sostener el plato, calentar la sartén. La CPU es la parte más rápida de la computadora, IO es lo que más frecuentemente se ralentiza, por eso a menudo una solución efectiva es ocupar la CPU mientras se obtienen datos de IO.

Siguiendo la metáfora:

  • Si en el proceso de preparar la omelet, además intentara cambiarme de ropa, eso sería un ejemplo de multitarea. Un detalle importante: los ordenadores son mucho mejores en esto que los humanos.
  • Una cocina con varios chefs, como en un restaurante, es un computador multinúcleo.
  • Muchos restaurantes en un patio de comidas en un centro comercial son un centro de datos.

Herramientas .NET

En el trabajo con hilos, al igual que en muchas otras áreas, .NET es excelente. Con cada nueva versión presenta más herramientas para trabajar con ellos, nuevas capas de abstracción sobre los hilos del sistema operativo. En el trabajo de construcción de abstracciones, los desarrolladores del framework utilizan un enfoque que permite, al usar una abstracción de alto nivel, bajar uno o varios niveles. A menudo no es necesario, de hecho, esto abre la posibilidad de dispararse en el pie con una escopeta, pero a veces, en raras ocasiones, puede ser la única manera de resolver un problema que no se puede solucionar en el nivel de abstracción actual.

Con herramientas me refiero tanto a las interfaces de programación (API) proporcionadas por el framework y paquetes de terceros, como a soluciones de software completas que facilitan la búsqueda de problemas relacionados con el código multihilo.

Iniciar hilo

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

  • ThreadStart — Sin parámetros
  • ParametrizedThreadStart — con un parámetro de tipo object.

El delegado se ejecutará en el nuevo hilo creado después de llamar al método Start. Si se pasó un delegado de tipo ParametrizedThreadStart al constructor, entonces se debe pasar un objeto al método Start. Este mecanismo es necesario para transmitir cualquier información local al hilo. Vale la pena señalar que crear un hilo es una operación costosa, y el hilo en sí es un objeto pesado, al menos porque se asigna 1 MB de memoria en la pila y requiere interacción con la API del sistema operativo.

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

La clase ThreadPool representa el concepto de un grupo. En .NET, el grupo de hilos es una obra maestra de ingeniería, y los desarrolladores de Microsoft han invertido mucho esfuerzo para que funcione de manera óptima en diversos escenarios.

Concepto general:

Desde el inicio, la aplicación en segundo plano crea varios hilos de reserva y proporciona la posibilidad de utilizarlos. Si los hilos se utilizan a menudo y en gran cantidad, el grupo se expande para satisfacer la necesidad del código llamador. Cuando no hay hilos libres en el grupo en un momento dado, espera a que uno de los hilos se libere o crea uno nuevo. De esto se deduce que el grupo de hilos es excelente para acciones cortas y poco adecuado para operaciones que funcionan como servicios durante toda la duración de las aplicaciones.

Para utilizar un hilo del grupo, hay un método QueueUserWorkItem que acepta un delegado de tipo WaitCallback, que coincide en la firma con ParametrizedThreadStart, y el parámetro pasado ejecuta la misma función.

ThreadPool.QueueUserWorkItem(...);

Un método menos conocido del grupo de hilos, RegisterWaitForSingleObject, sirve para organizar operaciones de IO no bloqueantes. El delegado pasado a este método se llamará cuando el WaitHandle pasado al método sea 'liberado'.

ThreadPool.RegisterWaitForSingleObject(...);

En .NET hay un temporizador de hilos que se diferencia de los temporizadores de WinForms/WPF en que su manejador se llamará en un hilo tomado del grupo.

System.Threading.Timer

También hay un método bastante exótico para enviar un delegado a ejecutar en un hilo del grupo: el método BeginInvoke.

DelegateInstance.BeginInvoke

Quiero detenerme brevemente en la función a la que se refieren muchos de los métodos mencionados anteriormente: CreateThread de Kernel32.dll Win32 API. Existe una manera, gracias al mecanismo de métodos extern, de invocar esta función. Solo he visto tal llamada una vez en un espantoso ejemplo de código legado, y la motivación del autor que lo hizo de esta manera sigue siendo un misterio para mí.

Kernel32.dll CreateThread

Visualización y depuración de hilos

Los hilos que usted ha creado personalmente, así como todos los componentes de terceros y el grupo de .NET, se pueden ver en la ventana Threads de Visual Studio. Esta ventana mostrará información sobre los hilos solo cuando la aplicación esté en modo de depuración y parada (Break mode). Aquí puede ver fácilmente el nombre, la pila y las prioridades de cada hilo, además de cambiar la depuración a un hilo específico. Con la propiedad Priority de la clase Thread se puede establecer la prioridad del hilo, que tanto el OC como el CLR interpretarán como una recomendación al dividir el tiempo del procesador entre los hilos.

.NET: Herramientas para trabajar con paralelismo y asincronía. Parte 1

Biblioteca de Tareas Paralelas

La Biblioteca de Tareas Paralelas (TPL) apareció en .NET 4.0. Ahora es el estándar y la herramienta principal para trabajar con asincronía. Cualquier código que utilice enfoques más antiguos se considera legado. La unidad básica de la TPL es la clase Task del espacio de nombres System.Threading.Tasks. Task representa una abstracción sobre el hilo. Con la nueva versión del lenguaje C#, obtuvimos una forma elegante de trabajar con Tasks: los operadores async/await. Estos conceptos permitieron escribir código asíncrono como si fuera simple y sincrónico, lo que permitió incluso a personas con poco entendimiento de los entresijos de los hilos escribir aplicaciones que los utilicen, aplicaciones que no se bloquean al realizar operaciones prolongadas. El uso de async/await es un tema para uno o incluso varios artículos, pero intentaré resumir la esencia en algunas frases:

  • async es un modificador de método que devuelve Task o void
  • y await es un operador de espera no bloqueante para Task.

Una vez más: el operador await, en general (hay excepciones), liberará el hilo de ejecución actual, y cuando la Task termine de ejecutarse, el hilo (en realidad es más correcto decir contexto, pero de eso hablaremos más tarde) estará libre y continuará la ejecución del método. Dentro de .NET, este mecanismo se implementa de la misma manera que yield return, cuando el método escrito se convierte en toda una clase que actúa como una máquina de estados y puede ejecutarse en partes según estos estados. Quien esté interesado puede escribir cualquier código sencillo utilizando async/await, compilarlo y ver la compilación usando JetBrains dotPeek con el código generado por el compilador habilitado.

Consideremos las opciones para lanzar y usar una Task. En el ejemplo de código a continuación, creamos una nueva tarea que no hace nada útil (Thread.Sleep(10000)), pero en la vida real, esto debería ser algún trabajo complejo que involucre CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // El código de acción se ejecutará en otro contexto
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  El código después de await se ejecutará en el contexto capturado
}

La Task se crea con varias opciones:

  • LongRunning — una sugerencia de que la tarea no se completará rápidamente, por lo tanto, puede ser conveniente considerar no tomar un hilo del pool, sino crear uno separado para esta Task para no perjudicar a las demás.
  • AttachedToParent — las Task pueden estructurarse en jerarquías. Si se utiliza esta opción, la Task puede estar en un estado en el que ella misma se haya completado y esté esperando la finalización de las tareas secundarias.
  • PreferFairness — significa que sería recomendable ejecutar primero las Task que se enviaron antes, en comparación con las que se enviaron más tarde. Pero esto es solo una recomendación y el resultado no está garantizado.

El segundo parámetro en el método es el CancellationToken. Para un manejo adecuado de la cancelación de una operación después de su inicio, el código que se ejecuta debe estar lleno de comprobaciones del estado del CancellationToken. Si no hay comprobaciones, el método Cancel llamado en el objeto CancellationTokenSource solo podrá detener la ejecución de la Task antes de que se inicie.

El último parámetro es un objeto scheduler del tipo TaskScheduler. Esta clase y sus herederos están diseñados para gestionar las estrategias de distribución de tareas entre hilos; por defecto, la tarea se ejecutará en un hilo aleatorio del grupo.

Se ha aplicado el operador await a la tarea creada, lo que significa que el código escrito después de él, si existe, se ejecutará en el mismo contexto (a menudo, esto significa que en el mismo hilo) que el código antes del await.

El método está marcado como async void, lo que significa que se puede utilizar el operador await en él, pero el código que lo llama no podrá esperar su finalización. Si se necesita esta posibilidad, el método debe devolver un Task. Los métodos marcados como async void son bastante comunes: normalmente son manejadores de eventos u otros métodos que funcionan según el principio de 'ejecutar y olvidar' (fire and forget). Si es necesario no solo permitir esperar la finalización, sino también devolver un resultado, entonces debe usarse Task.

En el Task que devuelve el método StartNew, al igual que en cualquier otro, se puede llamar al método ConfigureAwait con el parámetro false, lo que hará que la ejecución después del await continúe no en el contexto capturado, sino en uno arbitrario. Esto debe hacerse siempre que el contexto de ejecución no sea crítico para el código posterior al await. También es una recomendación de Microsoft al escribir código que se distribuirá en forma de biblioteca.

Detengámonos un poco más en cómo podemos esperar la finalización de un Task. A continuación, un ejemplo de código, con comentarios, sobre cuándo la espera se ha realizado de manera condicionalmente buena y cuándo condicionalmente mala.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // bueno

    result = AsyncMethod().Result; // malo

    AsyncMethod().Wait(); // malo

    IEnumerable tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // bueno
    await Task.WhenAny(tasks); // bueno

    Task.WaitAll(tasks.ToArray()); // malo
}

En el primer ejemplo, esperamos la finalización del Task sin bloquear el hilo que lo llama; volveremos a procesar el resultado solo cuando esté disponible, hasta entonces el hilo que llama queda a su disposición.

En la segunda opción, bloqueamos el hilo que llama hasta que se calcule el resultado del método. Esto es problemático no solo porque hemos ocupado un hilo, un recurso valioso de la aplicación, con una tarea trivial, sino también porque si en el código del método que llamamos hay un await y el contexto de sincronización espera regresar al hilo que llama después del await, obtendremos un deadlock: el hilo que llama espera a que se calcule el resultado del método asíncrono, mientras que el método asíncrono intenta continuar su ejecución en el hilo que llama sin éxito.

Otro inconveniente de este enfoque es la complejidad en el manejo de errores. Las excepciones en el código asíncrono usando async/await son muy fáciles de manejar, ya que se comportan como si el código fuera sincrónico. Mientras que si aplicamos un aguardo sincrónico a un Task, la excepción original se envuelve en una AggregateException, por lo que para manejar la excepción habrá que investigar el tipo de InnerException y escribir una cadena de condiciones if dentro de un bloque catch, o usar la construcción catch when, en lugar de la más familiar en el mundo de C# que es una cadena de bloques catch.

El tercer y último ejemplo también presenta problemas por la misma razón y contiene los mismos inconvenientes.

Los métodos WhenAny y WhenAll son muy útiles para esperar a un grupo de Tasks, ya que envuelven a un grupo de Tasks en uno solo, que se activará ya sea por la primera activación de uno de los Tasks del grupo o cuando todos terminen su ejecución.

Detener hilos

Por diversas razones, puede surgir la necesidad de detener un hilo después de su inicio. Para esto, existen varias formas. La clase Thread tiene dos métodos con nombres apropiados — estos son Abort y Interrupt. El primero se desaconseja usar, ya que después de su invocación se lanzará una excepción en cualquier momento aleatorio, durante la ejecución de cualquier instrucción. ThreadAbortedException. ¿No esperas que se lance tal excepción al incrementar alguna variable entera, verdad? Sin embargo, con este método eso es una situación bastante real. Si es necesario evitar que CLR genere dicha excepción en un segmento específico de código, se puede envolver en llamadas a Thread.BeginCriticalRegion, Thread.EndCriticalRegionCualquier código escrito en el bloque finally está sujeto a estos tipos de excepciones. Por esta razón, en el código del marco se pueden encontrar bloques con un try vacío, pero no con un finally vacío. Microsoft desaconseja tanto este método que no lo incluyeron en .net core.

El método Interrupt funciona de manera más predecible. Puede interrumpir el hilo lanzando una excepción. ThreadInterruptedException solo en los momentos en que el hilo está en estado de espera. Este estado se alcanza al quedar en espera de WaitHandle, lock o tras invocar Thread.Sleep.

Ambas opciones descritas anteriormente son malas por su imprevisibilidad. La solución es utilizar la estructura CancellationToken y la clase CancellationTokenSource. La esencia es la siguiente: se crea una instancia de la clase CancellationTokenSource y solo quien la posee puede detener la operación llamando al método Cancel. A la operación se le pasa únicamente el CancellationToken. Los propietarios de CancellationToken no pueden cancelar la operación por sí mismos, solo pueden verificar si la operación ha sido cancelada. Para ello hay una propiedad booleana IsCancellationRequested y un método ThrowIfCancelRequested. El último generará una excepción TaskCancelledException si se ha invocado el método Cancel en la instancia de CancellationTokenSource que generó el CancellationToken. Y es este método el que recomiendo usar. Es mejor que las opciones anteriores, ya que proporciona un control total sobre los momentos en que se puede interrumpir la operación mediante una excepción.

La opción más drástica para detener un hilo es invocar la función de la API de Win32 TerminateThread. El comportamiento de CLR después de invocar esta función puede ser impredecible. En MSDN, esta función se describe de la siguiente manera: “TerminateThread es una función peligrosa que solo debe utilizarse en los casos más extremos.”

Transformación de API legado en basada en tareas mediante el método FromAsync.

Si tuviste la suerte de trabajar en un proyecto que comenzó después de que se introdujeron los Task y dejaron de causar el pavor silencioso de la mayoría de los desarrolladores, no tendrás que lidiar con una gran cantidad de API antiguas, ya sean de terceros o creadas por tu equipo en el pasado. Afortunadamente, el equipo de desarrollo de .NET Framework se ocupó de nosotros, aunque quizás su objetivo era cuidarse a sí mismos. De todos modos, en .NET hay varias herramientas para convertir sin dolor el código escrito en viejos enfoques de programación asincrónica a uno nuevo. Uno de ellos es el método FromAsync de TaskFactory. En el siguiente ejemplo de código, envuelvo antiguos métodos asincrónicos de la clase WebRequest en un Task usando este método.

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

Este es solo un ejemplo y es poco probable que tengas que hacer esto con tipos incorporados, pero cualquier proyecto antiguo está lleno de métodos BeginDoSomething que devuelven IAsyncResult y métodos EndDoSomething que los aceptan.

Conversión de API legacy a Task Based utilizando la clase TaskCompletionSource

Otra herramienta importante a considerar es la clase TaskCompletionSource. En cuanto a funciones, propósito y principio de funcionamiento, puede recordar a mí método RegisterWaitForSingleObject de la clase ThreadPool que mencioné anteriormente. Con esta clase, puedes envolver fácilmente y cómodamente APIs asincrónicas antiguas en Tasks.

Dirás que ya hablé del método FromAsync de la clase TaskFactory destinado a estos fines. Aquí es necesario recordar toda la historia del desarrollo de modelos asincrónicos en .NET que Microsoft ha propuesto en los últimos 15 años: antes del Task-Based Asynchronous Pattern (TAP) existía el Asynchronous Programming Pattern (APP), que se refería a los métodos BeginDoSomething que devuelve IAsyncResult y los métodos EndDoSomething que los aceptan, y para los legacy de esos años, el método FromAsync se adapta perfectamente, pero con el tiempo, fue reemplazado por el Event Based Asynchronous Pattern (EAP), que supuso que se llamaría un evento al finalizar la ejecución de una operación asincrónica.

TaskCompletionSource es ideal para envolver Tasks en APIs legadas que se basan en un modelo de eventos. Su funcionamiento es el siguiente: el objeto de esta clase tiene una propiedad pública de tipo Task cuyo estado se puede gestionar a través de los métodos SetResult, SetException, etc. En los lugares donde se aplicó el operador await a este Task, se completará o se producirá una excepción dependiendo del método aplicado al TaskCompletionSource. Si aún no está claro, veamos este ejemplo de código, donde una antigua API de la época de EAP se envuelve en un Task usando TaskCompletionSource: al ocurrir el evento, el Task se pondrá en estado Completed, y el método que aplicó el operador await a este Task reanudará su ejecución recibiendo el objeto. resultado.

public static Task DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    return completionSource.Task;
}

Consejos y trucos de TaskCompletionSource

Envolver APIs antiguas no es lo único que se puede lograr con TaskCompletionSource. El uso de esta clase abre una interesante posibilidad de diseñar diversas APIs basadas en Tasks que no consumen hilos. Y como recordamos, los hilos son un recurso costoso y su número es limitado (principalmente por la cantidad de RAM). Esta limitación puede alcanzarse fácilmente al desarrollar, por ejemplo, una aplicación web con alta carga y lógica empresarial compleja. Analicemos las oportunidades de las cuales hablo en la implementación de trucos como Long-Polling.

En breve, la esencia del truco es la siguiente: necesitas obtener información de la API sobre ciertos eventos que ocurren de su lado; sin embargo, la API, por alguna razón, no puede notificar sobre el evento, solo puede devolver el estado. Un ejemplo de esto son todas las API construidas sobre HTTP antes de la llegada de WebSockets o en caso de no poder utilizar esta tecnología por alguna razón. El cliente puede consultar al servidor HTTP. El servidor HTTP no puede provocar la comunicación con el cliente por sí mismo. Una solución simple es realizar polling al servidor por un temporizador, pero esto crea una carga adicional en el servidor y un retraso adicional en promedio de TimerInterval / 2. Para evitar esto, se inventó un truco llamado Long Polling, que implica retrasar la respuesta del servidor hasta que se agote el Timeout o ocurra el evento. Si ocurrió el evento, se procesa; si no, la solicitud se envía de nuevo.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Pero esta solución se mostrará terrible en cuanto el número de clientes esperando el evento aumente, ya que cada cliente en espera de un evento ocupa un hilo completo. Además, obtenemos un retraso adicional de 1 ms al activarse el evento. En la mayoría de los casos, esto no es significativo, pero ¿por qué empeorar el software más de lo necesario? Si se elimina Thread.Sleep(1), sobrecargaremos un núcleo del procesador al 100% sin necesidad, girando en un ciclo inútil. Usando TaskCompletionSource, se puede reescribir fácilmente este código y resolver todos los problemas mencionados anteriormente:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource> tasks;

    public async Task AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource();
        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);
    }
}

Este código no está listo para producción y es solo una demostración. Para usarlo en casos reales, también se debe, al menos, manejar la situación cuando un mensaje llega en un momento inesperado: en tal caso, el método AcceptMessageAsync debe devolver un Task ya completado. Si este caso es el más frecuente, también se puede considerar el uso de ValueTask.

Al recibir una solicitud de mensaje, creamos y colocamos en el diccionario TaskCompletionSource, y luego esperamos a que ocurra lo primero: que expire el intervalo de tiempo asignado o que se reciba un mensaje.

ValueTask: para qué y cómo

Los operadores async/await, al igual que el operador yield return, generan una máquina de estados a partir del método, lo que significa la creación de un nuevo objeto. Esto casi siempre no es importante, pero en raras ocasiones puede causar problemas. Esta situación puede darse cuando un método se llama con mucha frecuencia, es decir, decenas y cientos de miles de veces por segundo. Si dicho método está escrito de tal manera que en la mayoría de los casos devuelve el resultado evitando todos los métodos await, .NET proporciona una herramienta para optimizar esto: la estructura ValueTask. Para entenderlo mejor, consideremos un ejemplo de su uso: hay un caché al que accedemos con mucha frecuencia. Algunos valores están presentes y los devolvemos directamente; si no lo están, vamos a algún tipo de E/S lenta para obtenerlos. Último queremos hacer esto de manera asíncrona, lo que significa que todo el método se vuelve asíncrono. Por lo tanto, una forma obvia de escribir el método sería la siguiente:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Debido al deseo de optimizar un poco y a un ligero temor sobre lo que generará Roslyn al compilar este código, se puede reescribir este ejemplo de la siguiente manera:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

De hecho, la solución óptima en este caso sería optimizar la ruta caliente, es decir, obtener el valor del diccionario sin asignaciones innecesarias y sin carga en el GC, mientras que en esos raros casos en que realmente necesitamos ir a E/S para obtener datos, todo permanecería más o menos como antes:

public ValueTask<string> GetById(int id) {

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

Analicemos más a fondo este fragmento de código: cuando hay un valor en el caché, creamos una estructura; de lo contrario, la tarea real se envolverá en una significativa. Al código que llama no le importa por qué camino se ejecutó este código: desde el punto de vista de la sintaxis de C#, ValueTask se comportará igual que un Task normal en este caso.

TaskSchedulers: gestión de estrategias para ejecutar Tasks

La siguiente API que me gustaría analizar es la clase TaskScheduler y sus derivados. Ya mencioné anteriormente que en TPL hay una opción para gestionar las estrategias de distribución de tareas entre hilos. Estas estrategias se definen en las subclases de la clase TaskScheduler. Prácticamente cualquier estrategia que se pueda necesitar se encontrará en la biblioteca ParallelExtensionsExtras, desarrollada por Microsoft, pero que no forma parte de .NET, sino que se ofrece como un paquete Nuget. Veamos brevemente algunas de ellas:

sobre los TaskSchedulers en el blog de Microsoft. Windows Para facilitar la depuración de todo lo relacionado con tareas en Visual Studio, hay una ventana de Tasks. En esta ventana se puede ver el estado actual de la tarea y acceder a la línea de código que se está ejecutando en este momento.

PLinq y la clase Parallel

.NET: Herramientas para trabajar con paralelismo y asincronía. Parte 1

PLinq и класс Parallel

Además de los Tareas y todo lo mencionado sobre .NET, hay dos herramientas interesantes: PLinq (Linq2Parallel) y la clase Parallel. La primera promete ejecutar todas las operaciones Linq en paralelo en múltiples hilos. El número de hilos se puede configurar utilizando el método de extensión WithDegreeOfParallelism. Desafortunadamente, a menudo PLinq en su modo de funcionamiento predeterminado no tiene suficiente información sobre el interior de su fuente de datos para garantizar una mejora significativa en la velocidad; por otro lado, el costo de intentar es muy bajo: solo necesita llamar al método AsParallel antes de la cadena de métodos Linq y realizar pruebas de rendimiento. Además, existe la posibilidad de pasar información adicional a PLinq sobre la naturaleza de su fuente de datos utilizando el mecanismo Partitions. Para más detalles, puede leer más. aquí y aquí.

La clase estática Parallel proporciona métodos para la iteración paralela de colecciones Foreach, ejecución de ciclos For y ejecución de varios delegados en paralelos mediante Invoke. La ejecución del hilo actual se detendrá hasta que se complete el cálculo. El número de hilos se puede configurar pasando ParallelOptions como el último argumento. También puede especificar TaskScheduler y CancellationToken con las opciones.

Conclusiones

Cuando comencé a escribir este artículo basado en mi presentación y la información que reuní durante el tiempo posterior, no esperaba que resultara ser tan extenso. Ahora, cuando el editor de texto que estoy utilizando me señala que voy por la página 15, quiero presentar un resumen intermedio. Otros trucos, API, herramientas visuales y posibles inconvenientes se tratarán en el siguiente artículo.

Conclusiones:

  • Es necesario conocer las herramientas para trabajar con hilos, asincronía y paralelismo para aprovechar los recursos de las PC modernas.
  • .NET tiene muchas herramientas diferentes para estos objetivos.
  • No todas aparecieron de inmediato, por lo que a menudo se puede encontrar legacy; sin embargo, hay formas de convertir API antiguas sin mucho esfuerzo.
  • El trabajo con hilos en .NET se presenta a través de las clases Thread y ThreadPool.
  • Los métodos Thread.Abort, Thread.Interrupt y la función TerminateThread de la API de Win32 son peligrosos y no se recomiendan. En su lugar, es mejor utilizar el mecanismo de CancellationTokens.
  • El flujo es un recurso valioso y su cantidad es limitada. Es necesario evitar situaciones en las que los flujos estén ocupados esperando eventos. Para ello, es conveniente utilizar la clase TaskCompletionSource.
  • Las herramientas más potentes y avanzadas de .NET para trabajar con paralelismo y asincronía son los Tasks.
  • Los operadores async/await de C# implementan el concepto de espera no bloqueante.
  • La distribución de los Tasks entre los hilos se puede gestionar mediante clases derivadas de TaskScheduler.
  • La estructura ValueTask puede ser útil para optimizar las rutas críticas y el tráfico de memoria.
  • Las ventanas de Tasks y Threads de Visual Studio proporcionan mucha información útil para depurar código multiproceso o asincrónico.
  • PLinq es una herramienta impresionante, pero puede que no tenga suficiente información sobre su fuente de datos; sin embargo, esto se puede solucionar con el mecanismo de particionamiento.
  • Continuará...

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster