Gerenciamento de memória ou menos frequentemente atire no seu pé

Olá Habr! Neste artigo, tentarei dizer o que é gerenciamento de memória em programas / aplicativos do ponto de vista de um programador de aplicativos. Este não é um guia ou manual exaustivo, mas simplesmente uma visão geral dos problemas existentes e algumas abordagens para resolvê-los.


Por que isso é necessário? Um programa é uma sequência de instruções de processamento de dados (no caso mais geral). Esses dados devem ser armazenados , carregados , transferidos etc. de alguma forma. Todas essas operações não ocorrem instantaneamente, portanto, elas afetam diretamente a velocidade da sua aplicação final. A capacidade de gerenciar dados de maneira otimizada no processo de trabalho permitirá criar programas não triviais e que exigem muitos recursos.


Nota: a maior parte do material é apresentada com exemplos de jogos / mecanismos de jogos (já que este tópico é mais interessante para mim), no entanto, a maior parte do material pode ser aplicada a servidores de gravação, aplicativos de usuário, pacotes gráficos etc.



É impossível manter tudo em mente. Mas se você não conseguiu carregá-lo, receberá sabão


Logo de cara


Aconteceu na indústria que grandes projetos de jogos AAA são desenvolvidos principalmente em mecanismos escritos usando C ++. Um dos recursos desse idioma é a necessidade de gerenciamento manual de memória. Java / C # etc. Eles possuem coleta de lixo (GarbageCollection / GC) - a capacidade de criar objetos e ainda não liberar a memória usada manualmente. Esse processo simplifica e acelera o desenvolvimento, mas também pode causar alguns problemas: um coletor de lixo acionado periodicamente pode matar todo o tempo em tempo real e adicionar congelamentos desagradáveis ​​ao jogo.


Sim, em projetos como "Minecraft" o GC pode não ser perceptível, pois eles geralmente não exigem os recursos do computador; no entanto, jogos como "Red Dead Redemption 2", "God of War", "Last of Us" trabalham "quase" no pico do desempenho do sistema e, portanto, precisam não apenas de grandes dimensões. quantidade de recursos, mas também em sua distribuição competente.


Além disso, trabalhando em um ambiente com alocação automática de memória e coleta de lixo, você pode encontrar uma falta de flexibilidade no gerenciamento de recursos. Não é segredo que o Java oculta todos os detalhes e aspectos de implementação de seu trabalho, portanto, na saída, você tem apenas a interface instalada para interagir com os recursos do sistema, mas pode não ser suficiente para solucionar alguns problemas. Por exemplo, iniciar um algoritmo com um número não constante de alocações de memória em cada quadro (isso pode ser uma busca de caminhos para IA, verificação de visibilidade, animação etc.) inevitavelmente leva a uma queda catastrófica no desempenho.


Como são as alocações no código


Antes de continuar a discussão, gostaria de mostrar como o trabalho com memória em C / C ++ acontece diretamente com alguns exemplos. Em geral, a interface padrão e mais simples para alocar memória do processo é representada pelas seguintes operações:


//        size  void* malloc(size_t size); //      p void free(void* p); 

Aqui você pode adicionar funções adicionais que permitem alocar um pedaço de memória alinhado:


 // C11  -     , * alignment void* aligned_alloc(size_t size, size_t alignment); // Posix  -       //        address (*address = allocated_mem_p) int posix_memalign(void** address, size_t alignment, size_t size); 

Observe que plataformas diferentes podem oferecer suporte a diferentes padrões de funções, disponíveis, por exemplo, no macOS e não disponíveis no win.


No futuro, áreas de memória especialmente alinhadas podem ser necessárias para você atingir a linha de cache do processador e fazer cálculos usando um conjunto extenso de registros ( SSE , MMX , AVX etc.).


Um exemplo de um programa de brinquedo que aloca memória e imprime valores de buffer, interpretando-os como números inteiros assinados:


 /* main.cpp */ #include <cstdio> #include <cstdlib> int main(int argc, char** argv) { const int N = 10; int* buffer = (int*) malloc(sizeof(int) * N); for(int i = 0; i < N; i++) { printf("%i ", buffer[i]); } free(buffer); return 0; } 

No macOS 10.14, este programa pode ser criado e executado com o seguinte conjunto de comandos:


 $ clang++ main.cpp -o main $ ./main 

Nota: daqui em diante, não quero realmente abranger operações C ++ como new / delete, pois é mais provável que sejam usadas para construir / destruir objetos diretamente, mas elas usam as operações usuais com memória como malloc / free.


Problemas de memória


Existem vários problemas que surgem ao trabalhar com a RAM do computador. Todos eles, de uma maneira ou de outra, são causados ​​não apenas pelos recursos do sistema operacional e do software, mas também pela arquitetura do ferro no qual todas essas coisas funcionam.


1. Quantidade de memória


Infelizmente, a memória é fisicamente limitada. No PlayStation 4, são 8 GiB GDDR5, 3,5 GiB, dos quais o sistema operacional reserva para suas necessidades . A memória virtual e a troca de páginas não ajudarão muito, pois a troca de páginas para o disco é uma operação muito lenta (dentro de N quadros por segundo fixos, se falamos de jogos).


Também vale a pena notar o " orçamento " limitado - alguma limitação artificial na quantidade de memória usada, criada para executar o aplicativo em várias plataformas. Se você está criando um jogo para uma plataforma móvel e deseja oferecer suporte não apenas a um, mas a toda uma linha de dispositivos, terá que limitar seu apetite para fornecer um mercado de vendas mais amplo. Isso pode ser alcançado simplesmente limitando o consumo de RAM e pela capacidade de configurar essa restrição, dependendo do gadget no qual o jogo realmente inicia.


2. Fragmentação


Um efeito desagradável que aparece durante o processo de várias alocações de pedaços de memória de vários tamanhos. Como resultado, você obtém um espaço de endereço fragmentado em várias partes separadas. Combinar essas partes em blocos únicos de tamanho maior não funcionará, pois parte da memória está ocupada e não podemos movê-la livremente.



Fragmentação pelo exemplo de alocações sequenciais e liberações de blocos de memória


Como resultado: podemos ter memória livre suficiente quantitativamente, mas não qualitativamente. E a próxima solicitação, digamos, "alocar espaço para a faixa de áudio", o alocador não poderá satisfazer, porque simplesmente não há uma única peça de memória desse tamanho.


3. cache da CPU



Hierarquia de memória do computador


O cache de um processador moderno é um link intermediário que conecta a memória principal (RAM) e o processador é registrado diretamente. Aconteceu que o acesso de leitura / gravação à memória é uma operação muito lenta (se falarmos sobre o número de ciclos de clock da CPU necessários para executar). Portanto, existe alguma hierarquia de cache (L1, L2, L3 etc.), que permite, por assim dizer, "de acordo com algumas previsões" carregar dados da RAM ou empurrá-los lentamente para uma memória mais lenta.


A colocação de objetos do mesmo tipo em uma linha na memória permite acelerar "significativamente" o processo de processamento (se o processamento ocorrer sequencialmente), pois nesse caso é mais fácil prever quais dados serão necessários em seguida. E "significante" significa ganhos de produtividade às vezes. Os desenvolvedores do mecanismo Unity falaram repetidamente sobre isso em seus relatórios no GDC .


4. Multi-rosqueamento


Garantir o acesso seguro à memória compartilhada em um ambiente com vários threads é um dos principais problemas que você terá que resolver ao criar seu próprio mecanismo de jogo / jogo / qualquer outro aplicativo que use vários threads para obter melhor desempenho. Os computadores modernos são organizados de uma maneira não trivial. Temos uma estrutura de cache complexa e vários núcleos de calculadora. Tudo isso, se usado incorretamente, pode levar a situações em que os dados compartilhados do seu processo serão danificados como resultado de vários encadeamentos (se eles tentarem trabalhar simultaneamente com esses dados sem controle de acesso). No caso mais simples, ficará assim:

Não quero me aprofundar no tópico da programação multithread, pois muitos de seus aspectos vão muito além do escopo do artigo ou mesmo do livro inteiro.


5. Malloc / grátis


As operações de alocação / liberação não ocorrem instantaneamente. Nos sistemas operacionais modernos, se falamos de Windows / Linux / MacOS, eles são bem implementados e funcionam rapidamente na maioria das situações . Mas, potencialmente, esta é uma operação que consome muito tempo. Não é apenas uma chamada do sistema, mas, dependendo da implementação, pode demorar um pouco para encontrar um pedaço de memória adequado (primeiro ajuste, melhor ajuste etc.) ou para encontrar um local para inserir e / ou mesclar a área liberada.


Além disso, a memória recém-alocada pode não ser realmente mapeada para páginas físicas reais, o que também pode levar algum tempo no primeiro acesso.


Esses são detalhes da implementação, mas e a aplicabilidade? Malloc / new não tem idéia de onde, como ou por que você os chamou. Eles alocam memória (no pior caso) de 1 KiB e 100 MiB igualmente ... igualmente ruins. Diretamente, a estratégia de uso é deixada para o programador ou para quem implementou o tempo de execução do seu programa.


6. corrupção de memória


Como diz o wiki , este é um dos erros mais imprevisíveis que aparece apenas durante o curso do programa, e geralmente é causado diretamente por erros na criação deste programa. Mas qual é esse problema? Felizmente (ou infelizmente), isso não está relacionado à corrupção do seu computador. Em vez disso, exibe uma situação em que você está tentando trabalhar com memória que não lhe pertence . Vou explicar agora:


  1. Isso pode ser uma tentativa de leitura / gravação em uma área de memória não alocada.
  2. Indo além dos limites do bloco de memória fornecido a você. Esse problema é um tipo especial de problema (1), mas é pior porque o sistema avisa que você ultrapassou os limites somente quando você deixou a página exibida para você. Ou seja, potencialmente, esse problema é muito difícil de resolver, porque o sistema operacional pode responder apenas se você deixar os limites das páginas virtuais exibidas para você. Você pode estragar a memória do processo e obter um erro muito estranho no local em que não era esperado.
  3. Liberar uma memória já liberada (soa estranha) ou ainda não alocada
  4. etc.

Em C / C ++, onde há aritmética de ponteiro, você encontrará essa uma ou duas vezes. No entanto, no Java Runtime, você precisa suar bastante para obter esse tipo de erro (eu ainda não tentei, mas acho que isso é possível, caso contrário, a vida seria muito simples).


7. vazamentos de memória


É um caso especial de um problema mais geral que ocorre em muitas linguagens de programação. A biblioteca C / C ++ padrão fornece acesso aos recursos do SO. Pode ser arquivos, soquetes, memória, etc. Após o uso, o recurso deve ser corretamente fechado e
a memória ocupada por ele deve ser liberada. E falar especificamente sobre a liberação da memória - vazamentos acumulados como resultado do programa podem levar a um erro de "falta de memória", quando o sistema operacional não poderá atender à próxima solicitação de alocação. Freqüentemente, o desenvolvedor simplesmente esquece de liberar a memória usada por um motivo ou outro.


Aqui vale a pena adicionar sobre o fechamento e a liberação corretos de recursos na GPU, porque os primeiros drivers não permitiram continuar o trabalho com a placa de vídeo se a sessão anterior não foi concluída corretamente. Somente a reinicialização do sistema poderia resolver esse problema, o que é muito duvidoso - para forçar o usuário a reiniciar o sistema após executar o aplicativo.


8. Ponteiro oscilante


Um ponteiro pendente é um jargão que descreve uma situação em que um ponteiro se refere a um valor inválido. Uma situação semelhante pode surgir facilmente ao usar ponteiros de estilo C clássicos em um programa C / C ++. Suponha que você alocou memória, salvou o endereço no ponteiro p e liberou a memória (veja o exemplo de código):


 //   void* p = malloc(size); // ...  -    //   free(p); //    p? // *p == ? 

O ponteiro armazena algum valor, que podemos interpretar como o endereço do bloco de memória. Aconteceu que não podemos dizer se esse bloco de memória é válido ou não. Somente um programador, com base em determinados contratos, pode operar com um ponteiro. A partir do C ++ 11, vários ponteiros “ponteiros inteligentes” adicionais foram introduzidos na biblioteca padrão, o que permite, de alguma forma, enfraquecer o controle de recursos pelo programador usando meta-informações adicionais dentro de si (mais sobre isso posteriormente).


Como solução parcial, você pode usar o valor especial do ponteiro, que nos indicará que não há nada neste endereço. Em C, a macro NULL é usada como o valor desse valor e, em C ++, a palavra-chave da linguagem nullptr. A solução é parcial, porque:


  1. O valor do ponteiro deve ser definido manualmente, para que o programador possa simplesmente esquecer de fazê-lo.
  2. nullptr ou apenas 0x0 são incluídos no conjunto de valores aceitos pelo ponteiro, o que não é bom quando o estado especial de um objeto é expresso por meio do estado usual. Isso é algum tipo de legado e, por acordo, o sistema operacional não alocará para você um pedaço de memória cujo endereço começa com 0x0.

Código de amostra com nulo:


 //  -  p free(p); p = nullptr; //   p == nullptr   ,        

Você pode automatizar esse processo até certo ponto:


 void _free(void* &p) { free(p); p = nullptr; } //  -  p _free(p); //   p == nullptr,     //    

9. Tipo de memória


A RAM é uma memória de acesso aleatório de uso geral comum, cujo acesso através do barramento central possui todos os núcleos do seu processador e dispositivos periféricos. Seu volume varia, mas na maioria das vezes estamos falando de N gigabytes, onde N é 1,2,4,8,16 e assim por diante. As chamadas malloc / free procuram colocar o bloco de memória que você deseja diretamente na RAM do computador.


VRAM (memória de vídeo) - memória de vídeo, fornecida com a placa de vídeo / acelerador de vídeo do seu PC. Por regra, é menor que a RAM (cerca de 1.2.4 GiB), mas possui alta velocidade. A distribuição desse tipo de memória é gerenciada pelo driver da placa de vídeo e, na maioria das vezes, você não tem acesso direto a ela.


Não existe essa separação no PlayStation 4, e toda a RAM é representada por um único 8 gigabytes no GDDR5. Portanto, todos os dados do processador e do acelerador de vídeo estão próximos.


Um bom gerenciamento de recursos no mecanismo de jogo inclui alocação de memória competente tanto na RAM principal quanto no lado da VRAM. Aqui você pode encontrar duplicação quando os mesmos dados estiverem lá e ali, ou com transferência excessiva de dados da RAM para VRAM e vice-versa.


Como uma ilustração de todos os problemas mencionados : você pode ver os aspectos dos computadores dos dispositivos no exemplo da arquitetura do PlayStation 4 (Fig.). Aqui está o processador central, 8 núcleos, caches de nível L1 e L2, barramentos de dados, RAM, acelerador de gráficos, etc. Para uma descrição completa e detalhada, consulte "Game Engine Architecture" de Jason Gregory.



Arquitetura PlayStation 4


Abordagens gerais


Não há solução universal. Mas há um conjunto de alguns pontos nos quais você deve se concentrar para implementar a alocação manual e o gerenciamento de memória em seu aplicativo. Isso inclui contêineres e alocadores especializados, estratégias de alocação de memória, design de sistema / jogo, gerenciadores de recursos e muito mais.


Tipos de alocadores


O uso de alocadores de memória especiais baseia-se na seguinte idéia: você sabe qual o tamanho, em quais momentos do trabalho e em que local precisará de peças de memória. Portanto, você pode alocar a memória necessária, estruturá-la de alguma forma e usá-la / reutilizá-la. Essa é a idéia / conceito geral de usar alocadores especiais. O que são (é claro, nem todos) pode ser visto mais adiante:


  1. Alocador linear
    Representa um buffer de espaço de endereço contíguo. No decorrer do trabalho, ele permite alocar áreas de memória de tamanho arbitrário (para que elas se ajustem a um buffer). Mas você pode liberar toda a memória alocada apenas 1 vez. Ou seja, uma parte arbitrária da memória não pode ser liberada - ela permanecerá como se estivesse ocupada até que todo o buffer seja marcado como limpo. Esse design fornece a alocação e liberação de O (1), o que garante uma velocidade em qualquer condição.

    Caso de uso típico: no processo de atualização do estado do processo (todos os quadros do jogo), você pode usar o LinearAllocator para alocar buffers tmp para qualquer necessidade técnica: processamento de entrada, trabalho com strings, análise de comandos do ConsoleManager no modo de depuração, etc.


  2. Alocador de pilha
    Modificação de um alocador linear. Permite liberar memória na ordem inversa de alocação, ou seja, se comporta como uma pilha regular de acordo com o princípio LIFO. Pode ser muito útil para executar cálculos matemáticos carregados (hierarquia de transformações), para implementar o trabalho do subsistema de script, para quaisquer cálculos em que o procedimento indicado para liberar memória seja conhecido antecipadamente.

    A simplicidade do design fornece alocação de memória O (1) e velocidade de liberação.


  3. Alocador de pool
    Permite alocar blocos de memória do mesmo tamanho. Pode ser implementado como um buffer de espaço de endereço contínuo, dividido em blocos de tamanho predeterminado. Esses blocos podem formar uma lista vinculada. E sempre sabemos qual bloco ceder na próxima alocação. Essa metainformação pode ser armazenada nos próprios blocos, o que impõe uma restrição ao tamanho mínimo do bloco (sizeof (void *)). Na realidade, isso não é crítico.

    Como todos os blocos são do mesmo tamanho, não importa para nós qual bloco retornar e, portanto, todas as operações de alocação / desalocação podem ser executadas em O (1).


  4. Alocador de quadros
    Alocador linear, mas apenas com referência ao quadro atual - permite fazer a alocação de memória tmp e liberar automaticamente tudo ao alterar o quadro. Ele deve ser destacado separadamente, já que essa é uma entidade global e única dentro da estrutura do jogo em tempo de execução e, portanto, pode ser feita de um tamanho impressionante, digamos algumas dúzias de MiB, o que será muito útil ao carregar recursos e processá-los.


  5. Alocador de quadro duplo
    É um alocador de quadro duplo, mas com alguns recursos. Permite alocar memória no quadro atual e usá-lo no quadro atual e no próximo. Ou seja, a memória que você alocou no quadro N será liberada somente após o quadro N + 1. Isso é realizado alternando o quadro ativo para realçar no final de cada quadro.

    Mas esse tipo de alocador, como o anterior, impõe várias restrições à vida útil dos objetos criados na memória alocada a ele. Portanto, você deve estar ciente de que, no final do quadro, os dados simplesmente se tornam inválidos e o acesso repetido a eles pode causar sérios problemas.


  6. Alocador estático
    Esse tipo de alocador aloca memória de um buffer obtido, por exemplo, no estágio de inicialização do programa ou capturado na pilha em um quadro de função. Por tipo, pode ser absolutamente qualquer alocador: linear, pool, pilha. Por que é chamado estático ? O tamanho do buffer de memória capturado deve ser conhecido no estágio de compilação do programa. Isso impõe uma limitação significativa: a quantidade de memória disponível para este alocador não pode ser alterada durante a operação. Mas quais são os benefícios? O buffer usado será capturado automaticamente e liberado (após a conclusão do trabalho ou após a saída da função). Isso não carrega a pilha, evita a fragmentação, permite alocar rapidamente a memória no local.
    Você pode ver o exemplo de código usando esse alocador, se precisar quebrar a cadeia de caracteres em substrings e fazer algo com eles:

    Também se pode notar que o uso de memória da pilha em teoria é muito mais eficiente, porque empilhar o quadro da função atual com uma alta probabilidade já estará no cache do processador.



Todos esses alocadores, de alguma forma, resolvem os problemas com fragmentação, com falta de memória, com a velocidade de recebimento e liberação de blocos do tamanho necessário, com a vida útil dos objetos e a memória que eles ocupam.


Também deve ser observado que a abordagem correta para o design de interface permitirá criar um tipo de hierarquia de alocadores quando, por exemplo: o pool aloca memória a partir da alocação de quadros, e o alocação de quadros, por sua vez, aloca a memória a partir da alocação linear. Uma estrutura semelhante pode ser continuada ainda mais, adaptando-se às suas tarefas e necessidades.



Vejo uma interface semelhante para criar hierarquias da seguinte maneira:


 class IAllocator { public: virtual void* alloc(size_t size) = 0; virtual void* alloc(size_t size, size_t alignment) = 0; virtual void free (void* &p) = 0; } 

malloc/free , . , , . / , .



Smart pointer — C++ ++11 ( boost, ). -, , - , . .


? :


  1. (/)

:


  1. Unique pointer
    1 ( ).
    unique pointer , . , .. 1 / .
    uniquePtr1 uniquePtr2, uniquePtr1 , . 1 .


  2. Shared pointer
    (reference counting). , , . , , , .

    . -, , . . -, - .


  3. Weak pointer
    . , . O que isso significa? shared pointer. , shared pointer , . , shared pointer weak pointer. , (shared) , weak pointer shared pointer. — weak pointer , , , .

    shared, weak pointer meta-data . - , .. , O(N) overhead , N — - . , . , . .



: . , shared pointer, , ( ) - - - . . meta-info , , . Um exemplo:


 /*     */ /*   ,  shared pointer */ Array<TSharedPtr<Object>> objects; objects.add(newShared<Object>(...)); ... objects.add(newShared<Object>(...)); 

 /*      (   meta-info    ) */ Array<Object> objects; objects.emplace(...); ... objects.emplace(...); 

. . Sobre isso mais.


Unique id


, . (id/identificator), , , -. :



  1. , id. , , , id.

  2. , ( , )

  3. id , , id.

  4. . , id, .

: id, , id, .


id , (Vulkan, OpenGL), (Godot, CryEngine). EntityID CryEngine .


, id : . , ( ), , .


 /*    */ class ID { uint32 index; uint32 generation; } 

 /*  - /  */ class ObjectManager { public: ID create(...); void destroy(ID); void update(ID id, ...); private: Array<uint32> generations; Array<Objects> objects; } 

ID , ID . :


 generation = generations[id.index]; if (generation == id.generation) then /*    */ else /*  ,     */ 

id generation 1 id ids.



C++ , . std, , . :


  • Linked list —
  • Array — /
  • Queue —
  • Stack —
  • Map —
  • Set —

? memory corruption. / , , , , .



, , . , , / .



, , . , ( ) . , malloc/free , , .


? , (/ ), , , . , , , .



ryEngine Sandbox:


, Unreal, Unity, CryEngine ., , . , , , — , .


Pre-allocating


, / .


: malloc/free . , "run out of memory", . . , (, , .).


. . , - . , malloc/free, : , , .



. : , , , .. .


: , , , . open-source , , . , , — malloc/free.



GDC CD Project Red , , "The Witcher: Blood and Wine" () . , , , , .


Naughty Dog , "Uncharted 4: A Thief's End" , (, ) .


Conclusão


, , , . , . / , , - .. , (, ).



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


All Articles