Conceitos errados para desenvolvedores iniciantes em C #. Tentando responder a perguntas padrão

Recentemente, tive a oportunidade de conversar com um número bastante grande de desenvolvedores iniciantes em C #. Muitos deles estão interessados ​​em linguagem e plataforma, e isso é muito legal. Entre os juniores verdes, o obscurantismo é generalizado sobre as coisas óbvias (basta ler um livro sobre memória). E isso também me levou a criar este artigo. O artigo é voltado principalmente para desenvolvedores iniciantes, mas acho que muitos fatos serão úteis para a prática de engenheiros. Bem, os erros mais óbvios e desinteressantes, é claro, são omitidos. Aqui estão os mais interessantes e significativos, especialmente do ponto de vista da aprovação na entrevista.



# 1 Mantra cerca de 3 gerações em qualquer situação


Isso é mais uma imprecisão do que um erro. A pergunta sobre o "coletor de lixo em C #" para o desenvolvedor tornou-se um clássico e poucas pessoas começarão a responder de maneira inteligente sobre o conceito de gerações. No entanto, por alguma razão, poucas pessoas prestam atenção ao fato de que o grande e terrível coletor de lixo faz parte do tempo de execução. Assim, eu teria deixado claro que não era um dedo e teria perguntado que tipo de ambiente de tempo de execução estava envolvido. Para a consulta "coletor de lixo em c #" na Internet, você pode encontrar mais do que muitas informações semelhantes. No entanto, poucas pessoas mencionam que essas informações se referem ao CLR / CoreCLR (como regra). Mas não se esqueça do Mono, um tempo de execução leve, flexível e incorporado que ocupou seu nicho no desenvolvimento móvel (Unity, Xamarin) e é usado no Blazor. E para os respectivos desenvolvedores, aconselho que você indague sobre os detalhes do dispositivo de montagem em Mono. Por exemplo, na consulta “gerações mono-coletor de lixo”, é possível ver que existem apenas duas gerações - berçário e geração antiga (no novo e elegante coletor de lixo - SGen ).

# 2 Mantra cerca de 2 etapas da coleta de lixo em qualquer situação


Não faz muito tempo, as fontes do coletor de lixo estavam escondidas de todos. No entanto, o interesse na estrutura interna da plataforma sempre foi. Portanto, as informações foram extraídas de diferentes maneiras. E algumas imprecisões na engenharia reversa do coletor levaram ao mito de que o coletor trabalha em duas etapas: marcação e limpeza. Ou pior, 3 etapas - marcação, limpeza, compressão.

No entanto, tudo mudou quando o pessoal do fogo desencadeou uma guerra com o advento do CoreCLR e o código-fonte do coletor. O código do compilador para CoreCLR foi obtido inteiramente da versão do CLR. Ninguém o escreveu do zero, respectivamente, quase tudo que pode ser aprendido com o código-fonte CoreCLR também será válido para o CLR. Agora, para entender como algo funciona, basta ir ao github e encontrá-lo no código-fonte ou leia o leia- me . Lá você pode ver que existem 5 fases: marcação, planejamento, atualização de links, compactação (exclusão com realocação) e exclusão sem realocação (isso é difícil de traduzir). Mas formalmente pode ser dividido em 3 etapas - marcação, planejamento, limpeza.

No estágio da marcação, verifica-se quais objetos não devem ser coletados pelo coletor.
No estágio de planejamento, vários indicadores do estado atual da memória são calculados e os dados necessários no estágio de limpeza são coletados. Graças às informações recebidas nesta etapa, é tomada uma decisão sobre a necessidade de compactação (desfragmentação), que também calcula quanto você precisa mover objetos, etc.

E na fase de limpeza , dependendo da necessidade de compactação, os links podem ser atualizados e compactados ou excluídos sem se mover.

# 3 Alocar memória no heap é tão rápido quanto na pilha


Novamente, imprecisão, em vez de mentira absoluta. No caso geral, é claro, a diferença na velocidade de alocação de memória é mínima. De fato, na melhor das hipóteses, com alocação de ponteiro de retorno , a alocação de memória é apenas uma mudança de ponteiro, como na pilha. No entanto, fatores como atribuir um novo objeto ao campo antigo (que afetará a barreira de gravação , atualizar a tabela de cartões - um mecanismo que permite rastrear links da geração mais antiga para a mais recente), a presença de um finalizador (você deve adicionar o tipo à fila apropriada) pode afetar a alocação de memória na pilha. Também é possível que o objeto seja registrado em um dos furos livres na pilha (após a montagem sem desfragmentação). E encontrar um buraco assim, embora rápido, é obviamente mais lento que um simples deslocamento de ponteiro. Bem, é claro, cada objeto criado aproxima a próxima coleta de lixo. E no próximo procedimento para alocar memória, isso pode acontecer. O que, naturalmente, levará algum tempo.

# 4 Definição de referência, tipos significativos e embalagens através dos conceitos de pilha e pilha


Clássico certo, que, felizmente, não é tão comum.

O tipo de referência está localizado na pilha. Significativo na pilha. Certamente muitos já ouviram essas definições com muita frequência. Mas essa não é apenas uma verdade parcial, portanto, definir conceitos por meio de abstração vazada não é uma boa idéia. Para todas as definições, sugiro que você consulte o padrão CLI - ECMA 335 . Primeiro, vale esclarecer que os tipos descrevem valores. Portanto, o tipo de referência é definido da seguinte forma - o valor descrito pelo tipo de referência (link) indica a localização de outro valor. Para um tipo significativo, o valor descrito por ele é autônomo (independente). Sobre onde esses ou esses tipos de palavras estão localizados. Esta é uma abstração vazada que você ainda deve saber.

Um tipo significativo pode estar localizado:

  1. Na memória dinâmica (pilha), se faz parte de um objeto localizado na pilha ou no caso de empacotamento;
  2. Na pilha, se for um valor local variável / argumento / retorno do método;
  3. Nos registros, se permite o tamanho de um tipo significativo e outras condições.

O tipo de referência, ou seja, o valor para o qual o link aponta, está atualmente localizado no heap.

O link em si pode estar localizado no mesmo local que o tipo significativo.

A embalagem também não é determinada pelos locais de armazenamento. Considere um breve exemplo.

Código C #
public struct MyStruct { public int justField; } public class MyClass { public MyStruct justStruct; } public static void Main() { MyClass instance = new MyClass(); object boxed = instance.justStruct; } 


E o código IL correspondente para o método Main

Código IL
  1: nop 2: newobj instance void C/MyClass::.ctor() 3: stloc.0 4: ldloc.0 5: ldfld valuetype C/MyStruct C/MyClass::justStruct 6: box C/MyStruct 7: stloc.1 8: ret 


Como o tipo significativo faz parte da referência, é óbvio que ele estará localizado no heap. E a sexta linha deixa claro que estamos lidando com embalagens. Consequentemente, a definição típica de "copiar da pilha para a pilha" falha.

Para determinar o que é um pacote, para iniciantes, vale dizer que, para cada tipo significativo, o CTS (sistema de tipos comuns) define um tipo de referência, chamado de tipo empacotado. Portanto, empacotar é uma operação em um tipo significativo que cria o valor do tipo empacotado correspondente contendo uma cópia bit a bit do valor original.

# 4 Eventos - um mecanismo separado


Os eventos existem a partir da primeira versão do idioma e as perguntas sobre eles são muito mais comuns do que os próprios eventos. No entanto, vale a pena entender e saber o que é, porque esse mecanismo permite que você escreva um código muito pouco acoplado, o que às vezes é útil.

Infelizmente, muitas vezes um evento é entendido como um instrumento, tipo, mecanismo separado. Isso é especialmente facilitado pelo tipo do BCL EventHandler , cujo nome sugere que é algo separado.

A definição de um evento deve começar definindo as propriedades. Há muito tempo eu desenhei essa analogia para mim e vi recentemente que ela foi desenhada na especificação da CLI.

A propriedade define o valor nomeado e os métodos que o acessam. Isso parece bastante óbvio. Passamos para eventos. O CTS suporta eventos e propriedades, mas os métodos de acesso são diferentes e incluem métodos para inscrição e cancelamento de inscrição em um evento. Na especificação da linguagem C #, a classe define um evento ... que lembra uma declaração de campo com a adição da palavra-chave event. O tipo desta declaração deve ser o tipo de delegado. Graças ao padrão CLI para as definições.

Portanto, isso significa que o evento nada mais é do que um delegado que expõe apenas parte da funcionalidade dos delegados - adicionando outro delegado à lista para execução, removendo-o desta lista. Dentro da classe, o evento não é diferente de um campo simples do tipo delegado.

# 5 Recursos gerenciados e não gerenciados. Finalizadores e IDisposable


Existe uma confusão absoluta ao lidar com esses recursos. Isso é amplamente facilitado pela Internet com milhares de artigos sobre a implementação correta do padrão Dispose. Na verdade, não há nada de criminoso nesse padrão - um método de modelo modificado para um caso específico. Mas a questão é se é necessário. Por alguma razão, algumas pessoas têm um desejo irresistível de implementar um finalizador para cada espirro. Muito provavelmente, a razão para isso não é uma compreensão completa do que é um "recurso não gerenciado". E as linhas sobre o fato de que nos finalizadores, via de regra, os recursos não gerenciados são liberados devido a esse entendimento incompleto, passam e não permanecem na cabeça.

Um recurso não gerenciado é um recurso que não é gerenciado (por mais estranho que seja). Um recurso gerenciado , por sua vez, é aquele que é alocado e liberado pela CLI automaticamente por meio de um processo chamado coleta de lixo. Eu descaradamente descartei essa definição do padrão CLI. Mas se você tentar explicar de maneira mais simples, os recursos não gerenciados são aqueles que o coletor de lixo não conhece. (A rigor, podemos fornecer ao coletor algumas informações sobre esses recursos usando GC.AddMemoryPressure e GC.RemoveMemoryPressure, isso pode afetar o ajuste interno do coletor). Consequentemente, ele não será capaz de cuidar da libertação deles, e, portanto, devemos fazer isso por ele. E pode haver muitas abordagens para isso. E para que o código não fique deslumbrado com a diversidade da imaginação dos desenvolvedores, são utilizadas 2 abordagens geralmente aceitas.

  1. A interface IDisposable (e sua versão assíncrona de IAsyncDisposable). Ele é monitorado por todos os analisadores de código, por isso é difícil esquecer sua chamada. Fornece um único método - Dispose. E o suporte ao compilador é a instrução using. Um excelente candidato para o corpo do método Dispose é chamar um método semelhante de um dos campos da classe ou liberar um recurso não gerenciado. Chamado explicitamente pelo usuário da classe. A presença dessa interface na classe implica que, após a conclusão do trabalho com a instância, você precisa chamar esse método.
  2. Finalizador Na sua essência, o seguro. Chamado implicitamente, em um tempo indefinido, durante a coleta de lixo. Retarda a alocação de memória, o trabalho do coletor de lixo, prolonga a vida útil dos objetos pelo menos até a próxima montagem, ou até mais, mas é chamado por si só, mesmo que ninguém o tenha chamado. Devido à sua natureza não determinística, somente recursos não gerenciados devem ser liberados. Você também pode encontrar exemplos nos quais o finalizador foi usado para ressuscitar o objeto e organizar o pool de objetos dessa maneira. No entanto, essa implementação de um conjunto de objetos é definitivamente uma má ideia. Como tentar fazer login, lançar exceções, acessar o banco de dados e milhares de ações semelhantes.

E você pode facilmente imaginar a situação ao escrever uma biblioteca crítica para o desempenho, que usa recursos não gerenciados internamente, que pode ser manipulada simplesmente pelo manuseio competente desse recurso, liberando a memória com cuidado manualmente. Ao escrever essas bibliotecas de alto desempenho, o OOP, o suporte e outros semelhantes vão além.

E, contrariamente à afirmação de que Dispose viola o conceito em que o CLR fará tudo por nós, nos forçará a fazer algo por conta própria, a se lembrar de algo etc., direi o seguinte. Ao trabalhar com recursos não gerenciados, você deve estar preparado para que eles não sejam gerenciados por ninguém além de você. E, em geral, as situações em que esses recursos serão usados ​​nos empreendimentos quase nunca são encontradas. E, na maioria dos casos, você pode conviver com maravilhosas classes de wrapper, como o SafeHandle, que fornece finalização crítica de recursos, impedindo a montagem prematura.

Se, por um motivo ou outro, houver muitos recursos em seu aplicativo que exijam etapas adicionais para liberar, verifique o excelente padrão do JetBrains, Lifetime. Mas você não deve usá-lo quando vir o primeiro objeto IDisposable.

# 6 Pilha de fluxo, pilha de chamadas, pilha de computação e
  Pilha <T> 


O último parágrafo acrescentou risadas por isso; não creio que haja quem atribua o último aos dois anteriores. No entanto, há muita confusão sobre o que são uma pilha de fluxo, pilha de chamada e pilha computacional.

A pilha de chamadas é uma estrutura de dados, ou seja, uma pilha, para armazenar endereços de retorno, para retornar de funções. A pilha de chamadas é um conceito mais lógico. Ele não regula onde e como as informações devem ser armazenadas para retorno. Acontece que a pilha de chamadas é a pilha mais comum e nativa, ou seja, Pilha (piada). As variáveis ​​locais são armazenadas nele, os parâmetros são passados ​​por ele e os endereços de retorno são armazenados quando a instrução CALL e as interrupções são chamadas, que são subsequentemente usadas pela instrução RET para retornar da função / interrupção. Vá em frente. Uma das principais piadas do fluxo é um ponteiro para a instrução, que é executada posteriormente. Um thread, por sua vez, executa instruções que se combinam em funções. Por conseguinte, cada segmento tem uma pilha de chamadas. Assim, verifica-se que a pilha de fluxo é a pilha de chamadas. Ou seja, a pilha de chamadas desse fluxo. Em geral, também é referido sob outros nomes: pilha de software, pilha de máquinas.

Foi considerado em detalhes no artigo anterior .
Além disso, a definição da pilha de chamadas é usada para indicar a cadeia de chamadas de métodos específicos em um idioma específico.

Pilha de computação (pilha de avaliação) . Como você sabe, o código C # é compilado no código IL, que faz parte das DLLs resultantes (no caso mais geral). E no coração do tempo de execução que absorve nossas DLLs e executa o código IL está a máquina de empilhar. Quase todas as instruções de IL operam com uma certa pilha. Por exemplo, o ldloc carrega uma variável local em um índice específico na pilha. Aqui, a pilha se refere a uma certa pilha virtual, porque no final essa variável pode com alta probabilidade estar em registros. Instruções aritméticas, lógicas e outras instruções de IL operam nas variáveis ​​da pilha e colocam o resultado lá. Ou seja, os cálculos são feitos através dessa pilha. Assim, verifica-se que a pilha de computação é uma abstração em tempo de execução. A propósito, muitas máquinas virtuais são baseadas em pilha.

# 7 Mais threads - código mais rápido


Parece intuitivamente que o processamento de dados em paralelo será mais rápido do que alternadamente. Portanto, munidos de conhecimento sobre o trabalho com encadeamentos, muitos tentam paralelizar qualquer ciclo e computação. Quase todo mundo já sabe sobre a sobrecarga, o que contribui para a criação do encadeamento, então eles usam os encadeamentos do ThreadPool e Task de maneira famosa. Mas a sobrecarga de criação de um fluxo está longe do fim. Aqui estamos lidando com outra abstração vazada, o mecanismo que o processador usa para melhorar o desempenho - o cache. E, como costuma acontecer, o cache é uma lâmina de dois gumes. Por um lado, acelera significativamente o trabalho com acesso seqüencial aos dados de um fluxo. Mas, por outro lado, quando vários threads funcionam, mesmo sem a necessidade de sincronizá-los, o cache não apenas ajuda, mas também torna o trabalho mais lento. É gasto tempo adicional na invalidação do cache, ou seja, manutenção de dados relevantes. E não subestime esse problema, que a princípio parece um pouco. Um algoritmo eficiente em cache executará um encadeamento mais rapidamente que um algoritmo multithread, no qual o cache é usado ineficientemente.

Também tentar trabalhar com uma unidade de vários threads é suicídio. O disco já é um fator inibidor em muitos programas que trabalham com ele. Se você tentar trabalhar com ele a partir de vários threads, precisará esquecer a velocidade.

Para todas as definições, recomendo entrar em contato aqui:

Especificação da linguagem C # - ECMA-334
Apenas boas fontes:
Konrad Kokosa - Gerenciamento de memória Pro .NET
Especificação CLI - ECMA-335
Desenvolvedores CoreCLR sobre tempo de execução - Book Of The Runtime
De Stanislav Sidristy sobre finalização e muito mais - .NET Platform Architecture

Source: https://habr.com/ru/post/pt463213/


All Articles