Cómo no escribir código concurrente en .NET

En muchas ocasiones el código que se escribe en .NET se tiene que tener en cuenta que puede ser llamado de manera concurrente, es decir, desde varios hilos del sistema operativo. En ese caso hay que hace que el código sea lo más óptimo posible para no generar muchas esperas y bloqueos innecesarios del código.

Lo más sencillo

La forma de empezar a bloquear código para asegurarse de que el código sólo es ejecutado por un único Thread es envolver ese código con la palabra reservada lock (en C#), un ejemplo de ese código se puede encontrar a continuación.

 public class LockExample
{
    private object syncLock = new object();
    
    public void MethodWithLock()
    {
        lock(syncLock)
        {
            // código
        }
    }
}

De este tipo de bloqueo se pueden encontrar variantes, pero que en esencia son lo mismo. Utilizando el atributo MethodImpl con el valor de MehtodImplOptions.Synchronized se consigue el mismo resultado que es bloquear todo el cuerpo de la función utilizando la palabra reservada lock. La diferencia es que cuando el método es de instancia se utiliza el objeto this para señalar el bloqueo, mientras que cuando el método es estático se utiliza el typeof de la clase para hacer el bloqueo.

MethodImpl para métodos de instancia

Este código que utiliza MethodImpl,

 [MethodImpl(MethodImplOptions.Synchronized)]
public void MethodWithLock()
{
}

Es exactamente igual a:

 public void MethodWithLock()
{
    lock(this)
    {
    }
}

MethodImpl para método estáticos

Para los método estáticos utilizar MethodImpl

 public class LockExample
{
    [MethodImpl(MethodImplOptions.Synchronized)]
    public static void MethodWithLock()
    {
    }
}

Es igual a escribir el siguiente código:

 public class LockExample
{
    public static void MethodWithLock()
    {
        lock (typeof(LockExample))
        {
        }
    }
}

Como se ha comentado anteriormente este tipo de bloqueos no son recomendables en ningún caso, porque aumenta la granularidad del bloqueo y no se tiene un control sobre las operaciones de lectura o escritura de las variables que se desea acceder. Además, en caso de excepción no está claro si el bloqueo se libera o se queda para siempre.

Queda también comentar que en caso concreto del bloqueo para los métodos estáticos además se puede incurrir en un comportamiento muy peculiar del CLR que se llama Marshal-by-bleed.

Marshal-by-bleed

.NET Framwork soporta marshalling de objetos entre dominios de aplicación llamado marshal-by-bleed. Esto significa que cuando se tienen varios dominios de aplicación dentro del mismo proceso de .NET entre estos dominios de aplicación, si no se ha especificado de LoaderOptimization, los ensamblados firmados con un nombre fuerte (strong name) serán compartidos entre los dominios de aplicación. Pues bien, eso puede llevar a que la referencia en memoria de objetos estáticos referenciados desde un GCRoot pueda ser el mismo entre varios dominios de aplicación. En efecto prácticos, la llamada a typeof(String) puede ser la misma referencia entre dominios de aplicación. Sabiendo esto si el código de más arriba la clase que se utiliza como bloqueo en el atributo MethodImpl es una clase que forma parte de un ensamblado firmado con un nombre fuerte, el hecho de utilizar ese objeto puede hacer que el mismo código ejecutado en otro dominio de aplicación diferente (dentro del mismo proceso) bloquee el otro dominio de aplicación. Así de esta manera se están produciendo bloqueos a través de dominios de aplicación, una situación que es bastante complicada de detectar en aplicaciones en producción.

Para más información Unai Zorilla escribió un artículo en 2009 con un ejemplo sobre esto (https://geeks.ms/blogs/unai/archive/2009/02/08/marshall-by-bleed-explained.aspx) también Joe Duffy escribió sobre esto en 2006 (https://joeduffyblog.com/2006/08/21/dont-lock-on-marshalbybleed-objects/)

Mejorando el código concurrente

Como se ha visto en los anteriores ejemplos, este tipo de código no es la mejor solución para controlar el acceso a métodos o variables. Otra opción es utiliza la clase Monitor, que permite entre otras cosas poner un timeout para que en caso de que el bloqueo dure demasiado tiempo, tener un mecanismo para poder abortarlo.

Otra opción para aumentar la granularidad de los bloqueos es utilizar una clase que permita tener diferentes patrones de acceso a recursos compartidos. Uno de los más utilizados es un escritor varios lectores, que se basa en la idea de tener una único thread escribiendo un varios leyendo a la vez. Para hacer eso dentro de .NET Framework hay una clase llamada ReaderWriterLockSlim que permite justamente crear este patrón.

En el siguiente código se protege el acceso a una variable entera para que se pueda leer desde muchos threads pero sólo se pueda escribir desde uno.

 public class MultipleReadsOneWriter
{
    private volatile int value;
    private ReaderWriterLockSlim rwls;
    
    public MultipleReadsOneWriter()
    {
        rwls = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
    }
    
    public int ReadValue()
    {
        int result = default(int);
        rwls.EnterReadLock();
        try
        {
            result = value;
        }
        finally
        {
            rwls.ExitReadLock();
        }
        return result;
    }
    
    public void WriteValue(int number)
    {
        rwls.EnterWriteLock();
        try
        {
            value = number;
        }
        finally
        {
            rwls.ExitWriteLock();
        }
    }

    public void WriteValueIfEqual(int compare, int number)
    {
        rwls.EnterUpgradeableReadLock();
        try
        {
            int current = value;
            if (current == compare)
            {
                rwls.EnterWriteLock();
                try
                {
                    value = number;
                }
                finally
                {
                    rwls.ExitWriteLock();
                }
            }
        }
        finally
        {
            rwls.ExitUpgradeableReadLock();
        }
    }
}

Algunos detalles interesantes sobres este código. La clase ReaderWriterLockSlim tiene métodos para poder bloquear para sólo lectura, sólo escritura y también para una lectura que tiene posibilidad de actualizarse a una escritura. De esta manera se controla mucho mejor las lecturas y escritura de una variable.

Otro detalle interesante de código es que cualquier interacción sobre las llamadas de la clase ReaderWriterLockSlim está envuelta entre un try/finally que permite asegurarse que siempre se vaya a llamar a la funciona de salida de la operación actual. Esto es muy importante porque evita que se tengan bloqueos huérfanos que nunca se liberen.

Luis Guerrero.

Technical Evangelist Microsoft Azure.

@guerrerotook