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");
}
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
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 concorrência significativa pelos recursos do sistema. Em um ambiente de produção, onde o sistema pode estar processando centenas de outras requisições simultaneamente, a execução desse código pode não ser tão eficiente. Problemas como esgotamento de recursos (estouro de memória, limite de rede, sobrecarga no banco de dados, etc) pode causar lentidões e até mesmo quebrar a aplicação. Portanto, é crucial considerar esses fatores e implementar mecanismos de controle para evitar a sobrecarga do sistema ao processar tarefas em paralelo.
Conhecendo a classe SemaphoreSlim
O .Net possui uma classe chamada SemaphoreSlim
que limita o número de tarefas que podem ser executadas 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();
}
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
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
Top comments (0)