Controlando accesos concurrentes con semáforos en dotNet

  • net-core
  • net
  • bloqueo
  • dotnet
  • semaforo
  • semaphore
  • threads

Los semáforos son una herramienta de sincronización utilizada para limitar el número de subprocesos que pueden tener acceso a un recurso o grupo de recursos simultáneamente.

A diferencia de los locks, este límite no tiene porque restringirse a un solo proceso, sino que se puede configurar para permitir un numero personalizado de accesos simultáneos.

Para aclarar el concepto, voy a mostrar un ejemplo utilizando los semáforos.

Usando semáforos

El ejemplo que muestro a continuación es muy sencillo. Simplemente tengo un método que imprime la hora y, tras ello, espera 5 segundos. Este método está protegido con un semáforo, por lo que solo podrán ejecutarlo de manera simultanea un numero de hilos. Para el ejemplo, 3.

example.DoSomethingAsync();
example.DoSomethingAsync();
example.DoSomethingAsync();
example.DoSomethingAsync();
example.DoSomethingAsync();
example.DoSomethingAsync();
example.DoSomethingAsync();

public class Example : IDisposable
{
    private Semaphore semaphoreObject;

    public Example()
    {
        semaphoreObject = new Semaphore(initialCount: 3, maximumCount: 3);
    }

    public async void DoSomethingAsync()
    {
        semaphoreObject.WaitOne();
        Console.WriteLine(DateTime.Now);
        await Task.Delay(5000);
        semaphoreObject.Release();
    }
}

A continuación voy a proceder a explicar los comandos resaltados, para entender un poco mejor el funcionamiento de los semaforos.

  • Constructor: recibe 2 parámetros. Uno que indica el valor inicial del contador y otro que indica el valor máximo del contador. El valor inicial no puede ser 0, ni puede ser mayor que el máximo.

  • WaitOne(): Reduce en una unidad el valor del contador del semáforo. Por ejemplo, el primer WaitOne(), reducirá el valor del semáforo de 3 (initialCount) a 2. Puede recibir un TimeSpan que, si se especifica, indica el tiempo que tiene que pasar hasta que se libere el semáforo.

  • Release(): Es el opuesto al WaitOne, es decir, libera el semáforo. Por ejemplo, si el valor del semáforo es 1 y se ejecuta Release(), dicho valor se incrementa a 2. Por defecto, el incremento es en una unidad, pero puede incrementarse el más pasándole un valor entero como parámetro.

Resumiendo todo lo anterior, el valor de un semáforo se disminuye cada vez que un subproceso entra en el semáforo (WaitOne) y se incrementa cuando un subproceso libera el semáforo (Release). Cuando el recuento es cero, las solicitudes posteriores se bloquean hasta que otros subprocesos liberan el semáforo.

Consideraciones

Hay varios puntos a tener en cuenta antes de utilizar los semáforos.

  • No hay ningún orden garantizado, como FIFO o LIFO, en el que los subprocesos bloqueados entran en el semáforo.

  • Un subproceso puede escribir el semáforo varias veces llamando a WaitOne varias veces. De manera análoga, puede liberar algunas o todas estas entradas, ejecutando el método Release varias veces, o bien informándole un parámetro con el número de entradas que se van a liberar.

  • Es responsabilidad del programador asegurarse de que los subprocesos no liberan el semáforo demasiadas veces. Si lo hace, se producirá un SemaphoreFullException.

  • Impacto en el rendimiento. El uso excesivo de semáforos puede ralentizar la aplicación debido a la competencia por los recursos y la necesidad de esperar a que los bloqueos se liberen.

  • Deadlocks. Si no se gestionan adecuadamente, los bloqueos pueden llevar a situaciones de deadlock, donde varios subprocesos quedan atrapados esperando mutuamente la liberación de recursos.

  • Mayor complejidad. La utilización de semáforos puede aumentar la complejidad del código haciéndolo más difícil de entender, propenso a errores y más difícil de depurar.

Tipos de semáforos

Los semáforos pueden ser locales y del sistema con nombre.

Los primeros son los que hemos visto en el ejemplo anterior. Que sean locales significa que ese semáforo solo se puede usar en nuestra aplicación, dentro del ámbito que hayamos definido. Además, se pueden utilizar en todas las plataformas soportadas por dotNet.

Por otro lado, los semáforos con nombre quedan asociados al sistema operativo, por lo que se pueden utilizar en distintas aplicaciones para controlar el acceso a un bloque de código. Para dar nombre a un semáforo se utiliza el constructor y se puede recuperar un semáforo con nombre utilizando el método OpenExisting. Es importante saber que (en el momento de escribir este artículo) este tipo de semáforos solo funcionan en Windows.

Para entender el funcionamiento de los semáforos con nombre, he creado un ejemplo que consta de dos aplicaciones de consola. Una espera a que el semáforo quede liberado y escribe la hora en la consola. La otra aplicación, se dedica a liberar el semáforo, haciendo un Release cada 5 segundos.

//Application 1
Example example = new Example();
List<Task> exampleTasks = new List<Task>();
for (int i = 0; i < 10; i++)
    exampleTasks.Add(Task.Run(() => example.DoSomething()));

await Task.WhenAll(exampleTasks);

public class Example
{
    private Semaphore? semaphoreObject;

    public Example()
    {
        bool existsSemaphore = Semaphore.TryOpenExisting("testJorge", out semaphoreObject);
        if (!existsSemaphore)
            semaphoreObject = new Semaphore(initialCount: 0, maximumCount: 3, name: "testJorge");
    }

    public void DoSomething()
    {
        semaphoreObject!.WaitOne();
        Console.WriteLine(DateTime.Now);
    }
}c
//Application 2
Example example = new Example();
for (int i = 0; i < 10; i++)
    await example.DoSomething();

public class Example
{
    private Semaphore? semaphoreObject;

    public Example()
    {
        bool existsSemaphore = Semaphore.TryOpenExisting("testJorge", out semaphoreObject);
        if (!existsSemaphore)
            semaphoreObject = new Semaphore(initialCount: 0, maximumCount: 3, name: "testJorge");
    }

    public async Task DoSomething()
    {
        semaphoreObject!.Release();
        await Task.Delay(5000);
    }
}

En base al ejemplo anterior, la salida que tenemos en la aplicación 1 es la siguiente:

04/08/2023 22:23:30
04/08/2023 22:23:35
04/08/2023 22:23:40
04/08/2023 22:23:45
04/08/2023 22:23:50
04/08/2023 22:23:55
04/08/2023 22:24:00
04/08/2023 22:24:05
04/08/2023 22:24:10
04/08/2023 22:24:15

Podemos ver que se imprime la hora cada 5 segundos, que es el tiempo que tarda la aplicación 2 en ir liberando el semáforo.

Foto de Marco Biondi en Unsplash