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

Estoy publicando el artículo original sobre Habr, cuya traducción está publicada en el corporativo блоге.

La necesidad de hacer algo de forma asincrónica, sin esperar el resultado aquí y ahora, o dividir un gran trabajo entre varias unidades que lo realizan, existía antes de la llegada de las computadoras. Con su llegada, esta necesidad se volvió muy tangible. Ahora, en 2019, estoy escribiendo este artículo en una computadora portátil con un procesador Intel Core de 8 núcleos, en el que se ejecutan más de cien procesos en paralelo, e incluso más subprocesos. Cerca hay un teléfono un poco desgastado, comprado hace un par de años, tiene un procesador de 8 núcleos a bordo. Los recursos temáticos están llenos de artículos y vídeos en los que sus autores admiran los smartphones insignia de este año con procesadores de 16 núcleos. MS Azure proporciona una máquina virtual con un procesador de 20 núcleos y 128 TB de RAM por menos de 2 dólares la hora. Desafortunadamente, es imposible extraer el máximo y aprovechar este poder sin poder gestionar la interacción de los hilos.

Vocabulario

Proceso - Objeto del sistema operativo, espacio de direcciones aislado, contiene subprocesos.
Hilo - un objeto del sistema operativo, la unidad de ejecución más pequeña, parte de un proceso, los subprocesos comparten memoria y otros recursos entre ellos dentro de un proceso.
Multitarea - Propiedad del sistema operativo, la capacidad de ejecutar varios procesos simultáneamente
multinúcleo - una propiedad del procesador, la capacidad de utilizar varios núcleos para el procesamiento de datos
Multiprocesamiento - una propiedad de una computadora, la capacidad de trabajar físicamente simultáneamente con varios procesadores
subprocesos múltiples — una propiedad de un proceso, la capacidad de distribuir el procesamiento de datos entre varios subprocesos.
Paralelismo - realizar varias acciones físicamente simultáneamente por unidad de tiempo
asincronía — ejecución de una operación sin esperar a que finalice este procesamiento; el resultado de la ejecución puede procesarse más tarde.

Metáfora

No todas las definiciones son buenas y algunas necesitan explicación adicional, por lo que agregaré una metáfora sobre preparar el desayuno a la terminología presentada formalmente. Cocinar el desayuno en esta metáfora es un proceso.

Mientras preparo el desayuno por la mañana yo (CPU) vengo a la cocina (Ordenador). tengo 2 manos (Nucleos). Hay varios dispositivos en la cocina (IO): horno, hervidor, tostadora, frigorífico. Enciendo el gas, le pongo una sartén y le echo aceite sin esperar a que se caliente (asincrónicamente, IO-espera sin bloqueo), saco los huevos del frigorífico y los rompo en un plato, luego los bato con una mano (Tema #1), y segundo (Tema #2) sosteniendo el plato (Recurso Compartido). Ahora me gustaría encender la tetera, pero no tengo suficientes manos (hambre de hilo) Durante este tiempo se calienta la sartén (Procesando el resultado) en la que vierto lo que he batido. Cojo la tetera, la enciendo y estúpidamente observo cómo hierve el agua (Bloqueo-IO-Espera), aunque durante este tiempo podría haber lavado el plato donde batía la tortilla.

Yo hice una tortilla con solo 2 manos, y no tengo más, pero al mismo tiempo, en el momento de montar la tortilla, se realizaron 3 operaciones a la vez: batir la tortilla, sujetar 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 lo que muchas veces una solución efectiva es ocupar la CPU con algo mientras se reciben datos de IO.

Continuando con la metáfora:

  • Si en el proceso de preparar una tortilla también intentara cambiarme de ropa, este sería un ejemplo de multitarea. Un matiz importante: las computadoras lo hacen mucho mejor que las personas.
  • Una cocina con varios chefs, por ejemplo en un restaurante: un ordenador multinúcleo.
  • Muchos restaurantes en un patio de comidas en un centro comercial - centro de datos

Herramientas .NET

.NET es bueno trabajando con subprocesos, como con muchas otras cosas. Con cada nueva versión, introduce más y más herramientas nuevas para trabajar con ellos, nuevas capas de abstracción sobre los subprocesos del sistema operativo. Cuando trabajan con la construcción de abstracciones, los desarrolladores de marcos utilizan un enfoque que deja la oportunidad, cuando se utiliza una abstracción de alto nivel, de bajar uno o más niveles por debajo. La mayoría de las veces esto no es necesario; de hecho, abre la puerta a dispararse en el pie con una escopeta, pero a veces, en casos raros, puede ser la única forma de resolver un problema que no se resuelve en el nivel actual de abstracción. .

Por herramientas me refiero tanto a las interfaces de programación de aplicaciones (API) proporcionadas por el marco como a los paquetes de terceros, así como a soluciones de software completas que simplifican la búsqueda de cualquier problema relacionado con el código multiproceso.

Iniciando un hilo

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

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

El delegado se ejecutará en el hilo recién 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 transferir cualquier información local a la secuencia. 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 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 grupo. En .NET, el grupo de subprocesos es una pieza de ingeniería y los desarrolladores de Microsoft se han esforzado mucho para asegurarse de que funcione de manera óptima en una amplia variedad de escenarios.

Concepto general:

Desde el momento en que se inicia la aplicación, crea varios subprocesos en reserva en segundo plano y brinda la posibilidad de utilizarlos. Si los subprocesos se utilizan con frecuencia y en grandes cantidades, el grupo se expande para satisfacer las necesidades de la persona que llama. Cuando no hay subprocesos libres en el grupo en el momento adecuado, esperará a que regrese uno de los subprocesos o creará uno nuevo. De ello se deduce que el grupo de subprocesos es excelente para algunas acciones a corto plazo y no es adecuado para operaciones que se ejecutan como servicios durante toda la operación de la aplicación.

Para usar un subproceso del grupo, existe un método QueueUserWorkItem que acepta un delegado de tipo WaitCallback, que tiene la misma firma que ParametrizedThreadStart, y el parámetro que se le pasa realiza la misma función.

ThreadPool.QueueUserWorkItem(...);

El método de grupo de subprocesos menos conocido RegisterWaitForSingleObject se utiliza para organizar operaciones de IO sin bloqueo. El delegado pasado a este método será llamado cuando el WaitHandle pasado al método esté "Liberado".

ThreadPool.RegisterWaitForSingleObject(...)

.NET tiene un temporizador de subprocesos y se diferencia de los temporizadores de WinForms/WPF en que su controlador será llamado en un subproceso tomado del grupo.

System.Threading.Timer

También existe una forma bastante exótica de enviar un delegado para su ejecución a un subproceso del grupo: el método BeginInvoke.

DelegateInstance.BeginInvoke

Me gustaría detenerme brevemente en la función a la que se pueden llamar muchos de los métodos anteriores: CreateThread desde Kernel32.dll API Win32. Existe una manera, gracias al mecanismo de métodos externos, de llamar a esta función. He visto una llamada así sólo una vez en un terrible ejemplo de código heredado, y la motivación del autor que hizo exactamente esto sigue siendo un misterio para mí.

Kernel32.dll CreateThread

Ver y depurar subprocesos

Los subprocesos creados por usted, todos los componentes de terceros y el grupo .NET se pueden ver en la ventana Subprocesos de Visual Studio. Esta ventana solo mostrará información del hilo cuando la aplicación esté en depuración y en modo de interrupción. Aquí puede ver cómodamente los nombres de las pilas y las prioridades de cada subproceso y cambiar la depuración a un subproceso específico. Usando la propiedad Prioridad de la clase Thread, puede establecer la prioridad de un subproceso, que OC y CLR percibirán como una recomendación al dividir el tiempo del procesador entre subprocesos.

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

Biblioteca de tareas paralelas

La biblioteca paralela de tareas (TPL) se introdujo en .NET 4.0. Ahora es la herramienta estándar y principal para trabajar con asincronía. Cualquier código que utilice un enfoque antiguo se considera heredado. La unidad básica de TPL es la clase Task del espacio de nombres System.Threading.Tasks. Una tarea es una abstracción sobre un hilo. Con la nueva versión del lenguaje C#, tenemos una forma elegante de trabajar con Tareas: operadores asíncronos/en espera. Estos conceptos hicieron posible escribir código asincrónico como si fuera simple y sincrónico, esto hizo posible que incluso personas con poca comprensión del funcionamiento interno de los hilos escribieran aplicaciones que los usan, aplicaciones que no se congelan al realizar operaciones largas. El uso de async/await es un tema para uno o incluso varios artículos, pero intentaré entenderlo en pocas oraciones:

  • async es un modificador de un método que devuelve Task o void
  • y await es un operador de tarea en espera sin bloqueo.

Una vez más: el operador de espera, en el caso general (hay excepciones), liberará aún más el hilo de ejecución actual, y cuando la Tarea finalice su ejecución, y el hilo (de hecho, sería más correcto decir el contexto , pero hablaremos de eso más adelante) continuará ejecutando el método. Dentro de .NET, este mecanismo se implementa de la misma manera que el retorno de rendimiento, cuando el método escrito se convierte en una clase completa, que es una máquina de estados y se puede ejecutar en partes separadas dependiendo de estos estados. Cualquiera interesado puede escribir cualquier código simple usando asynс/await, compilar y ver el ensamblado usando JetBrains dotPeek con el código generado por el compilador habilitado.

Veamos las opciones para iniciar y usar Task. En el ejemplo de código siguiente, creamos una nueva tarea que no hace nada útil (Thread.Sleep (10000)), pero en la vida real esto debería ser un trabajo complejo que requiere un uso intensivo 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 varias opciones:

  • LongRunning es un indicio de que la tarea no se completará rápidamente, lo que significa que puede valer la pena considerar no tomar un hilo del grupo, sino crear uno separado para esta tarea para no dañar a otros.
  • AttachedToParent: las tareas se pueden organizar en una jerarquía. Si se utilizó esta opción, entonces la tarea puede estar en un estado en el que ella misma se haya completado y esté esperando la ejecución de sus hijos.
  • PreferFairness: significa que sería mejor ejecutar las tareas enviadas para su ejecución antes que las enviadas más tarde. Pero esto es sólo una recomendación y los resultados no están garantizados.

El segundo parámetro pasado al método es CancellationToken. Para manejar correctamente la cancelación de una operación después de que haya comenzado, el código que se ejecuta debe completarse con comprobaciones para el estado CancellationToken. Si no hay comprobaciones, entonces el método Cancelar llamado en el objeto CancellationTokenSource podrá detener la ejecución de la Tarea solo antes de que comience.

El último parámetro es un objeto planificador de tipo TaskScheduler. Esta clase y sus descendientes están diseñados para controlar estrategias para distribuir Tareas entre subprocesos; de forma predeterminada, la Tarea se ejecutará en un subproceso aleatorio del grupo.

El operador de espera se aplica a la tarea creada, lo que significa que el código escrito después, si lo hay, se ejecutará en el mismo contexto (a menudo esto significa en el mismo hilo) que el código anterior a la espera.

El método está marcado como async void, lo que significa que puede usar el operador de espera, pero el código de llamada no podrá esperar la ejecución. Si dicha característica es necesaria, entonces el método debe devolver Tarea. Los métodos marcados como async void son bastante comunes: por regla general, son controladores de eventos u otros métodos que funcionan según el principio de disparar y olvidar. Si no solo necesita dar la oportunidad de esperar hasta el final de la ejecución, sino también devolver el resultado, entonces debe usar Task.

En la tarea que devolvió el método StartNew, así como en cualquier otra, puede llamar al método ConfigureAwait con el parámetro false, luego la ejecución después de await continuará no en el contexto capturado, sino en uno arbitrario. Esto siempre debe hacerse cuando el contexto de ejecución no es importante para el código después de la espera. Esta también es una recomendación de MS al escribir código que se entregará empaquetado en una biblioteca.

Detengámonos un poco más en cómo se puede esperar a que se complete una Tarea. A continuación se muestra un ejemplo de código, con comentarios sobre cuándo la expectativa se cumple condicionalmente bien y cuándo se hace condicionalmente mal.

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, esperamos a que la Tarea se complete sin bloquear el subproceso que realiza la llamada; volveremos a procesar el resultado solo cuando ya esté allí; hasta entonces, el subproceso que realiza la llamada se deja a su suerte.

En la segunda opción, bloqueamos el hilo de llamada hasta que se calcule el resultado del método. Esto es malo no sólo porque hemos ocupado un hilo, un recurso tan valioso del programa, con simple inactividad, sino también porque si el código del método que llamamos contiene await, y el contexto de sincronización requiere regresar al hilo que llama después await, entonces obtendremos un punto muerto: el hilo que llama espera mientras se calcula el resultado del método asincrónico, el método asincrónico intenta en vano continuar su ejecución en el hilo que llama.

Otra desventaja de este enfoque es el complicado manejo de errores. El hecho es que los errores en el código asincrónico cuando se usa async/await son muy fáciles de manejar: se comportan igual que si el código fuera síncrono. Mientras que si aplicamos el exorcismo de espera sincrónica a una Tarea, la excepción original se convierte en una AggregateException, es decir Para manejar la excepción, tendrá que examinar el tipo InnerException y escribir una cadena if dentro de un bloque catch o usar la construcción catch when, en lugar de la cadena de bloques catch que es más familiar en el mundo C#.

El tercer y último ejemplo también está marcado como malo por la misma razón y contiene los mismos problemas.

Los métodos WhenAny y WhenAll son extremadamente convenientes para esperar un grupo de Tareas; envuelven un grupo de Tareas en una, que se activará cuando se active por primera vez una Tarea del grupo o cuando todas hayan completado su ejecución.

Deteniendo hilos

Por diversas razones, puede ser necesario detener el flujo una vez iniciado. hay muchas maneras de hacer esto. La clase Thread tiene dos métodos con nombres apropiados: aborto involuntario и Interrumpir. No se recomienda el uso del primero, porque después de llamarlo en cualquier momento aleatorio, durante el procesamiento de cualquier instrucción, se lanzará una excepción Excepción abortada del hilo. No espera que se produzca una excepción de este tipo al incrementar cualquier variable entera, ¿verdad? Y cuando se utiliza este método, esta es una situación muy real. Si necesita evitar que CLR genere dicha excepción en una determinada sección de código, puede envolverla en llamadas Hilo.BeginCriticalRegión, Thread.EndCriticalRegión. Cualquier código escrito en un bloque finalmente está incluido en dichas llamadas. Por esta razón, en lo más profundo del código framework puedes encontrar bloques con un try vacío, pero no con un finalmente vacío. Microsoft desaconseja tanto este método que no lo incluyó en .net core.

El método Interrumpir funciona de forma más predecible. Puede interrumpir el hilo con una excepción. Excepción interrumpida por subproceso solo durante aquellos momentos en que el hilo está en estado de espera. Entra en este estado mientras se cuelga mientras espera WaitHandle, se bloquea o después de llamar a Thread.Sleep.

Ambas opciones descritas anteriormente son malas debido a su imprevisibilidad. La solución es utilizar una estructura. Token de cancelación y clase CancelaciónTokenSource. El punto es este: se crea una instancia de la clase CancellationTokenSource y solo quien la posee puede detener la operación llamando al método Cancelar. Sólo el CancellationToken se pasa a la operación misma. Los propietarios de CancellationToken no pueden cancelar la operación ellos mismos, solo pueden verificar si la operación se ha cancelado. Hay una propiedad booleana para esto. Es una cancelación solicitada y metodo LanzarSiCancelarSolicitado. Este último lanzará una excepción. Excepción de tarea cancelada si se llamó al método Cancel en la instancia de CancellationToken que se está repitiendo. Y este es el método que recomiendo usar. Esta es una mejora con respecto a las opciones anteriores al obtener control total sobre en qué punto se puede cancelar una operación de excepción.

La opción más brutal para detener un hilo es llamar a la función TerminateThread de la API de Win32. El comportamiento del CLR después de llamar a esta función puede ser impredecible. En MSDN está escrito lo siguiente sobre esta función: “TerminateThread es una función peligrosa que sólo debe usarse en los casos más extremos. “

Conversión de API heredada a basada en tareas utilizando el método FromAsync

Si tiene la suerte de trabajar en un proyecto que se inició después de que se introdujeron las Tareas y dejó de causar un horror silencioso a la mayoría de los desarrolladores, entonces no tendrá que lidiar con muchas API antiguas, tanto de terceros como de su equipo. ha torturado en el pasado. Por suerte el equipo de .NET Framework nos cuidó, aunque quizás el objetivo era cuidarnos nosotros mismos. Sea como fuere, .NET tiene una serie de herramientas para convertir sin problemas el código escrito en viejos enfoques de programación asincrónica al nuevo. Uno de ellos es el método FromAsync de TaskFactory. En el siguiente ejemplo de código, envuelvo los antiguos métodos asíncronos de la clase WebRequest en una Tarea usando este método.

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

Este es solo un ejemplo y es poco probable que tenga que hacer esto con tipos integrados, pero cualquier proyecto antiguo simplemente está repleto de métodos BeginDoSomething que devuelven métodos IAsyncResult y EndDoSomething que lo reciben.

Convierta la API heredada a basada en tareas utilizando la clase TaskCompletionSource

Otra herramienta importante a considerar es la clase. Fuente de finalización de tarea. En términos de funciones, propósito y principio de funcionamiento, puede recordar algo al método RegisterWaitForSingleObject de la clase ThreadPool, sobre el que escribí anteriormente. Con esta clase, puede empaquetar fácil y cómodamente API asincrónicas antiguas en Tareas.

Dirás que ya te he hablado del método FromAsync de la clase TaskFactory destinado a estos fines. Aquí tendremos que recordar toda la historia del desarrollo de modelos asincrónicos en .net que Microsoft ha ofrecido durante los últimos 15 años: antes del Patrón Asincrónico Basado en Tareas (TAP), existía el Patrón de Programación Asincrónica (APP), que se trataba de métodos Comenzarhacer algo regresando Resultado IAsync y metodos FinDoSomething que lo acepta y para el legado de estos años el método FromAsync es simplemente perfecto, pero con el tiempo fue reemplazado por el patrón asíncrono basado en eventos (EAP), que suponía que se generaría un evento cuando se completara la operación asincrónica.

TaskCompletionSource es perfecto para empaquetar tareas y API heredadas creadas en torno al modelo de eventos. La esencia de su trabajo es la siguiente: un objeto de esta clase tiene una propiedad pública de tipo Task, cuyo estado se puede controlar mediante los métodos SetResult, SetException, etc. de la clase TaskCompletionSource. En los lugares donde se aplicó el operador de espera a esta tarea, se ejecutará o fallará con una excepción según el método aplicado a TaskCompletionSource. Si aún no está claro, veamos este ejemplo de código, donde alguna API EAP antigua está incluida en una Tarea usando TaskCompletionSource: cuando se activa el evento, la Tarea se colocará en el estado Completado y el método que aplicó el operador de espera a esta Tarea reanudará su ejecución habiendo recibido el objeto 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;
}

Consejos y trucos de TaskCompletionSource

Ajustar API antiguas no es todo lo que se puede hacer con TaskCompletionSource. El uso de esta clase abre una interesante posibilidad de diseñar varias API sobre Tareas que no ocupen subprocesos. Y el streaming, como recordamos, es un recurso caro y su número está limitado (principalmente por la cantidad de RAM). Esta limitación se puede lograr fácilmente desarrollando, por ejemplo, una aplicación web cargada con una lógica empresarial compleja. Consideremos las posibilidades de las que estoy hablando al implementar un truco como Long-Polling.

En resumen, la esencia del truco es la siguiente: necesita recibir información de la API sobre algunos eventos que ocurren en su lado, mientras que la API, por alguna razón, no puede informar el evento, solo puede devolver el estado. Un ejemplo de esto son todas las API creadas sobre HTTP antes de los tiempos de WebSocket o cuando por alguna razón era imposible utilizar esta tecnología. El cliente puede preguntar al servidor HTTP. El servidor HTTP no puede iniciar por sí mismo la comunicación con el cliente. Una solución simple es sondear el servidor usando un temporizador, pero esto crea una carga adicional en el servidor y un retraso adicional en promedio TimerInterval / 2. Para solucionar esto, se inventó un truco llamado Long Polling, que implica retrasar la respuesta de el servidor hasta que expire el tiempo de espera o se produzca un evento. Si el evento ha ocurrido, entonces se procesa, si no, entonces se envía nuevamente la solicitud.

while(!eventOccures && !timeoutExceeded)  {

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

Pero esta solución resultará terrible en cuanto aumente el número de clientes que esperan el evento, porque... Cada uno de estos clientes ocupa un hilo completo esperando un evento. Sí, y obtenemos un retraso adicional de 1 ms cuando se activa el evento; la mayoría de las veces esto no es significativo, pero ¿por qué empeorar el software de lo que puede ser? Si eliminamos Thread.Sleep(1), en vano cargaremos un núcleo de procesador 100% inactivo, girando en un ciclo inútil. Usando TaskCompletionSource puedes rehacer fácilmente este código y resolver todos los problemas identificados anteriormente:

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);
    }
}

Este código no está listo para producción, es solo una demostración. Para usarlo en casos reales, también necesita, como mínimo, manejar la situación cuando un mensaje llega en un momento en el que nadie lo espera: en este caso, el método AsseptMessageAsync debería devolver una Tarea ya completada. Si este es el caso más común, entonces puedes pensar en usar ValueTask.

Cuando recibimos una solicitud de mensaje, creamos y colocamos un TaskCompletionSource en el diccionario, y luego esperamos lo que sucede primero: el intervalo de tiempo especificado expira o se recibe un mensaje.

ValueTask: por qué y cómo

Los operadores asíncronos/await, al igual que el operador de retorno de rendimiento, generan una máquina de estado a partir del método, y esto es la creación de un nuevo objeto, lo cual casi siempre no es importante, pero en casos raros puede crear un problema. Este caso puede ser un método que se llama con mucha frecuencia, estamos hablando de decenas y cientos de miles de llamadas por segundo. Si dicho método está escrito de tal manera que en la mayoría de los casos devuelve un resultado sin pasar por todos los métodos de espera, entonces .NET proporciona una herramienta para optimizar esto: la estructura ValueTask. Para que quede claro, veamos un ejemplo de su uso: hay un caché al que acudimos muy a menudo. Hay algunos valores en él y luego simplemente los devolvemos; si no, entonces vamos a alguna IO lenta para obtenerlos. Quiero hacer esto último de forma asincrónica, lo que significa que todo el método resulta ser asincrónico. Por tanto, la forma obvia de escribir el método es 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 al ligero temor de lo que generará Roslyn al compilar este código, puedes 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 activa, es decir, obtener un valor del diccionario sin asignaciones ni cargas innecesarias en el GC, mientras que en esos raros casos en los que todavía necesitamos ir a IO para obtener datos , todo seguirá siendo un más/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));
}

Echemos un vistazo más de cerca a este fragmento de código: si hay un valor en el caché, creamos una estructura; de lo contrario, la tarea real se envolverá en una significativa. Al código de llamada no le importa en qué ruta se ejecutó este código: ValueTask, desde el punto de vista de la sintaxis de C#, se comportará igual que una tarea normal en este caso.

TaskSchedulers: gestión de estrategias de lanzamiento de tareas

La siguiente API que me gustaría considerar es la clase Programador de tareas y sus derivados. Ya mencioné anteriormente que TPL tiene la capacidad de gestionar estrategias para distribuir tareas entre subprocesos. Estas estrategias se definen en los descendientes de la clase TaskScheduler. Casi cualquier estrategia que pueda necesitar se puede encontrar en la biblioteca. Extensiones paralelasExtras, desarrollado por Microsoft, pero no forma parte de .NET, sino que se suministra como un paquete Nuget. Veamos brevemente algunos de ellos:

  • Programador de tareas de hilo actual — ejecuta tareas en el hilo actual
  • Programador de tareas de nivel de concurrencia limitado — limita el número de tareas ejecutadas simultáneamente por el parámetro N, que se acepta en el constructor
  • Programador de tareas ordenadas — se define como LimitedConcurrencyLevelTaskScheduler(1), por lo que las tareas se ejecutarán de forma secuencial.
  • TrabajoRoboTareaProgramador - implementos robo de trabajo enfoque para la distribución de tareas. Esencialmente es un ThreadPool separado. Resuelve el problema de que en .NET ThreadPool es una clase estática, una para todas las aplicaciones, lo que hace que su sobrecarga o uso incorrecto en una parte del programa pueda provocar efectos secundarios en otra. Además, es extremadamente difícil entender la causa de tales defectos. Eso. Puede que sea necesario utilizar WorkStealingTaskSchedulers independientes en partes del programa donde el uso de ThreadPool puede ser agresivo e impredecible.
  • Programador de tareas en cola — le permite realizar tareas de acuerdo con las reglas de la cola de prioridad
  • ThreadPerTaskScheduler — crea un hilo separado para cada tarea que se ejecuta en él. Puede resultar útil para tareas que tardan un tiempo impredeciblemente largo en completarse.

Hay un buen detalle artículo sobre TaskSchedulers en el blog de Microsoft.

Para una depuración cómoda de todo lo relacionado con las Tareas, Visual Studio tiene una ventana de Tareas. En esta ventana puede ver el estado actual de la tarea y saltar a la línea de código que se está ejecutando actualmente.

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

PLinq y la clase paralela

Además de Tareas y todo lo dicho sobre ellas, existen dos herramientas más interesantes en .NET: PLinq (Linq2Parallel) y la clase Parallel. El primero promete la ejecución paralela de todas las operaciones de Linq en múltiples subprocesos. La cantidad de subprocesos se puede configurar utilizando el método de extensión WithDegreeOfParallelism. Desafortunadamente, la mayoría de las veces Plinq en su modo predeterminado no tiene suficiente información sobre las partes internas de su fuente de datos para proporcionar una ganancia de velocidad significativa; por otro lado, el costo de intentarlo es muy bajo: solo necesita llamar al método AsParallel antes. la cadena de métodos Linq y ejecutar pruebas de rendimiento. Además, es posible pasar información adicional a PLinq sobre la naturaleza de su fuente de datos utilizando el mecanismo de Particiones. Puedes leer más aquí и aquí.

La clase estática Parallel proporciona métodos para iterar a través de una colección Foreach en paralelo, ejecutar un bucle For y ejecutar múltiples delegados en Invoke en paralelo. La ejecución del hilo actual se detendrá hasta que se completen los cálculos. El número de subprocesos se puede configurar pasando ParallelOptions como último argumento. También puede especificar TaskScheduler y CancellationToken usando opciones.

Hallazgos

Cuando comencé a escribir este artículo basándome en los materiales de mi informe y la información que recopilé durante mi trabajo posterior, no esperaba que hubiera tanto. Ahora, cuando el editor de texto en el que estoy escribiendo este artículo me diga con reproche que la página 15 ya no existe, resumiré los resultados provisionales. En el próximo artículo se tratarán otros trucos, API, herramientas visuales y trampas.

Conclusiones:

  • Es necesario conocer las herramientas para trabajar con subprocesos, asincronía y paralelismo para poder utilizar los recursos de las PC modernas.
  • .NET tiene muchas herramientas diferentes para estos fines.
  • No todas aparecieron a la vez, por lo que a menudo puedes encontrar las heredadas; sin embargo, hay formas de convertir API antiguas sin mucho esfuerzo.
  • Trabajar con subprocesos en .NET está representado por las clases Thread y ThreadPool
  • Los métodos Thread.Abort, Thread.Interrupt y Win32 API TerminateThread son peligrosos y no se recomienda su uso. En su lugar, es mejor utilizar el mecanismo CancellationToken.
  • El flujo es un recurso valioso y su oferta es limitada. Deben evitarse situaciones en las que los hilos estén ocupados esperando eventos. Para esto es conveniente utilizar la clase TaskCompletionSource
  • Las herramientas .NET más potentes y avanzadas para trabajar con paralelismo y asincronía son Tareas.
  • Los operadores async/await de c# implementan el concepto de espera sin bloqueo
  • Puede controlar la distribución de tareas entre subprocesos utilizando clases derivadas de TaskScheduler
  • La estructura ValueTask puede resultar útil para optimizar las rutas activas y el tráfico de memoria.
  • Las ventanas de Tareas y Subprocesos de Visual Studio proporcionan mucha información útil para depurar código asincrónico o de subprocesos múltiples.
  • PLinq es una herramienta interesante, pero es posible que no tenga suficiente información sobre su fuente de datos, pero esto se puede solucionar utilizando el mecanismo de partición
  • To be continued ...

Fuente: habr.com

Añadir un comentario