Melhorias de desempenho .NET Core 7 - Parte 1
Published Nov 01 2022 06:24 AM 3,112 Views
Microsoft

Quem me acompanha regularmente já sabe que eu adoro estudar e trabalhar com assuntos relacionados a desempenho no .NET, por isso estou extremamente entusiasmado com a nova versão.

Segundo o Stephen Toub essa nova versão é a que apresentou um dos maiores saltos qualitativos em desempenho na história do .NET Core.

"É o .NET mais rápido de todos os tempos. Se seu gerente perguntar por que o seu projeto deveria ser atualizado para o .NET 7, você pode dizer “além de todas as novas funcionalidades, o .NET 7 é super-rápido”." - Stephen Toub - Performance Improvements in .NET 7

São muitas melhorias em diversos pontos do Framework, nesse primeiro artigo vou focar nas melhorias em relação ao uso de Threads, assunto este tão presente na nossa vida quando estamos desenvolvendo aplicações Web.

 

Múltiplas Filas Globais

O ThreadPool conta com uma fila global de tarefas, onde qualquer thread pode enfileirar ou desenfileirar um item.

Cada uma das threads também conta com sua própria fila local, onde apenas a dona da fila pode enfileirar items, mas qualquer outra pode desenfileirar. Resultando em algumas filas compartilhadas.

Quando uma thread termina uma tarefa, primeiro ela busca algum item em sua própria fila, caso nada encontrar, ela tenta olhar para a fila global, se essa estiver sem tarefas, a thread percorrerá todas as outras filas "vizinhas", a fim de encontrar alguma tarefa para processar.

Com a evolução dos processadores, existem cada vez mais núcleos de processamento, e consequentemente nossas aplicações podem demandar números maiores de threads, o que em linhas gerais é um ponto positivo, mas em alguns casos pode acabar gerando uma contenção nessas filas, principalmente na fila global.

O pull request dotnet/runtime#69386 visa diminuir essa possível contenção, onde haverá mais de uma fila global em casos de máquinas com mais de 32 processadores.

 

Número de threads reduzido em momentos oportunos

Um outro cenário comum é quando existem várias threads em estado de "aguardando" e uma tarefa é adicionada na fila global, com isso o pool sinalizará essas threads que existem tarefas a serem processadas.

Quando o ThreadPool não estiver com uma carga muito elevada, pode ser que mais threads que o necessário comecem a competir para processar esta tarefa. Isso se tornou um ponto de degradação de desempenho em alguns momentos.

O PR dotnet/runtime#57885 endereçou de forma implacável esse problema, agora o ThreadPool sinalizará apenas UMA thread que existem tarefas a serem processadas, apenas depois que o primeiro item for desenfileirado, uma outra thread poderá ser acionada.

Para exemplificar vou usar a comparação entre a versão 6 e a 7, que está presente no artigo: https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7.

private readonly byte[][] _nestedArrays = new byte[8][];
private const int Iterations = 100_000;

private static byte IterateAll(byte[] arr)
{
    byte ret = default;
    foreach (byte item in arr) ret = item;
    return ret;
}

[Benchmark(OperationsPerInvoke = Iterations)]
public async Task MultipleSerial()
{
    for (int i = 0; i < Iterations; i++)
    {
        for (int j = 0; j < _nestedArrays.Length; j++)
        {
            _nestedArrays[j] = ArrayPool<byte>.Shared.Rent(4096);
            _nestedArrays[j].AsSpan().Clear();
        }

        await Task.Yield();

        for (int j = _nestedArrays.Length - 1; j >= 0; j--)
        {
            IterateAll(_nestedArrays[j]);
            ArrayPool<byte>.Shared.Return(_nestedArrays[j]);
        }
    }
}
Method Runtime Mean Ratio
MultipleSerial .NET 6.0 14.340 us 1.00
MultipleSerial .NET 7.0 9.262 us 0.6

 

O resultado é impressionante, uma melhoria de aproximadamente 64,58%.

 

Mudanças na classe ReaderWriterLockSlim

Antes de entender a mudança em si, vale uma breve explicação sobre o que é a classe ReaderWriterLockSlim, assim será mais simples de explicar suas melhorias.

A classe ReaderWriterLockSlim é usada para sincronizar acessos de leitura e escrita em objetos que podem ser usados por várias threads de forma concorrente (Thread safety).

Resumidamente, usando essa classe as threads poderão entrar em alguns estados diferentes:

  • Várias threads poderão estar em estado de leitura.
  • Apenas uma thread poderá estar no estado de escrita, sendo a dona do lock.
  • Uma thread poderá estar em modo de leitura atualizável, onde essa pode ser atualizada para o modo de escrita, sem ter que renunciar ao seu acesso de leitura. Isto diminui a complexidade para trabalhar com operações de upsearch em estruturas lineares (listas, dicionários, etc).

Agora que você já entende o que essa classe faz, observe o seguinte código (retirado do artigo Performance Improvements in .NET 7:(

using System.Diagnostics;

var rwl = new ReaderWriterLockSlim();
var tasks = new Task[100];
int count = 0;

DateTime end = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < end)
{
    for (int i = 0; i < 100; ++i)
    {
        tasks[i] = Task.Run(() =>
        {
            var sw = Stopwatch.StartNew();
            rwl.EnterReadLock();
            rwl.ExitReadLock();
            sw.Stop();
            if (sw.ElapsedMilliseconds >= 10)
            {
                Console.WriteLine(Interlocked.Increment(ref count));
            }
        });
    }

    Task.WaitAll(tasks);
}

O looping while (DateTime.UtcNow < end) irá criar uma lista de 100 tasks, que serão processadas de forma concorrente. Quando todas finalizarem, o programa criará mais 100 tasks, isso repetidamente por dez segundos. Para cada tarefa, será criado um cronômetro, através da chamada Stopwatch.StartNew(), em sequência será inciado e encerrado um lock através das chamadas: rwl.EnterReadLock();,rwl.ExitReadLock();.

O cronômetro irá mensurar qual foi o tempo gasto entre o início e o fim do lock, caso esse tempo seja maior que dez milissegundos um contador será incrementado e escrito no console do programa.

Rodando esse código no .NET 6 obtivemos aproximadamente 100 ocorrências do lock demorando mais do que dez milissegundos, em contrapartida .NET 7, não houve nenhum lock que demorou mais que dez milissegundos.

Essa diferença aconteceu por conta da PR dotnet/runtime#70165 que endereçou esse problema refatorando um ponto da classe ReaderWriterLockSlim, onde removeu uma chamada do método Thread.Sleep(1) dentro do método privado GetEnterDeprioritizationStateChange. O mesmo implementava uma espécie de spin-lock. Essa implementação estava resultando em uma latência em certos momentos.

 

Conclusão

São notáveis os avanços periódicos no desempenho do .NET. As melhorias são inúmeras, neste primeiro artigo foquei nas melhorias relacionadas as threads, pois praticamente qualquer melhoria de desempenho nesse tópico resulta em melhorias em diversos outros pontos, principalmente em pacotes como ASP.NET Core, EF Core, em alguns casos até diretamente na nossa aplicação.

Co-Authors
Version history
Last update:
‎Nov 01 2022 06:24 AM
Updated by: