Cómo realizar múltiples subprocesos de forma segura y eficiente en .NET – CloudSavvy IT

El subproceso múltiple se puede utilizar para acelerar drásticamente el rendimiento de su aplicación, pero ninguna aceleración es gratuita: la gestión de subprocesos paralelos requiere una programación cuidadosa y, sin las precauciones adecuadas, puede encontrarse con condiciones de carrera, interbloqueos e incluso bloqueos.

¿Qué dificulta el subproceso múltiple?

A menos que le indique a su programa lo contrario, todo su código se ejecuta en el «Subproceso principal». Desde el punto de entrada de su aplicación, recorre y ejecuta todas sus funciones una tras otra. Esto tiene un límite de rendimiento, ya que, obviamente, solo puede hacer tanto si tiene que procesar todo de uno en uno. La mayoría de las CPU modernas tienen seis o más núcleos con 12 o más subprocesos, por lo que queda rendimiento sobre la mesa si no los está utilizando.

Sin embargo, no es tan simple como «activar el subproceso múltiple». Solo las cosas específicas (como los bucles) pueden ser multiproceso correctamente, y hay muchas consideraciones a tener en cuenta al hacerlo.

La primera y más importante cuestión es condiciones de carrera. Estos suelen ocurrir durante las operaciones de escritura, cuando un subproceso modifica un recurso compartido por varios subprocesos. Esto conduce a un comportamiento en el que la salida del programa depende de qué hilo finaliza o modifica algo primero, lo que puede provocar un comportamiento aleatorio e inesperado.

Estos pueden ser muy, muy simples; por ejemplo, tal vez necesite llevar un recuento continuo de algo entre los bucles. La forma más obvia de hacer esto es crear una variable e incrementarla, pero esto no es seguro para subprocesos.

Esta condición de carrera ocurre porque no se trata simplemente de «agregar uno a la variable» en un sentido abstracto; la CPU está cargando el valor de number en el registro, agregando uno a ese valor y luego almacenando el resultado como el nuevo valor de la variable. No sabe que, mientras tanto, otro hilo también estaba tratando de hacer exactamente lo mismo y cargó un valor que pronto será incorrecto de number. Los dos hilos entran en conflicto, y al final del ciclo, number puede no ser igual a 100.

.NET proporciona una función para ayudar a gestionar esto: lock palabra clave. Esto no evita que se realicen cambios directamente, pero ayuda a administrar la simultaneidad al permitir que solo un subproceso a la vez obtenga el bloqueo. Si otro subproceso intenta ingresar una declaración de bloqueo mientras otro subproceso se está procesando, esperará hasta 300ms antes de continuar.

Solo puede bloquear tipos de referencia, por lo que un patrón común es crear un objeto de bloqueo de antemano y usarlo como sustituto para bloquear el tipo de valor.

Sin embargo, puede notar que ahora hay otro problema: interbloqueos. Este código es un ejemplo del peor de los casos, pero aquí, es casi exactamente lo mismo que hacer un for bucle (en realidad, un poco más lento, ya que los subprocesos y bloqueos adicionales son una sobrecarga adicional). Cada subproceso intenta obtener el bloqueo, pero solo uno a la vez puede tener el bloqueo, por lo que solo un subproceso a la vez puede ejecutar el código dentro del bloqueo. En este caso, ese es el código completo del bucle, por lo que la declaración de bloqueo elimina todos los beneficios del subproceso y solo hace que todo sea más lento.

Generalmente, desea bloquear según sea necesario siempre que necesite realizar escrituras. Sin embargo, querrá tener en cuenta la simultaneidad al elegir qué bloquear, porque las lecturas tampoco siempre son seguras para subprocesos. Si otro hilo está escribiendo en el objeto, leerlo desde otro hilo puede dar un valor incorrecto o hacer que una condición particular devuelva un resultado incorrecto.

Afortunadamente, hay algunos trucos para hacer esto correctamente, donde puede equilibrar la velocidad del subproceso múltiple mientras usa bloqueos para evitar las condiciones de carrera.

Use Interlocked para operaciones atómicas

Para operaciones básicas, usando el lock La declaración puede ser exagerada. Si bien es muy útil para bloquear antes de modificaciones complejas, es demasiado para algo tan simple como agregar o reemplazar un valor.

Entrelazado es una clase que envuelve algunas operaciones de memoria como sumar, reemplazar y comparar. Los métodos subyacentes se implementan a nivel de CPU y se garantiza que son atómicos y mucho más rápidos que el estándar. lock declaración. Querrá usarlos siempre que sea posible, aunque no reemplazarán por completo el bloqueo.

En el ejemplo anterior, reemplazando el bloqueo con una llamada a Interlocked.Add() Acelerará mucho la operación. Si bien este simple ejemplo no es más rápido que no usar Interlocked, es útil como parte de una operación más grande y sigue siendo una aceleración.

También hay Increment y Decrement por ++ y -- operaciones, lo que le ahorrará dos pulsaciones de teclas sólidas. Literalmente envuelven Add(ref count, 1) debajo del capó, por lo que no hay una aceleración específica para usarlos.

También puedes usar Intercambio, un método genérico que establecerá una variable igual al valor que se le pasa. Sin embargo, debe tener cuidado con este: si lo establece en un valor que calculó con el valor original, no es seguro para subprocesos, ya que el valor anterior podría haberse modificado antes de ejecutar Interlocked.Exchange.

CompareExchange comprobará la igualdad de dos valores y reemplazará el valor si son iguales.

Usar colecciones seguras para subprocesos

Las colecciones predeterminadas en System.Collections.Generic se pueden usar con subprocesos múltiples, pero no son completamente seguros para subprocesos. Microsoft proporciona implementaciones seguras para subprocesos de algunas colecciones en System.Collections.Concurrent.

Entre estos se encuentran los ConcurrentBag, una colección genérica desordenada, y ConcurrentDictionary, un diccionario seguro para subprocesos. Tambien hay colas y pilas concurrentes, y OrderablePartitioner, que puede dividir fuentes de datos ordenables como Listas en particiones separadas para cada hilo.

Mire para paralelizar bucles

A menudo, el lugar más fácil para realizar múltiples subprocesos es en bucles grandes y costosos. Si puede ejecutar varias opciones en paralelo, puede obtener una gran aceleración en el tiempo de ejecución general.

La mejor manera de manejar esto es con System.Threading.Tasks.Parallel. Esta clase proporciona reemplazos para for y foreach bucles que ejecutan los cuerpos del bucle en subprocesos separados. Es fácil de usar, aunque requiere una sintaxis ligeramente diferente:

Obviamente, el problema aquí es que debes asegurarte DoSomething() es seguro para subprocesos y no interfiere con ninguna variable compartida. Sin embargo, eso no siempre es tan fácil como reemplazar el bucle con un bucle paralelo, y en muchos casos debe lock objetos compartidos para realizar cambios.

Para solucionar algunos de los problemas con los interbloqueos, Parallel.For y Parallel.ForEach proporcionan funciones adicionales para tratar con el estado. Básicamente, no todas las iteraciones se ejecutarán en un subproceso separado; si tiene 1000 elementos, no creará 1000 subprocesos; hará tantos subprocesos como su CPU pueda manejar y ejecutará múltiples iteraciones por subproceso. Esto significa que si está calculando un total, no necesita bloquear para cada iteración. Simplemente puede pasar una variable subtotal y, al final, bloquear el objeto y realizar cambios una vez. Esto reduce drásticamente la sobrecarga en listas muy grandes.

Echemos un vistazo a un ejemplo. El siguiente código toma una gran lista de objetos y necesita serializar cada uno por separado en JSON, terminando con un List<string> de todos los objetos. La serialización JSON es un proceso muy lento, por lo que dividir cada elemento en varios subprocesos supone una gran aceleración.

Hay un montón de argumentos y mucho que desglosar aquí:

  • El primer argumento toma un IEnumerable, que define los datos sobre los que se repite. Este es un bucle ForEach, pero el mismo concepto funciona para los bucles For básicos.
  • La primera acción inicializa la variable subtotal local. Esta variable se compartirá en cada iteración del bucle, pero solo dentro del mismo hilo. Otros hilos tendrán sus propios subtotales. Aquí, lo estamos inicializando en una lista vacía. Si estuviera calculando un total numérico, podría return 0 aquí.
  • La segunda acción es el cuerpo del bucle principal. El primer argumento es el elemento actual (o el índice en un bucle For), el segundo es un objeto ParallelLoopState que puede usar para llamar .Break()y la última es la variable subtotal.
    • En este bucle, puede operar en el elemento y modificar el subtotal. El valor que devuelva reemplazará el subtotal para el siguiente ciclo. En este caso, serializamos el elemento en una cadena, luego agregamos la cadena al subtotal, que es una Lista.
  • Finalmente, la última acción toma el subtotal ‘resultado’ después de que todas las ejecuciones hayan finalizado, lo que le permite bloquear y modificar un recurso en función del total final. Esta acción se ejecuta una vez, al final, pero aún se ejecuta en un subproceso separado, por lo que deberá bloquear o usar métodos interbloqueados para modificar recursos. Aquí, llamamos AddRange() para agregar la lista de subtotales a la lista final.

Unity Multithreading

Una nota final: si está utilizando el motor de juego de Unity, querrá tener cuidado con los subprocesos múltiples. No puedes llamar a ninguna API de Unity o el juego se bloqueará. Es posible usarlo con moderación al realizar operaciones de API en el hilo principal y cambiar de un lado a otro cada vez que necesite paralelizar algo.

Principalmente, esto se aplica a las operaciones que interactúan con la escena o el motor de física. La matemática de Vector3 no se ve afectada y puede usarla desde un hilo separado sin problemas. También eres libre de modificar los campos y las propiedades de tus propios objetos, siempre que no llamen a ninguna operación de Unity bajo el capó.

Deja un comentario

En esta web usamos cookies para personalizar tu experiencia de usuario.    Política de cookies
Privacidad