Puede necesitarlos más a menudo cuando parece. Por ejemplo, cuando realiza un desarrollo web del lado del servidor, se encuentra en el contexto de subprocesos múltiples porque cada solicitud se ejecuta en un subproceso independiente y, si tiene un servicio único en su aplicación, debe asegurarse de que todo el código del servicio sea a salvo de amenazas. En el desarrollo de la interfaz de usuario (WPF, Xamarin, lo que sea), siempre tenemos tareas principales y en segundo plano, y si un usuario y un servicio en segundo plano pueden modificar una colección desde la interfaz de usuario, debe asegurarse de que su código sea seguro para subprocesos.
Comencemos con un ejemplo simple.
if(!dictionary.KeyExists(key)) { dictionary.Add(key, value); }
Y echemos un vistazo a lo que puede suceder en el escenario de dos hilos:
Al ejecutar este código en varios subprocesos, puede haber una posibilidad en ambos subprocesos if
el caso pasa, pero solo un subproceso podrá modificar el diccionario y obtendrá ArgumentException
(un elemento con la misma clave ya existe en el diccionario).
Para trabajar con colecciones en un entorno de subprocesos múltiples en .NET
, tenemos un espacio de nombres System.Collections.Concurrent
. Echemos un vistazo muy breve al respecto.
System.Collections.Concurrent
Namespace? ConcurrentDictionary
: un diccionario seguro para subprocesos de uso general al que se puede acceder mediante varios subprocesos al mismo tiempo
ConcurrentStack
: una colección de último en entrar, primero en salir (LIFO) segura para subprocesos
ConcurrentQueue
: una colección FIFO (primero en entrar, primero en salir) segura para subprocesos
ConcurrentBag
: una colección desordenada de objetos segura para subprocesos. Este tipo mantiene una colección separada para cada subproceso para agregar y obtener elementos para que tengan un mejor rendimiento cuando el productor y el consumidor residen en el mismo subproceso.
BlockingCollection
: proporciona adición y eliminación simultáneas de elementos de varios subprocesos con los métodos Add
y Take
(con sobrecargas TryAdd
y TryTake
). También tiene capacidades de delimitación y bloqueo, lo que significa que puede establecer la capacidad máxima de la colección, y los productores se bloquearán cuando se alcance una cantidad máxima de elementos para evitar un consumo excesivo de memoria.
BlockingCollection
es un contenedor para ConcurrentStack
, ConcurrentQueue
, ConcurrentBag
. De forma predeterminada, utiliza ConcurrentStack
bajo el capó, pero puede proporcionar una colección más adecuada para su caso de uso durante la inicialización.
Todas estas colecciones ( BlockingCollection
, ConcurrentStack
, ConcurrentQueue
, ConcurrentBag
) implementan la interfaz IProducerConsumerCollection
, por lo que siempre intente usarla y podrá cambiar fácilmente entre diferentes tipos de colecciones.
También hay Partitioner
, OrderablePartitioner
, EnumerablePartitionerOptions
, que utiliza Parallel.ForEach
para la segmentación de colecciones.
Ahora profundicemos un poco más y veamos el principal beneficio que ofrecen las recopilaciones concurrentes.
Echemos un vistazo a otro ejemplo: el método Enqueue
de la implementación de la cola genérica estándar en .NET
// Adds item to the tail of the queue. public void Enqueue(T item) { if (_size == _array.Length) { Grow(_size + 1); } _array[_tail] = item; MoveNext(ref _tail); _size++; _version++; }
Queue <T> usa una matriz para almacenar elementos y cambia el tamaño de esta matriz cuando es necesario. Además, utiliza las propiedades _head
y _tail
para los índices desde los que quitar o poner en cola los elementos, respectivamente. Del código, vemos que Enqueue
consta de varios pasos. Verificamos la longitud de la matriz y la redimensionamos si es necesario, luego almacenamos el elemento en la matriz y actualizamos las propiedades _tail
y _size
. Por así decirlo, no es una operación atómica .
Por ejemplo, el subproceso 1 asigna un valor a _array[_tail]
y, mientras modifica la propiedad _tail
, el subproceso 2 asigna otro valor al mismo índice _tail
y terminamos con un estado inconsistente de nuestra colección.
A diferencia del estándar, las colecciones concurrentes garantizan la integridad de una colección en un entorno de subprocesos múltiples. Pero esto tiene un precio.
Las colecciones simultáneas tendrán menos rendimiento que las colecciones estándar en un entorno de un solo subproceso. Y el peor rendimiento que obtendrá luego de acceder a un estado agregado de una colección concurrente. El estado agregado es un valor que requiere acceso exclusivo a todos los elementos de la colección (por ejemplo, las propiedades .Count
o .IsEmpty
). Las colecciones concurrentes usan diferentes técnicas para optimizar el bloqueo (bloqueos granulares, gestión de colecciones separadas para diferentes subprocesos), pero para consultar el estado agregado, debe bloquear toda la colección, lo que podría bloquear múltiples subprocesos. Por lo tanto, evite consultar el estado agregado con demasiada frecuencia.
En ambos ejemplos, ya hemos visto que el resultado de una operación depende del orden en que los hilos hacen su trabajo. Este tipo de problemas se denominan condiciones de carrera . Y las colecciones concurrentes tienen una API específica para minimizar las condiciones de carrera. Echemos un vistazo a este ejemplo de un solo hilo:
if (dictionary.ContainsKey(key)) { dictionary[key] += 1; } else { dictionary.Add(key, 1); }
Ya debe comprender que este código puede fallar en diferentes lugares si se ejecuta en un entorno de subprocesos múltiples. Para tratar estos casos, el diccionario concurrente tiene el método AddOrUpdate
, que se puede usar así:
var newValue = dictionary .AddOrUpdate(key, 1, (itemKey, itemValue) => itemValue + 1)
Aquí tenemos un delegado como tercer parámetro del método AddOrUpdate
. Uno podría esperar que AddOrUpdate
sea una operación atómica, y no tendremos ningún problema aquí. Aunque esta operación es realmente atómica, usa TryOrUpdate
bajo el capó, y si este último no puede actualizar el valor actual (por ejemplo, el valor ya se actualizó desde otro hilo), entonces el delegado se ejecutará nuevamente con un nuevo itemValue
. Por lo tanto, debemos recordar que el delegado se puede ejecutar varias veces y que no debe contener efectos secundarios ni una lógica que dependa de varias ejecuciones.
Para terminar, deberíamos decir que su mejor opción es alejarse de la concurrencia tanto como sea posible, pero cuando no es posible, las colecciones concurrentes pueden ser útiles, aunque de ninguna manera son una varita mágica.