.NET - Otimizando consultas com execução em paralelo
Hoje veremos como otimizar consultas na plataforma .NET com a execução em paralelo. |
Às vezes, temos que realizar várias consultas ao banco de dados, colocar os resultados na memória e processá-los conforme necessário.
|
Vamos supor que temos uma tabela Fornecedores e uma
tabela Clientes onde ambas as tabelas têm uma
quantidade substancial de dados e queremos combinar os dados existentes das duas
tabelas com critérios específicos.
Geralmente, realizaremos duas consultas sequencialmente da seguinte forma :
consultar primeiro a tabela Fornecedores e, em seguida, consultar a tabela
Clientes. A segunda consulta será executada após a conclusão da primeira
consulta.
Se cada consulta levar 5 segundos para ser processada e o processo de mesclagem
da consulta na memória levar 2 segundos, o tempo total necessário para concluir
todo o processo será de cerca de 12 segundos (5+5+2).
Agora surge a pergunta: “Podemos realizar a primeira e a segunda consulta
simultaneamente (paralela) e a seguir combinar os resultados depois que as duas
consultas forem concluídas ?”
Sim, podemos !!!
Com isso, o tempo total necessário para concluir todo o processo será reduzido significativamente executando a primeira e a segunda consultas simultaneamente.
Como fazer isso ???
Temos duas opções principais que podemos usar :
O método AsParallel() esta disponível no namespace System.Linq e permite que a consulta LINQ seja executada em paralelo, aproveitando os recursos de hardware disponíveis, como processadores com vários núcleos. Quando você chama o método AsParallel() em uma consulta LINQ, o .NET irá executar a consulta em várias threads para acelerar o processamento. Por exemplo:
using System.Linq;
var numeros = Enumerable.Range(1, 1000000);
var resultado = numeros.AsParallel()
.Where(x => x % 2 == 0)
.ToArray();
|
Nesse exemplo, a consulta LINQ é executada em paralelo usando o método AsParallel(), e, isso permite que o .NET processe a consulta mais rapidamente, pois aproveita vários núcleos do processador. Observe que o resultado da consulta é convertido em uma matriz usando o método ToArray().
Já o método WhenAll() é um método disponível na classe Task do namespace System.Threading.Tasks. Ele permite que você execute várias tarefas de forma assíncrona e aguarde até que todas as tarefas sejam concluídas. Por exemplo:
using System.Threading.Tasks;
var t1 = Task.Run(() => MetodoAssincrono1());
var t2 = Task.Run(() => MetodoAssincrono2());
await Task.WhenAll(t1, t2);
|
Nesse exemplo, duas tarefas assíncronas são criadas usando o método Task.Run(), e o método WhenAll() é chamado com as duas tarefas. Isso permite que ambas as tarefas sejam executadas em paralelo e aguarda até que ambas sejam concluídas.
Executando consultas em paralelo
Vamos então mostrar na prática a vantagem em executar consultas em paralelo usando as duas abordagens acima.
Vamos criar um projeto Console no VS 2022 chamado OtimizaConsultas usando o .NET e o recurso do Top Level Statement.
Inclua no projeto o pacote nuget : Microsoft.EntityFrameworkCore.SqlServer (estou usando a versão 7.05)
Neste projeto vamos criar a pasta Entities e criar nesta pasta as classes Fornecedor e Cliente:
1- Cliente
public
class
Cliente { public int Id { get; set; } public string? Nome { get; set; } } |
2- Fornecedor
public
class
Fornecedor { public int Id { get; set; } public string? Nome { get; set; } } |
Crie uma pasta Context no projeto e crie a classe de contexto AppDbContext que herda de DbContext:
public
class
AppDbContext
: DbContext { public AppDbContext(DbContextOptions options) : base(options) {} public DbSet<Fornecedor> Fornecedores { get; set; } public DbSet<Cliente> Clientes { get; set; } } |
Ainda nesta pasta vamos criar a classe PopulaDados() que iremos usar para criar o banco de dados e as tabelas Fornecedores e Clientes incluindo 1000000 registros para podermos fazer o teste.
public class PopulaDados
{
private readonly AppDbContext _db;
public PopulaDados(AppDbContext db)
{
_db = db;
}
public async Task Run()
{
await _db.Database.EnsureDeletedAsync();
await _db.Database.EnsureCreatedAsync();
var total = 1000000;
for (int i = 0; i < total; i++)
{
_db.Fornecedores.Add(new Fornecedor
{
Nome = $"F{(i + total)}"
});
_db.Clientes.Add(new Cliente
{
Nome = $"C{(i + total)}"
});
}
await _db.SaveChangesAsync();
}
}
|
Ao final da execução deste código teremos a tabela Clientes e Fornecedores com 1000000 de registros onde o campo Nome em Clientes inicia com 'C1000000' e em Fornecedores inicia com 'F1000000'
Esse método é usado para garantir que um banco de dados seja excluído antes que o contexto o crie novamente. Ele é útil em cenários de teste, onde você deseja excluir o banco de dados antigo antes de executar novamente o teste para garantir que o teste esteja limpo e comece do zero.
O método EnsureDeletedAsync() exclui o banco de dados de forma assíncrona, e isso é útil em cenários em que você precisa excluir o banco de dados em uma thread separada para não bloquear a thread principal.
Observe que esse método não apenas remove as tabelas do banco de dados, mas também exclui todo o banco de dados. Se você quiser apenas excluir algumas tabelas, pode usar o método DropTable(). Além disso, lembre-se de que este método exclui permanentemente o banco de dados, portanto, use-o com cuidado em cenários de produção.
Esse método é usado para criar o banco de dados caso ele não exista. Quando você chama esse método, o Entity Framework Core verifica se o banco de dados já existe e, se não existir, ele cria o banco de dados com base no modelo definido pelo seu contexto.
O método EnsureCreatedAsync() cria o banco de dados de forma assíncrona, no entanto, é importante notar que o ele não atualiza o esquema do banco de dados se você fizer alterações no modelo do seu contexto. Se você fizer alterações no modelo, precisará excluir e recriar o banco de dados ou usar a migração de banco de dados para atualizar o esquema do banco de dados.
Este método é projetado para ser usado em cenários de desenvolvimento e teste, pois cria o banco de dados automaticamente. Em um ambiente de produção, geralmente é necessário usar a migração de banco de dados para criar ou atualizar o esquema do banco de dados.
Vamos agora definir na classe Program o código onde vamos executar a consulta sequêncial e a seguir vamos executar as consultas em paralelo comparando o tempo gasto usando a classe StopWatch.
using
EFConsulta.Context; using Microsoft.EntityFrameworkCore; using System.Diagnostics; var stopwatch = new Stopwatch();var connectionString = "Data Source=.;Initial Catalog = DemoDB; Integrated Security = True";var optionsBuilder = new DbContextOptionsBuilder().UseSqlServer(connectionString); // inicialização stopwatch.Restart(); var ctxSequencial = new AppDbContext(optionsBuilder.Options); await ConsultaSequencial(stopwatch, ctxSequencial); // paralela - WhenAll() stopwatch.Restart(); // cria duas instancias do contexto var ctxParalelo1 = new AppDbContext(optionsBuilder.Options); var ctxParalelo2 = new AppDbContext(optionsBuilder.Options); await ConsultaParalela(stopwatch, ctxParalelo1, ctxParalelo2); // paralela - AsParallel() stopwatch.Restart(); ConsultaParalela2(stopwatch, ctxParalelo1, ctxParalelo2); Console.ReadKey(); |
Primeiro temos o código para inicializar os dados criando o banco e as tabelas e populando os dados:
static
async Task
InicializacaoDados(Stopwatch stopwatch,
DbContextOptionsBuilder optionsBuilder) { Console.WriteLine("Criando o banco e populando as tabelas..."); Console.WriteLine("Aguarde..."); // inicializa os dados stopwatch.Start(); var dbInicializa = new AppDbContext(optionsBuilder.Options); var populaDados = new PopulaDados(dbInicializa); await populaDados.Run(); stopwatch.Stop(); Console.WriteLine($"Inicialização dos dados: {stopwatch.Elapsed}"); } |
A seguir vejamos o código da consulta sequencial:
static
async
Task ConsultaSequencial(Stopwatch stopwatch, AppDbContext ctxSequencial) { Console.WriteLine("\nExecutando consulta sequencial...\n"); var fornecedoresSequencial = await ctxSequencial.Clientes .Where(x => x.Nome.Contains("7")) .OrderByDescending(x => x.Nome) .ToListAsync(); var clientesSequencial = await ctxSequencial.Clientes .Where(x => x.Nome.Contains("7")) .OrderByDescending(x => x.Nome) .ToListAsync();
var totalSequencial =
fornecedoresSequencial.Concat(clientesSequencial).ToList(); |
Agora o código da consulta em paralelo usando o método WhenAll() da classe Task:
static
async
Task ConsultaParalela(Stopwatch stopwatch, AppDbContext ctxParalelo1,
AppDbContext ctxParalelo2) { Console.WriteLine("\nExecutando consulta em paralelo usando WhenAll...\n"); // task para a primeira consulta var taskFornecedorParalelo = ctxParalelo1.Fornecedores .Where(x => x.Nome.Contains("7")) .OrderByDescending(x => x.Nome) .ToListAsync(); // task para segunda consulta var taskClienteParalelo = ctxParalelo2.Clientes .Where(x => x.Nome.Contains("7")) .OrderByDescending(x => x.Nome) .ToListAsync(); // roda ambas as task em paralelo await Task.WhenAll(taskFornecedorParalelo, taskClienteParalelo); // Obtem os resultados das consultas var resultadoConsultaFornecedores = taskFornecedorParalelo.Result; var resultadoConsultaClientes = taskClienteParalelo.Result; var consultaParaleloTotal = resultadoConsultaFornecedores.Count + resultadoConsultaClientes.Count; stopwatch.Stop(); Console.WriteLine($"\nConsulta em Paralelo (Total: {consultaParaleloTotal}): {stopwatch.Elapsed}"); } |
Para concluir temos o código da consulta em paralelo usando o método AsParallel() da LINQ:
static
void
ConsultaParalela2(Stopwatch stopwatch, AppDbContext ctxParalelo1, AppDbContext ctxParalelo2) { Console.WriteLine("\nExecutando consulta em paralelo usando AsParallel...\n"); var fornecedores = ctxParalelo1.Fornecedores .AsParallel() .Where(f => f.Nome.Contains("7")) .Select(f => new { Id = f.Id, Nome = f.Nome }) .ToList(); var clientes = ctxParalelo2.Clientes .AsParallel() .Where(c => c.Nome.Contains("7")) .Select(c => new { Id = c.Id, Nome = c.Nome }) .ToList(); var resultado = fornecedores.Concat(clientes).ToList(); stopwatch.Stop(); Console.WriteLine($"\nConsulta Paralela (Total: {resultado.Count}): {stopwatch.Elapsed}"); } |
Executando o projeto teremos o seguinte resultado no console:
A consulta sequêncial leva algo em torno de 7 segundos enquanto a consulta em paralelo com WhenAll() gasta 4 segundos , quase a metade tempo. Na segunda consulta em paralelo temos um tempo parecido com a primeira consulta em paralelo.
Com isso temos que a execução das consultas em paralelo aumenta o desempenho e velocidade da execução.
No entanto, devemos ter cuidado, pois temos que usar um contexto de dados diferente para cada consulta que será executada em paralelo e usar vários contextos de dados requerem mais alocação de memória e mais pool de conexões.
Pegue o código do projeto: OtimizaConsultas.zip ...
"Uns confiam em carros e outros em cavalos, mas nós faremos
menção do nome do Senhor nosso Deus."
Salmos 20:7
Referências:
LINQ - Gerando um Produto Cartesiano - Macoratti
C# - Salvando e Lendo informações em um arquivo XML - Macoratti