En el artículo anterior acerca del uso de conjuntos, mencionamos cómo un HashSet<T> nos ofrecía un mecanismo de búsqueda muy eficiente en cuanto a rendimiento. Además, permitían realizar una serie de operaciones especiales como, por ejemplo, identificar si un objeto se encontraba en la colección. Esta es una operación extremadamente rápida en los conjuntos – con una función de coste O(1)-, frente a otro tipo de colección en los que el tiempo de búsqueda del elemento en concreto depende directamente del número de elementos del mismo – coste O(n) o peor.

Del mismo modo que ocurría con los HashSet<T>, si necesitamos una colección de datos cuyo acceso a los elementos sea muy rápido, .NET  nos ofrece la estructura Dictionary<TKey,TValue>, también conocido como HashMap en otros lenguajes. Los elementos almacenados en esta estructura se acceden directamente a través de una clave, en vez de iterando sobre la propia estructura como podría ser el caso de una lista. Si bien para una implementación de un hilo único puede ser suficiente, para un entorno multi hilo con una alta concurrencia - como son muchos de los servicios y aplicaciones en un entorno empresarial -, es posible que el diccionario se quede corto. En este caso debemos considerar el uso de su implementación thread-safe relacionada: el ConcurrentDictionary<TKey,TValue>, disponible en el espacio de nombres System.Collections.Concurrent.

Utilizar esta estructura nos puede ahorrar muchos quebraderos de cabeza, ya que contiene todas las operaciones necesarias para que las lecturas y escrituras sobre nuestra colección sean thread safe sin necesidad de tener que desarrollar un sistema complejo de locks. A modo de ejemplo: un hilo podría estar recorriendo el diccionario mediante un foreach, mientras que otro podría hacer operaciones de lectura o escritura sin llegar a provocar ningún error de concurrencia.

Nuestra protagonista es una estructura muy similar al Dictionary<TKey,TValue>, pero algunos de sus métodos se han implementado de forma diferente para adaptarse a un entorno concurrente. Por una parte, tenemos los métodos Try...(), pensados para realizar operaciones atómicas sobre estas colecciones.

  • TryAdd(): Sustituye al Add() del Dictionary<TKey,TValue>. Únicamente añade un elemento a la colección en caso de que no esté ya definido para dicha clave.
var sample = new ConcurrentDictionary<int, MyCustomClass>();
if (sample.TryAdd(1, new MyCustomClass()))
    Console.WriteLine("El objeto con clave 1 se ha añadido al diccionario.");
else 
    Console.WriteLine("El objeto con clave 1 no ha podido añadirse porque ya existe en el diccionario.");
  • TryRemove(): Sustituye al Remove() del Dictionary<TKey,TValue>. Devuelve un booleano que indica si ese elemento ha sido eliminado y tiene un parámetro out que devuelve el objeto eliminado. En caso de que no exista el elemento y no se haya eliminado nada, el parámetro out devuelve el valor por defecto del tipo (en el caso del ejemplo, null)
var sample = new ConcurrentDictionary<int, MyCustomClass>();
if (sample.TryRemove(1, out var removedObject))
    Console.WriteLine("El objeto con clave 1 se ha eliminado del diccionario.");
else
    Console.WriteLine("El objeto con clave 1 no ha podido eliminarse del diccionario porque no existe.");
  • TryUpdate(): Método que sirve para intentar realizar una actualización sobre un valor existente. Podría pensarse que este método intenta realizar la actualización del valor de una clave tras comprobar si esa clave existe, pero en realidad añade una comprobación más para asegurar su atomicidad. Tiene tres parámetros: la clave del ConcurrentDictionary<TKey,TValue> que se quiere modificar, el valor que se espera encontrar con dicha clave y finalmente el valor actualizado que se quiere establecer. En caso de no encontrar la clave o de no tener el valor esperado, no se realizará ninguna actualización y devolverá false. Si la clave existe y el valor es el esperado, se actualizará devolviendo true.
var sample = new ConcurrentDictionary<string, int>();
if (sample.TryUpdate("test", 2, 8))
    Console.WriteLine("El objeto con clave 'test' ha podido actualizarse porque ya existía y tenía valor 2.");
else
    Console.WriteLine("El objeto con clave 'test' no ha podido actualizarse porque o bien no existe o su valor no era 2.");

Por otra parte, los ConcurrentDictionary<TKey,TValue> ofrecen una serie de métodos compuestos mediante los cuales en un mismo método se pueden realizar varias operaciones simultáneas.

  • GetOrAdd(): obtiene el elemento de la clave proporcionada, en caso de que no exista, establece el valor dado. Del mismo modo, esta función admite un Func delegate como parámetro para poder calcular el valor a establecer.
var sample = new ConcurrentDictionary<string, int>();
var storedValue= sample.GetOrAdd("test", 2);
var storedValueWithFunc = sample.GetOrAdd("testFunc", (x) => CalculateDefaultVale());
  • AddOrUpdate(): añade un par clave-valor o actualiza el valor en caso de que ya exista en el ConcurrentDictionary<TKey,TValue>. Los parámetros de este método son: la clave a modificar, el valor a añadir (o un Func delegate para calcularlo), y un Func delegate que se ejecutará en caso de que la clave exista y se vaya a actualizar el valor.
var sample = new ConcurrentDictionary<string, int>();
var storedValue = sample.AddOrUpdate("test", 3, (key, oldValue) => oldValue + 1);
var storedValueWithFunc = sample.AddOrUpdate("test", (key) => CalculateDefaultValue(), (key, oldValue) => oldValue + 1);

A diferencia de los métodos Try…(), estos dos últimos no son atómicos, es decir, si en nuestro software acceden varios hilos simultáneamente a la ejecución de estos métodos, puede que se den comportamientos extraños provocados por la ejecución simultánea de los parámetros de tipo Func delegate.

Por ejemplo: queremos desarrollar un gestor de conexiones a una cola RabbitMQ pero solo podemos tener una conexión activa según los requerimientos recibidos. Hemos decidido que vamos a utilizar un ConcurrentDictionary<TKey, TValue> que guardará la información de las conexiones por endpoint. Nuestro código podría quedar así:

public class ConnectionManager
{
    private ConcurrentDictionary<string, MyConnectionInfo> _connectionInfoByEndpoint 
        = new ConcurrentDictionary<string, MyConnectionInfo>();
    public MyConnectionInfo GetConnection(string endpoint)
    {
        return _connectionInfoByEndpoint.GetOrAdd(endpoint, (key) => InitConnection(key));
    }
    private MyConnectionInfo InitConnection(string endpoint)
    {
        var connection = new MyConnectionInfo();
        // Se inicia una conexión
        connection.Connect();
        return connection;
    }
}

Como veis, hemos decidido usar GetOrAdd() para obtener una posible conexión activa o inicializarla en caso de que no exista. El problema es que esa inicialización podría realizarse más de una vez en caso de que varios hilos llegasen a este método de manera simultánea, generando varias conexiones e incumpliendo los requerimientos que nos habían pedido. La probabilidad de que esta inicialización múltiple ocurra se incrementa en función del tiempo que tarde en ejecutarse dicho método. Si necesitamos cubrir una situación similar, deberíamos abordarla de una manera más tradicional, como puede ser mediante el uso de locks, y es posible que en este caso un Dictionary<TKey,TValue> nos pueda llegar a servir. Por ejemplo:

public class ConnectionManagerWithLock
{
    private Dictionary<string, MyConnectionInfo> _connectionInfoByEndpoint = new Dictionary<string, MyConnectionInfo>();
    private static object _connectionLock = new object();
    public MyConnectionInfo GetConnection(string endpoint)
    {
        lock (_connectionLock)
        {
            if (!_connectionInfoByEndpoint.TryGetValue(endpoint, out var connectionInfo))
            {
                connectionInfo = InitConnection(endpoint);
                _connectionInfoByEndpoint[endpoint] = connectionInfo;
            }
            return connectionInfo;
        }
    }
    private MyConnectionInfo InitConnection(string endpoint)
    {
        var connection = new MyConnectionInfo();
        // Se inicia una conexión
        connection.Connect();
        return connection;
    }
}

En conclusión, ConcurrentDictionary<TKey,TValue> nos ofrece una potente solución para utilizar en los desarrollos que sigan un paradigma multi hilo: son extremadamente eficaces y fáciles de usar, pero tenemos que ser cuidadosos a la hora de utilizar sus métodos compuestos, ya que podríamos estar ejecutando código o inicializando objetos no deseados.