DEV Community

Marcos Belorio
Marcos Belorio

Posted on

Executando Tasks em paralelo em aplicações .Net com o SemaphoreSlim

Introdução

Neste artigo vamos entender como executar tasks de forma paralela, visualizar possíveis problemas que poderemos causar em nossas aplicações e aprender a utilizar a classe SemaphoreSlim para nos ajudar a gerenciar a execução das tasks.

Cenário

Imagine uma aplicação onde será necessário de tempos em tempos processar uma massa de dados relativamente grande (entre 1 mil à 10 mil registros) e para cada registro processado, enviá-lo em uma requisição http. Para essa massa de dados poder ser executada em um tempo aceitável será necessário efetuar processamentos em paralelo.

Exemplificando o cenário

Vamos criar um trecho de código simples onde iremos simular o processamento de 10 mil registros de forma paralela:

static void Main(string[] args)
{        
    var timer = new Stopwatch();
    Console.WriteLine($"Início da execução");

    timer.Start();
    ProcessarMassaDeDados();
    timer.Stop();

    Console.WriteLine($"Tempo: {timer.Elapsed:m\\:ss\\.fff}");
    Console.ReadKey();
}

static void ProcessarMassaDeDados()
{
    var listOfTasks = new List<Task>();
    for (int i = 0; i < 10000; i++)
    {
        listOfTasks.Add(ProcessarRegistro());
    }
    Task.WaitAll(listOfTasks.ToArray());
}

static async Task ProcessarRegistro()
{
    var _httpClient = HttpClientFactory.Create();
    await _httpClient.GetAsync("http://httpstat.us/200?sleep=1000");
}
Enter fullscreen mode Exit fullscreen mode

Como podemos ver no código acima, primeiro criamos uma lista de tasks e adicionamos a task ProcessarRegistro 10 mil vezes dentro dessa lista, após isso executamos todas elas em paralelo através do comando Task.WaitAll, esse comando aguarda a conclusão de todas as tasks para poder seguir. Dentro do método ProcessarRegistro estamos fazendo uma requisição http que demora um segundo para ser executado.

Rodando esse trecho de código temos o seguinte resultado no meu computador:

Início da execução
Tempo: 1:37.821
Enter fullscreen mode Exit fullscreen mode

Se não executássemos essas tasks em paralelo, iria levar pelo menos 10 mil segundos (166 minutos) para executar tudo, já que cada requisição dura um segundo.

Olhando esses números o código acima parece nos atender bem, porém esse código só executou bem porque estamos testando em um ambiente local, sem nenhuma concorrência para utilizar os recursos da máquina, executar esse mesmo código em um ambiente que já possui centenas de outras requisições sendo processadas pela aplicação não é uma boa ideia, facilmente você irá provocar problemas de rede, causar lentidões nos recursos e impactar todo o ambiente.

Então como ganhar velocidade no processamento com paralelismo mas sem causar problemas e lentidões no ambiente?

Conhecendo a classe SemaphoreSlim

O .Net possui uma classe chamada SemaphoreSlim que limita o número de threads que podem acessar um recurso (ou vários recursos) simultaneamente.

Vamos modificar o nosso código e adicionar essa classe para gerenciar as execuções em paralelo:

static void Main(string[] args)
{        
    var timer = new Stopwatch();
    Console.WriteLine($"Início da execução");

    timer.Start();
    ProcessarMassaDeDadosComSemaforo();
    timer.Stop();

    Console.WriteLine($"Tempo: {timer.Elapsed:m\\:ss\\.fff}");
    Console.ReadKey();
}

static void ProcessarMassaDeDadosComSemaforo()
{
    var semaphoreSlim = new SemaphoreSlim(100, 100);
    var listOfTasks = new List<Task>();
    for (int i = 0; i < 10000; i++)
    {
        listOfTasks.Add(ProcessarRegistro(semaphoreSlim));
    }
    Task.WaitAll(listOfTasks.ToArray());
}

static async Task ProcessarRegistro(SemaphoreSlim semaphoreSlim)
{
    await semaphoreSlim.WaitAsync();

    var _httpClient = HttpClientFactory.Create();
    await _httpClient.GetAsync("http://httpstat.us/200?sleep=1000");

    semaphoreSlim.Release();
}
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, instanciamos a classe SemaphoreSlim e no seu construtor passamos respectivamente o número inicial de solicitações que podem ser executadas simultaneamente e o número máximo de solicitações que podem ser executadas simultaneamente.

Isso significa que após 100 tasks estarem sendo executadas em paralelo, a próxima só será executada quando uma dessas 100 terminarem, limitando sempre a no máximo 100 tasks em paralelo.
Obs: No código acima foi usado a quantidade de 100 execuções em paralelo somente como forma de exemplo, o ideal é você calibrar esse número de acordo com o seu cenário e ambiente.

Dentro do método ProcessarRegistro usamos o método semaphoreSlim.WaitAsync() e semaphoreSlim.Release(), eles que são os responsáveis respectivamente por fazer a execução aguardar e para liberar uma posição no semáforo.

Rodando o código com as modificações temos o seguinte resultado no meu computador:

Início da execução
Tempo: 2:23.149
Enter fullscreen mode Exit fullscreen mode

Podemos perceber que o processamento demora um pouco mais porém os recursos do ambiente são utilizados de maneira controlada.

Conclusão

Quando criamos uma aplicação devemos sempre considerar que o ambiente em execução possui recursos limitados. A classe SemaphoreSlim pode ser uma boa solução para utilizar esses recursos de maneira controlada.

Referências

SemaphoreSlim Class - Microsoft Documentation
Using SemaphoreSlim to Make Parallel HTTP Requests in .NET Core
Understanding Semaphore in .NET Core

Discussion (0)