Antes de .NET 4.0, si necesitábamos usar la clase Dictionary en un entorno multihilo, no teníamos más remedio que implementar nosotros mismos la sincronización de hilos para mantener los hilos seguros.
Muchos desarrolladores han implementado sin duda una solución similar de seguridad en hilos, ya sea creando un tipo de diccionario completamente nuevo para hilos seguros, o simplemente encapsulando un objeto de diccionario en una clase y añadiendo un mecanismo de bloqueo a todos los métodos, que llamamos "Diccionario + Bloqueos".
Pero ahora tenemos ConcurrentDictionary. La descripción segura de hilos de la documentación de la clase Dictionary en MSDN indica que si necesitas usar una implementación segura para hilos, usa ConcurrentDictionary.
Así que, ahora que tenemos una clase de diccionario segura para hilos, ya no necesitamos implementarla nosotros mismos. Genial, ¿verdad?
Origen del problema
De hecho, solo he usado CocurrentDictionary una vez antes, en mi prueba para comprobar su capacidad de respuesta. Como le iba bien en los exámenes, lo sustituí inmediatamente por mi clase, hice algunas pruebas y luego algo salió mal.
Entonces, ¿qué salió mal? ¿No dijiste seguro para hilos?
Tras más pruebas, encontré la raíz del problema. Pero por alguna razón, la versión 4.0 de MSDN no incluye una descripción de la firma del método GetOrAdd que requiera pasar un parámetro de tipo delegado. Después de mirar la versión 4.5, encontré esta nota:
Si llamas a GetOrAdd simultáneamente en diferentes hilos, puede llamarse a addValueFactory varias veces, pero su par clave/valor puede no añadirse al diccionario para cada llamada. Ese es el problema con el que me encontré. Como no se describía previamente en la documentación, tuve que hacer más pruebas para confirmar el problema. Por supuesto, el problema que tengo está relacionado con mi uso; en general, uso el tipo diccionario para almacenar en caché algunos datos:
Estos datos tardan mucho en crearse; Estos datos solo pueden crearse una vez, porque la segunda creación lanzará una excepción, o múltiples creaciones pueden provocar fuga de recursos, etc.; Tuve un problema con la segunda condición. Si ambos hilos detectan que un dato no existe, se creará una vez, pero solo se guardará con éxito un resultado. ¿Y el otro?
Si el proceso que creas genera una excepción, puedes usar pro: Atrapar (no es lo suficientemente elegante, pero soluciona el problema). ¿Pero qué pasa si un recurso se crea y no se recicla?
Podrías decir que un objeto se crea y será recogido por basura si ya no se menciona en él. Sin embargo, considera lo que ocurriría si ocurriera la situación descrita a continuación:
Genera código dinámicamente con Emit. Utilicé este enfoque en un framework de remoting y puse todas las implementaciones en un ensamblador que no podía reciclarse. Si un tipo se crea dos veces, el segundo siempre existirá, incluso si nunca se ha utilizado. Crea un hilo directa o indirectamente. Por ejemplo, necesitamos construir un componente que utilice un hilo propietario para procesar mensajes asíncronos y dependa del orden en que se reciben. Cuando el componente se instancia, se crea un hilo. Cuando esta instancia de componente es destruida, el hilo también termina. Pero si eliminamos la referencia al objeto tras destruir el componente, pero el hilo no termina por alguna razón y mantiene la referencia al objeto. Entonces, si el hilo no muere, el objeto tampoco será reciclado. Realiza una operación P/Invoke. Exigir que el número de tiempos cerrados para la manilla recibida sea igual al número de aperturas. Por supuesto, hay muchas situaciones similares. Por ejemplo, un objeto diccionario mantendrá una conexión a un servicio en un servidor remoto, que solo puede solicitarse una vez, y si se solicita una segunda vez, el otro servicio pensará que ha ocurrido algún tipo de error y lo registrará en el registro. (En una empresa para la que trabajé, había algunas sanciones legales por esta condición.) ) Así que es fácil ver que Diccionario + Bloqueos no puede reemplazarse apresuradamente por ConcurrentDictionary, aunque la documentación diga que es seguro para hilos.
Analizar el problema
¿Aún no lo entiendes?
Es cierto que este problema puede no surgir bajo el enfoque Diccionario + Candados. Como esto depende de la implementación específica, veamos este ejemplo sencillo:
En el código anterior, mantenemos el bloqueo en el diccionario antes de empezar a consultar el valor clave. Si el par clave-valor especificado no existe, se creará directamente. Al mismo tiempo, como ya tenemos un bloqueo en ese diccionario, podemos añadir pares clave-valor directamente al diccionario. Luego suelta el bloqueo del diccionario y devuelve el resultado. Si dos hilos consultan el mismo valor clave al mismo tiempo, el primer hilo que obtiene el bloqueo del diccionario completará la creación del objeto, y el otro hilo esperará a que se complete esta creación y obtendrá el resultado del valor clave creado tras obtener el bloqueo del diccionario.
Eso está bien, ¿no?
¡De verdad que no lo es! No creo que crear un objeto en paralelo así, donde al final solo se usa uno, no cree el problema que he descrito.
La situación y el problema que intento explicar pueden no ser siempre reproducibles; en un entorno paralelo podemos simplemente crear dos objetos y luego descartar uno. Entonces, ¿cómo comparamos exactamente Dictionary + Locks y ConcurrentDictionary?
La respuesta es: depende de la estrategia de uso de las cerraduras y de cómo se utilice el diccionario.
Juego 1: Crear el mismo objeto en paralelo
Primero, supongamos que un objeto puede crearse dos veces, así que ¿qué ocurre si dos hilos crean ese objeto al mismo tiempo?
En segundo lugar, ¿cuánto tiempo dedicamos a creaciones similares?
Podemos simplemente construir un ejemplo donde instanciar un objeto tarde 10 segundos. Cuando el primer hilo crea el objeto 5 segundos después, la segunda implementación intenta llamar al método GetOrAdd para obtener el objeto, y como el objeto sigue sin existir, también empieza a crear el objeto.
En esta condición, tenemos 2 CPUs trabajando en paralelo durante 5 segundos, y cuando el primer hilo termina de funcionar, el segundo hilo aún necesita seguir funcionando durante 5 segundos para completar la construcción del objeto. Cuando el segundo hilo termina de construir el objeto, descubre que un objeto ya existe y elige usar el objeto existente y descartar directamente el objeto recién creado.
Si el segundo hilo simplemente espera y el segundo CPU realiza otro trabajo (ejecutando otros hilos o aplicaciones, ahorrando energía), obtendrá el objeto deseado tras 5 segundos en lugar de 10.
Así que, bajo estas condiciones, Diccionario + Candados gana una pequeña partida.
Juego 2: Visitar diferentes objetos en paralelo
¡No, la situación que has dicho no es cierta en absoluto!
Bueno, el ejemplo anterior es un poco peculiar, pero describe el problema, solo que este uso es más extremo. Entonces, considera qué ocurre si el primer hilo está creando un objeto, y el segundo hilo necesita acceder a otro objeto clave-valor, y ese objeto clave-valor ya existe.
En ConcurrentDictionary, el diseño sin bloqueo hace que las lecturas sean muy rápidas porque no hay bloqueo en la lectura. En el caso de Diccionario + Cerraduras, la operación de lectura será bloqueada mutuamente excluyente, incluso si es una clave completamente diferente, lo que obviamente ralentizará la operación de lectura.
De este modo, ConcurrentDictionary retiró un juego.
Nota: Aquí considero que entiendes varios conceptos como Bucket/Nodo/Entry en la clase de diccionario; si no, se recomienda leer el artículo de Ofir Makmal "Understanding Generic Dictionary in-depth", que explica bien estos conceptos.
El tercer juego del juego: leer más y escribir un solo
¿Qué pasa si usas Múltiples Lectores y Un Solo Escritor en lugar de un bloqueo completo en el diccionario en Diccionario + Bloqueos?
Si un hilo está creando un objeto y mantiene un bloqueo actualizable hasta que se crea el objeto, el bloqueo se actualiza a un bloqueo de escritura, entonces la operación de lectura puede realizarse en paralelo.
También podemos resolver el problema dejando una operación de lectura inactiva durante 10 segundos. Pero si hay muchas más lecturas que escrituras, veremos que ConcurrentDictionary sigue siendo rápido porque implementa lecturas en modo sin bloqueo.
Usar ReaderWriterLockSlim para diccionarios empeora las lecturas, y generalmente se recomienda usar Full Lock para diccionarios en lugar de ReaderWriterLockSlim.
Así que, bajo estas condiciones, ConcurrentDictionary ganó otra partida.
Nota: He tratado las clases YieldReaderWriterLock y YieldReaderWriterLockSlim en artículos anteriores. Al usar este bloqueo de lectura y escritura, la velocidad se ha mejorado considerablemente (ahora evolucionó a SpinReaderWriterLockSlim) y permite ejecutar múltiples lecturas en paralelo con poco o ningún impacto. Aunque sigo usando esta forma, un ConcurrentDictionary sin bloqueo sería obviamente más rápido.
Juego 4: Añadir múltiples pares clave-valor
El enfrentamiento aún no ha terminado.
¿Y si tenemos varios valores clave para sumar, y todos no colisionan y se asignan en diferentes compartimentos?
Al principio, esta pregunta me resultó curiosa, pero hice una prueba que no encajaba del todo. Usé un diccionario de tipos <int, int> y la fábrica de construcción del objeto devolvía un resultado negativo directamente como clave.
Esperaba que ConcurrentDictionary fuera el más rápido, pero resultó ser el más lento. Diccionario + Cerraduras, en cambio, funciona más rápido. ¿Por qué?
Esto se debe a que ConcurrentDictionary asigna nodos y los coloca en diferentes cubos, lo cual está optimizado para cumplir con el diseño sin bloqueo para operaciones de lectura. Sin embargo, al añadir elementos clave-valor, el proceso de crear un nodo se vuelve costoso.
Incluso en condiciones paralelas, asignar un bloqueo de nodo sigue consumiendo más tiempo que usar un bloqueo completo.
Así que, Diccionario + Candados gana este juego.
Jugar al quinto juego: La frecuencia de las operaciones de lectura es mayor
Francamente, si tuviéramos un delegado que pudiera instanciar objetos rápidamente, no necesitaríamos un diccionario. Podemos llamar directamente al delegado para que consiga el objeto, ¿verdad?
De hecho, la respuesta también es que depende de la situación.
Imagina que el tipo de clave es cadena y contiene mapas de ruta para varias páginas en el servidor web, y el valor correspondiente es un tipo de objeto que contiene el registro de los usuarios actuales accediendo a la página y el número de todas las visitas desde que comenzó el servidor.
Crear un objeto así es casi instantáneo. Y después de eso, no necesitas crear un objeto nuevo, solo cambiar los valores guardados en él. Así que es posible permitir la creación de un método dos veces hasta que solo se use una instancia. Sin embargo, dado que ConcurrentDictionary asigna los recursos de Nodo más lentamente, usar Diccionario + Bloqueos resultará en tiempos de creación más rápidos.
Así que, con este ejemplo es muy especial, también vemos que Diccionario + Bloqueos rinde mejor bajo esta condición, tardando menos tiempo.
Aunque la asignación de nodos en ConcurrentDictionary es más lenta, no intenté meter 100 millones de elementos de datos para probar el tiempo. Porque eso obviamente lleva mucho tiempo.
Pero en la mayoría de los casos, una vez creado un elemento de datos, siempre se lee. Cómo cambia el contenido del elemento de datos es otra cuestión. Así que no importa cuántos milisegundos más se tarde en crear un elemento de datos, porque las lecturas son más rápidas (solo unos milisegundos más rápidas), pero las lecturas ocurren con más frecuencia.
Así que ConcurrentDictionary ganó el juego.
Juego 6: Crear objetos que consuman diferentes tiempos
¿Qué ocurre si el tiempo que tarda en crear diferentes elementos de datos varía?
Crea varios elementos de datos que consuman diferentes tiempos y añádelos al diccionario en paralelo. Este es el punto más fuerte de ConcurrentDictionary.
ConcurrentDictionary utiliza varios mecanismos de bloqueo diferentes para permitir añadir elementos de datos simultáneamente, pero lógica como decidir qué bloqueo usar, solicitar que un bloqueo cambie el tamaño del cubo, etc., no ayuda. La velocidad a la que se introducen los datos en un cubo es rápida como la máquina. Lo que realmente hace que ConcurrentDictionary gane es su capacidad para crear objetos en paralelo.
Sin embargo, en realidad podemos hacer lo mismo. Si no nos importa si estamos creando objetos en paralelo, o si algunos han sido descartados, podemos añadir un bloqueo para detectar si el elemento de datos ya existe, luego liberar el bloqueo, crear el elemento de datos, pulsarlo para obtener el bloqueo, comprobar de nuevo si el elemento de datos existe, y si no, añadir el elemento de datos. El código podría verse algo así:
* Ten en cuenta que uso un diccionario de tipo <int, int>.
En la estructura sencilla anterior, Dictionary + Locks funciona casi tan bien como ConcurrentDictionary al crear y añadir elementos de datos en condiciones paralelas. Pero también existe el mismo problema, donde algunos valores pueden generarse pero nunca se usan.
conclusión
Entonces, ¿hay alguna conclusión?
Por ahora, todavía quedan algunas:
Todas las clases de diccionario son muy rápidas. Aunque he creado millones de datos, sigue siendo rápido. Normalmente, solo creamos un pequeño número de elementos de datos, y hay algunos intervalos de tiempo entre lecturas, por lo que generalmente no notamos la sobrecarga de tiempo de leer los datos. Si el mismo objeto no puede crearse dos veces, no use ConcurrentDictionary. Si realmente te preocupa el rendimiento, Diccionario + Cerraduras podría seguir siendo una buena solución. Un factor importante es el número de elementos de datos añadidos y eliminados. Pero si hay muchas operaciones de lectura, es más lento que ConcurrentDictionary. Aunque no lo introduje yo, en realidad hay más libertad para usar el esquema Diccionario + Candados. Por ejemplo, puedes bloquear una vez, añadir varios elementos de datos, eliminar varios elementos de datos o consultar varias veces, etc., y luego liberar el bloqueo. En general, evita usar ReaderWriterLockSlim si hay muchas más lecturas que escrituras. Los tipos de diccionario ya son mucho más rápidos que conseguir un bloqueo de lectura en un bloqueo de lectura-escritura. Por supuesto, esto también depende del tiempo que se tarda en crear un objeto en una cerradura. Así que creo que los ejemplos dados son un poco extremos, pero muestran que usar ConcurrentDictionary no siempre es la mejor solución.
Siente la diferencia
Escribí este artículo con la intención de buscar una mejor solución.
Ya estoy intentando entender mejor cómo funciona una clase específica de diccionario (ahora siento que lo tengo muy claro).
Se podría argumentar que Bucket y Node en ConcurrentDictionary son muy simples. Hice algo similar cuando intenté crear una clase de diccionario. La clase normal de diccionario puede parecer más sencilla, pero en realidad es más compleja.
En ConcurrentDictionary, cada nodo es una clase completa. En la clase Diccionario, Nodo se implementa usando un tipo de valor, y todos los nodos se mantienen en un enorme array, mientras que Bucket se usa para indexar en el array. También se utiliza en lugar de la simple referencia de un Nodo a su siguiente Nodo (al fin y al cabo, como Nodo de tipo struct, no puede contener un Nodo miembro de un tipo struct).
Al añadir y eliminar un diccionario, la clase Diccionario no puede simplemente crear un nuevo nodo, debe comprobar si hay un índice que marque un nodo eliminado y luego reutilizarlo. O "Contar" se usa para obtener la posición del nuevo nodo en el array. De hecho, cuando el array está completo, la clase Diccionario obliga a cambiar el tamaño.
Para ConcurrentDictionary, un Nodo puede considerarse como un objeto nuevo. Eliminar un nodo es simplemente eliminar su referencia. Añadir un nuevo Node puede simplemente crear una nueva instancia de Node. Cambiar el tamaño es solo para evitar conflictos, pero no es obligatorio.
Entonces, si la clase Dictionary utiliza intencionadamente algoritmos más complejos para manejarlo, ¿cómo garantizará ConcurrentDictionary que rinda mejor en un entorno multihilo?
La verdad es que: poner todos los nodos en un solo array es la forma más rápida de asignar y leer, incluso si necesitamos otro array para llevar un registro de dónde encontrar esos datos. Así que parece que tener el mismo número de cubos consumirá más memoria, pero los nuevos elementos de datos no necesitan ser reasignados, no se necesitan nuevas sincronizaciones de objetos y no se produce una nueva recogida de basura. Porque todo ya está en su sitio.
Sin embargo, reemplazar contenido en un Nodo no es una operación atómica, lo que es uno de los factores que hacen que su hilo sea inseguro. Como todos los nodos son objetos, inicialmente se crea un nodo y luego se actualiza una referencia separada para apuntar a él (operación atómica aquí). Así, el hilo de lectura puede leer el contenido del diccionario sin bloqueo, y la lectura debe ser uno de los valores antiguo y nuevo, y no hay posibilidad de leer un valor incompleto.
Así que, la verdad es que: si no necesitas un bloqueo, la clase Diccionario es más rápida en lecturas, porque es el bloqueo el que ralentiza la lectura.
Este artículo está traducido del artículo de Paulo Zemek "Dictionary + Locking versus ConcurrentDictionary" en CodeProject, y algunas afirmaciones cambiarán por razones de comprensión.
|