Como o GC afeta a performance em .NET

Validação de CPF

Uma das causas mais comuns para problemas de performance em .NET é o descuido com o Garbage Collector. Mesmo funções simples, se implementadas de forma descuidada, podem ocasionar boas dores de cabeça.

Neste post, compartilha a análise e otimização de uma simples função para validação de CPF. Algo pequeno, frente ao tamanho de um sistema, mas sintomático quanto ao estilo de código que temos encontrado.

Como fizemos o teste

Acreditamos que é sempre importante ter mensurações para pautar qualquer otimização. Eis um exemplo simples de como mensurar uma função isolada:

static void Main(string[] args)
{
    var sw = new Stopwatch();
    var before2 = GC.CollectionCount(2);
    var before1 = GC.CollectionCount(1);
    var before0 = GC.CollectionCount(0);
    Func<string, bool> sut = Original.ValidarCPF;

    sw.Start();
    for (int i = 0; i < 1_000_000; i++)
    {
        if (!sut("771.189.500-33"))
        {
            throw new Exception("Error!");
        }

        if (sut("771.189.500-34"))
        {
            throw new Exception("Error!");
        }
    }
    sw.Stop();

    Console.WriteLine($"Tempo total: {sw.ElapsedMilliseconds}ms");
    Console.WriteLine($"GC Gen #2  : {GC.CollectionCount(2) - before2}");
    Console.WriteLine($"GC Gen #1  : {GC.CollectionCount(1) - before1}");
    Console.WriteLine($"GC Gen #0  : {GC.CollectionCount(0) - before0}");
    Console.WriteLine("Done!");
}

Aqui, estamos apurando tempo aproximado de execução. Poderíamos utilizar algo mais preciso (como Benchmark.net), mas, geralmente começamos considerando uma abordagem mais simples (apenas para termos uma ordem de grandeza).

Como, geralmente, problemas de performance tem relação direta com o Garbage Collector, apuramos o comportamento do mesmo também.

Obviamente, testar performance validando 1_000_000 de CPFs é um exagero. Perceba, porém, que este número “exagerado”, serve para que a comparação seja perceptível.

Mais uma vez, a ideia não é focar na função em si, mas destacar problemas com o estilo de codificação adotado.

O código original

Aqui, o código original, encontrado em uma base de código quente de um cliente.

public static bool ValidarCPF(string sourceCPF)
{
    if (String.IsNullOrWhiteSpace(sourceCPF))
        return false;

    string clearCPF;
    clearCPF = sourceCPF.Trim(); 
    clearCPF = clearCPF.Replace("-", "");
    clearCPF = clearCPF.Replace(".", ""); 

    if (clearCPF.Length != 11)
    {
        return false;
    }

    int[] cpfArray;
    int totalDigitoI = 0;
    int totalDigitoII = 0;
    int modI;
    int modII;

    if (clearCPF.Equals("00000000000") ||
        clearCPF.Equals("11111111111") ||
        clearCPF.Equals("22222222222") ||
        clearCPF.Equals("33333333333") ||
        clearCPF.Equals("44444444444") ||
        clearCPF.Equals("55555555555") ||
        clearCPF.Equals("66666666666") ||
        clearCPF.Equals("77777777777") ||
        clearCPF.Equals("88888888888") ||
        clearCPF.Equals("99999999999"))
    {
        return false;
    }

    foreach (char c in clearCPF)
    {
        if (!char.IsNumber(c))
        {
            return false;
        }
    }

    cpfArray = new int[11];
    for (int i = 0; i < clearCPF.Length; i++)
    {
        cpfArray[i] = int.Parse(clearCPF[i].ToString());
    }

    for (int posicao = 0; posicao < cpfArray.Length - 2; posicao++)
    {
        totalDigitoI += cpfArray[posicao] * (10 - posicao);
        totalDigitoII += cpfArray[posicao] * (11 - posicao);
    }

    modI = totalDigitoI % 11;
    if (modI < 2) { modI = 0; }
    else { modI = 11 - modI; }

    if (cpfArray[9] != modI)
    {
        return false;
    }

    totalDigitoII += modI * 2;

    modII = totalDigitoII % 11;
    if (modII < 2) { modII = 0; }
    else { modII = 11 - modII; }
    if (cpfArray[10] != modII)
    {
        return false;
    }
    // CPF Válido!
    return true;
}

Eis o os resultados .

O número que chama atenção aqui é a quantidade de coletas na Gen0 (algumas centenas).

Eliminando alocações obviamente desnecessárias

A primeira coisa que fizemos para diminuir a pressão sobre o Garbage Collector foi reduzir a quantidade de alocações. Começamos pela parte mais óbvia: todo digito do CPF, antes de ser convertido para número, gerava um string. Além disso, um array era gerado para armazenar a “versão numérica” do CPF. Optamos por uma estratégia sem essas alocações.

public static class Version2
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int ObterDigito(
        string value,
        int pos
    ) => value[pos] - '0';

    public static bool ValidarCPF(string sourceCPF)
    {
        if (String.IsNullOrWhiteSpace(sourceCPF))
            return false;

        var clearCPF = sourceCPF.Trim();
        clearCPF = clearCPF.Replace("-", "");
        clearCPF = clearCPF.Replace(".", ""); 

        if (clearCPF.Length != 11)
        {
            return false;
        }

        int totalDigitoI = 0;
        int totalDigitoII = 0;

        if (clearCPF.Equals("00000000000") ||
            clearCPF.Equals("11111111111") ||
            clearCPF.Equals("22222222222") ||
            clearCPF.Equals("33333333333") ||
            clearCPF.Equals("44444444444") ||
            clearCPF.Equals("55555555555") ||
            clearCPF.Equals("66666666666") ||
            clearCPF.Equals("77777777777") ||
            clearCPF.Equals("88888888888") ||
            clearCPF.Equals("99999999999"))
        {
            return false;
        }

        foreach (char c in clearCPF)
        {
            if (!char.IsNumber(c))
            {
                return false;
            }
        }
        for (int posicao = 0; posicao < clearCPF.Length - 2; posicao++)
        {
            totalDigitoI += ObterDigito(clearCPF, posicao) * (10 - posicao);
            totalDigitoII += ObterDigito(clearCPF, posicao) * (11 - posicao);
        }

        var modI = totalDigitoI % 11;
        if (modI < 2) { modI = 0; }
        else { modI = 11 - modI; }

        if (ObterDigito(clearCPF, 9) != modI)
        {
            return false;
        }

        totalDigitoII += modI * 2;
        var modII = totalDigitoII % 11;
        if (modII < 2) { modII = 0; }
        else { modII = 11 - modII; }

        if (ObterDigito(clearCPF, 10) != modII)
        {
            return false;
        }
        return true;
    }
}

O resultado obtido foi animador.

Reduzimos o volume de alocações, logo, reduzimos a quantidade de coletas de lixo e melhoramos a performance do nosso código em mais de 4x.

Trocando alocações por processamento

Agora, vamos remover as 3 alocações de strings que ocorrem logo no início do código. VAmos pagar o preço com processamento para ver o resultado.

public static class Version3
{
    public struct Cpf
    {
        private readonly string _value;
            
        private Cpf(string value)
        {
            _value = value;

        }

        public int CalculaNumeroDeDigitos()
        {
            if (_value == null)
            {
                return 0;
            }

            var result = 0;
            for (var i = 0; i < _value.Length; i++)
            {
                if (char.IsDigit(_value[i]))
                {
                    result++;
                }
            }

            return result;
        }

        public bool VerficarSeTodosOsDigitosSaoIdenticos()
        {
            var previous = -1;
            for (var i = 0; i < _value.Length; i++)
            {
                if (char.IsDigit(_value[i]))
                {
                    var digito = _value[i] - '0';
                    if (previous == -1)
                    {
                        previous = digito;
                    }
                    else
                    {
                        if (previous != digito)
                        {
                            return false;
                        }
                    }
                }
            }
            return true;
        }

        public int ObterDigito(int posicao)
        {
            int count = 0;
            for (int i = 0; i < _value.Length; i++)
            {
                if (char.IsDigit(_value[i]))
                {
                    if (count == posicao)
                    {
                        return _value[i] - '0';
                    }
                    count++;
                }
            }

            return 0;
        }

        public static implicit operator Cpf(string value)
            => new Cpf(value);

        public override string ToString() => _value;
    }

    public static bool ValidarCPF(Cpf sourceCPF)
    {
        if (sourceCPF.CalculaNumeroDeDigitos() != 11)
        {
            return false;
        }
        
        int totalDigitoI = 0;
        int totalDigitoII = 0;

        if (sourceCPF.VerficarSeTodosOsDigitosSaoIdenticos())
        {
            return false;
        }

        for (int posicao = 0; posicao < 9; posicao++)
        {
            var digito = sourceCPF.ObterDigito(posicao);
            totalDigitoI += digito * (10 - posicao);
            totalDigitoII += digito * (11 - posicao);
        }

        var modI = totalDigitoI % 11;
        if (modI < 2) { modI = 0; }
        else { modI = 11 - modI; }

        if (sourceCPF.ObterDigito(9) != modI)
        {
            return false;
        }

        totalDigitoII += modI * 2;

        var modII = totalDigitoII % 11;
        if (modII < 2) { modII = 0; }
        else { modII = 11 - modII; }

        if (sourceCPF.ObterDigito(10) != modII)
        {
            return false;
        }

        return true;
    }
}

Vejamos o resultado:

Adeus alocações! Veja quanta melhoria na performance. Menos memória alocada, nenhuma chamada ao GC, e um código que entrega resultado em muito menos tempo.

Evitando processamento em duplicidade

Nosso processamento tirou o Garbage Collector de cena e melhorou a saúde do sistema. Mas, há um bocado de coisas que poderiam ser melhores. Não acha?

Vamos reescrever nosso código, agora removendo processamento desnecessário.

public static class Version5
{
    public struct Cpf
    {
        private readonly string _value;
            
        public readonly bool EhValido;
        private Cpf(string value)
        {
            _value = value;
               
            if (value == null)
            {
                EhValido = false;
                return;
            }

            var posicao = 0;
            var totalDigito1 = 0;
            var totalDigito2 = 0;
            var dv1 = 0;
            var dv2 = 0;

            bool digitosIdenticos = true;
            var ultimoDigito = -1;

            foreach (var c in value)
            {
                if (char.IsDigit(c))
                {
                    var digito = c - '0';
                    if (posicao != 0 && ultimoDigito != digito)
                    {
                        digitosIdenticos = false;
                    }

                    ultimoDigito = digito;
                    if (posicao < 9)
                    {
                        totalDigito1 += digito * (10 - posicao);
                        totalDigito2 += digito * (11 - posicao);
                    }
                    else if (posicao == 9)
                    {
                        dv1 = digito;
                    }
                    else if (posicao == 10)
                    {
                        dv2 = digito;
                    }

                    posicao++;
                }
            }

            if (posicao > 11)
            {
                EhValido = false;
                return;
            }

            if (digitosIdenticos)
            {
                EhValido = false;
                return;
            }
                
            var digito1 = totalDigito1 % 11;
            digito1 = digito1 < 2 
                ? 0 
                : 11 - digito1;

            if (dv1 != digito1)
            {
                EhValido = false;
                return;
            }

            totalDigito2 += digito1 * 2;
            var digito2 = totalDigito2 % 11;
            digito2 = digito2 < 2 
                ? 0 
                : 11 - digito2;

            EhValido = dv2 == digito2;
        }
            
        public static implicit operator Cpf(string value)
            => new Cpf(value);

        public override string ToString() => _value;
    }

    public static bool ValidarCPF(Cpf sourceCPF) => 
        sourceCPF.EhValido;
}

O código ficou um pouco mais complexo. Mas compreensível.

Aqui está o resultado:

Acho que chegamos a um bom ponto.

Finalizando

O objetivo desse post foi mostrar como o Garbage Collector pode influenciar na eficiência de um sistema. Acho que ficou claro. Não acha?

Você consegue identificar esse problema nos códigos de sua empresa? Podemos ajudar.

Mais posts da série Como o GC afeta a performance em .NET

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *