С colecciones actuales en 10 minutos

imagen
Foto de Robert V. Ruggiero

El tema no es nuevo. Pero haciendo la pregunta "¿qué son las colecciones concurrentes y cuándo usarlas?" en una entrevista o revisión de código, casi siempre recibo una respuesta que consiste en una oración: "nos protegen completamente de las condiciones de carrera" (lo cual es imposible incluso en teoría). O: "es como colecciones ordinarias, pero todo lo que hay dentro está cerrado", lo que tampoco se corresponde con la realidad.

El propósito de este artículo es distinguir el tema en 10 minutos. Será útil para conocer rápidamente algunas sutilezas. O para refrescar tu memoria antes de la entrevista.

En primer lugar, veremos rápidamente el contenido del espacio de nombres System.Collections.Concurrent . Luego discutimos las principales diferencias entre las colecciones concurrentes y clásicas, tenga en cuenta algunos puntos no obvios. En conclusión, discutimos las posibles dificultades y cuándo vale la pena usar qué tipos de colecciones.

Qué hay en System.Collections.Concurrent


Intellisense te dice un poco:

imagen

Discutamos brevemente el propósito de cada clase.

ConcurrentDictionary : una colección de uso general segura para subprocesos aplicable a una amplia gama de escenarios.

ConcurrentBag, ConcurrentStack, ConcurrentQueue : Special Purpose Collections. "Especialidad" consta de los siguientes puntos:

  • Falta de API para acceder a un elemento arbitrario
  • Stack and Queue (como todos sabemos) tienen un orden dado de agregar y extraer elementos
  • ConcurrentBag para cada hilo mantiene su propia colección para agregar elementos. Al recuperar, "roba" elementos de una secuencia vecina si la colección está vacía para la secuencia actual

IProducerConsumerCollection : contrato utilizado por la clase BlockingCollection (ver más abajo). Implementado por las colecciones ConcurrentStack , ConcurrentQueue y ConcurrentBag .

BlockingCollection : se usa en escenarios cuando algunas secuencias llenan una colección, mientras que otras extraen elementos de ella. Un ejemplo típico es una cola de tareas reabastecida. Si la colección está vacía en el momento de la solicitud del siguiente elemento, el lector pasa al estado de espera del nuevo elemento (sondeo). Al llamar al método CompleteAdding () , podemos indicar que la colección ya no se repondrá, luego, cuando no se realice el sondeo de lectura. Puede verificar el estado de la colección utilizando las propiedades IsAddingCompleted ( verdadero si los datos ya no se agregarán) e IsCompleted ( verdadero si los datos ya no se agregarán y la colección está vacía).

Partitioner, OrderablePartitioner, EnumerablePartitionerOptions : construcciones básicas para implementar la segmentación de colecciones . Utilizado por el método Parallel.ForEach para especificar cómo distribuir elementos entre los subprocesos de procesamiento.

Más adelante en el artículo, nos centraremos en las colecciones: ConcurrentDictionary y ConcurrentBag / Stack / Queue .

Diferencias entre colecciones concurrentes y clásicas


Protección del estado interno.


Las colecciones clásicas están diseñadas teniendo en cuenta el máximo rendimiento, por lo que sus métodos de instancia no garantizan la seguridad del hilo.

Por ejemplo, eche un vistazo al código fuente del método Dictionary.Add .
Podemos ver las siguientes líneas (el código se simplifica para facilitar la lectura):

if (this._buckets == null) { int prime = HashHelpers.GetPrime(capacity); this._buckets = new int[prime]; this._entries = new Dictionary<TKey, TValue>.Entry[prime]; } 

Como podemos ver, el estado interno del diccionario no está protegido. Al agregar elementos de varios subprocesos, es posible el siguiente escenario:

  1. Hilo 1 llamado Agregar , la ejecución se detuvo inmediatamente después de ingresar la condición if
  2. El hilo 2 llamado Agregar , inicializó la colección, agregó el elemento
  3. La secuencia 1 volvió al trabajo, reinició la colección, destruyendo así los datos agregados por la secuencia 2.

Es decir, las colecciones clásicas no son adecuadas para grabar desde múltiples transmisiones.

La API tolera el estado actual de la colección.


Como sabemos, no se pueden agregar claves duplicadas al Diccionario . Si llamamos Agregar dos veces con la misma clave, la segunda llamada arrojará una Excepción Argument .

Esta protección es útil en escenarios de subproceso único. Pero con multihilo, no podemos estar seguros del estado actual de la colección. Naturalmente, las comprobaciones como las siguientes nos salvan solo cuando nos cerramos constantemente:

 if (!dictionary.ContainsKey(key)) { dictionary.Add(key, “Hello”); } 

La API basada en excepciones es una mala opción y no permitirá un comportamiento estable y predecible en escenarios de subprocesos múltiples. En cambio, necesita una API que no haga suposiciones sobre el estado actual de la colección, no arroje excepciones y deje una decisión sobre la admisibilidad de un estado en particular a la persona que llama.

En las colecciones concurrentes, las API se basan en el patrón TryXXX . En lugar de los habituales Agregar , Obtener y Eliminar, usamos los métodos TryAdd , TryGetValue y TryRemove . Y, si estos métodos devuelven falso , entonces decidimos si esta es una situación excepcional o no.

Vale la pena señalar que las colecciones clásicas ahora también tienen métodos tolerantes al estado. Pero en las colecciones clásicas, tal API es una buena adición, y en las colecciones concurrentes, es imprescindible.

API minimizando las condiciones de carrera


Considere la operación de actualización de elementos más simple:

 dictionary[key] += 1; 

Para toda su simplicidad, el código realiza tres acciones: obtiene el valor de la colección, agrega 1, escribe el nuevo valor. En la ejecución de subprocesos múltiples, es posible que el código recupere un valor, realice un incremento y luego borre de manera segura el valor escrito por otro subproceso mientras se ejecutaba el incremento.

Para resolver tales problemas, la API de colecciones concurrentes contiene varios métodos auxiliares. Por ejemplo, el método TryUpdate , que toma tres parámetros: la clave, el nuevo valor y el valor actual esperado. Si el valor en la colección no coincide con lo esperado, entonces la actualización no se realizará y el método devolverá falso .

Considere otro ejemplo. Literalmente, cada línea del siguiente código (incluida Console.WriteLine ) puede causar problemas con la ejecución de subprocesos múltiples:

 if (dictionary.ContainsKey(key)) { dictionary[key] += 1; } else { dictionary.Add(key, 1); } Console.WriteLine(dictionary[key]); 

Agregar o actualizar un valor, y luego realizar una operación con el resultado, es una tarea bastante típica. Por lo tanto, el diccionario concurrente tiene el método AddOrUpdate , que realiza una secuencia de acciones en una llamada y es seguro para subprocesos:

 var result = dictionary.AddOrUpdate(key, 1, (itemKey, itemValue) => itemValue + 1); Console.WriteLine(result); 

Hay un punto que vale la pena conocer.

La implementación del método AddOrUpdate llama al método TryUpdate descrito anteriormente y le pasa el valor actual de la colección. Si la actualización falla (el subproceso vecino ya ha cambiado el valor), el intento se repite y se llama nuevamente al delegado de actualización transmitido con el valor actual actualizado. Es decir, el delegado de actualización se puede llamar varias veces , por lo que no debe contener ningún efecto secundario.

Bloqueo de algoritmos libres y bloqueos granulares


Microsoft hizo un gran trabajo en el rendimiento de las colecciones concurrentes, y no solo envolvió todas las operaciones con bloqueos. Al estudiar la fuente, puede ver muchos ejemplos del uso de bloqueos granulares, el uso de algoritmos competentes en lugar de bloqueos, así como el uso de instrucciones especiales y primitivas de sincronización más "más ligeras" que Monitor .

Lo que las colecciones concurrentes no dan


De los ejemplos anteriores, es obvio que las colecciones concurrentes no brindan protección completa contra las condiciones de carrera, y debemos diseñar nuestro código en consecuencia. Pero eso no es todo, hay un par de puntos que vale la pena conocer.

Polimorfismo con colecciones clásicas.


Las colecciones concurrentes, como las clásicas, implementan las interfaces IDictionary , ICollection e IEnumerable . Pero parte de la API de estas interfaces no puede ser segura para subprocesos por definición. Por ejemplo, el método Add , que discutimos anteriormente.

Las colecciones concurrentes implementan dichos contratos sin seguridad de hilos. Y para "ocultar" una API insegura, utilizan una implementación explícita de las interfaces. Vale la pena recordar esto cuando pasamos colecciones concurrentes a métodos que toman datos, por ejemplo, ICollection.

Además, las colecciones concurrentes no cumplen con el principio de sustitución de Liskov con respecto a las colecciones clásicas.

Por ejemplo, el contenido de una colección clásica no se puede modificar durante la iteración , el siguiente código arrojará una InvalidOperationException para la clase List :

 foreach (var element in list) { list.Remove(element); } 

Si hablamos de colecciones concurrentes, la modificación en el momento de la enumeración no conduce a una excepción, de modo que podamos realizar lecturas y escrituras simultáneas de diferentes flujos.

Además, las colecciones concurrentes implementan de manera diferente la posibilidad de modificación durante la enumeración. ConcurrentDictionary simplemente no realiza ninguna comprobación y no garantiza el resultado de la iteración, y ConcurrentStack / Queue / Bag bloquea y crea una copia del estado actual, que se repite.

Posibles problemas de rendimiento


Mencionamos anteriormente que ConcurrentBag puede "robar" elementos de hilos vecinos. Esto puede conducir a problemas de rendimiento si escribe y lee al ConcurrentBag desde diferentes hilos.

Además, las colecciones concurrentes imponen bloqueos completos al consultar el estado de toda la colección ( Count , IsEmpty , GetEnumerator , ToArray , etc.) y, por lo tanto, son significativamente más lentas que sus contrapartes clásicas.

Conclusión: el uso de colecciones concurrentes vale la pena solo si son realmente necesarias, ya que esta opción no es "gratuita".

Cuándo qué tipos de colecciones usar


  • Guiones de un solo hilo: solo colecciones clásicas con el mejor rendimiento.
  • Graba desde múltiples transmisiones: solo colecciones concurrentes que protegen el estado interno y tienen una API adecuada para la grabación competitiva.
  • Lectura de múltiples hilos: no hay recomendaciones definitivas. Las colecciones concurrentes pueden crear problemas de rendimiento con solicitudes de estado intensivas para toda la colección. Sin embargo, para colecciones clásicas, Microsoft no garantiza el rendimiento incluso para operaciones de lectura. Por ejemplo, una implementación interna de una colección puede tener propiedades diferidas que se inician al leer datos y, por lo tanto, es posible destruir el estado interno al leer desde múltiples hilos. Una buena opción promediada es usar colecciones inmutables .
  • Y leer y escribir desde múltiples subprocesos: colecciones concurrentes únicas, que implementan protección de estado y una API segura.

Conclusiones


En este artículo, estudiamos brevemente las colecciones concurrentes, cuándo usarlas y qué detalles tienen. Por supuesto, el artículo no agota el tema, y ​​con un trabajo serio con colecciones multiproceso, debe profundizar más. La forma más fácil de hacer esto es mirar el código fuente de las colecciones utilizadas. Esto es informativo y para nada complicado, el código es muy, muy legible.

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


All Articles