Então isso não é uma sobrecarga de memória?
Sim não Talvez?
Essa é uma pergunta incômoda, porque imagine o intervalo de endereçamento de memória na máquina e um software que precisa acompanhar permanentemente onde as coisas estão na memória de uma maneira que não pode ser ligada à pilha.
Por exemplo, imagine um reprodutor de música em que o arquivo de música é carregado com o botão pressionado pelo usuário e descarregado da memória volátil quando o usuário tenta carregar outro arquivo de música.
Como podemos rastrear onde os dados de áudio são armazenados? Precisamos de um endereço de memória para ele. O programa não apenas precisa acompanhar os blocos de dados de áudio na memória, mas também onde eles estão na memória. Portanto, precisamos manter em torno de um endereço de memória (ou seja, um ponteiro). E o tamanho do armazenamento necessário para o endereço de memória corresponderá ao intervalo de endereçamento da máquina (por exemplo: ponteiro de 64 bits para um intervalo de endereçamento de 64 bits).
Portanto, é uma espécie de "sim", requer armazenamento para rastrear um endereço de memória, mas não é possível evitá-lo para uma memória alocada dinamicamente desse tipo.
Como isso é compensado?
Falando apenas do tamanho de um ponteiro, você pode evitar o custo em alguns casos, utilizando a pilha. Por exemplo, nesse caso, os compiladores podem gerar instruções que efetivamente codificam o endereço de memória relativo, evitando o custo de um ponteiro. No entanto, isso deixa você vulnerável a estouros de pilha, se você fizer isso para alocações grandes e de tamanho variável, e também tende a ser impraticável (se não totalmente impossível) para uma série complexa de ramificações direcionadas pela entrada do usuário (como no exemplo de áudio acima).
Outra maneira é usar estruturas de dados mais contíguas. Por exemplo, uma sequência baseada em matriz pode ser usada em vez de uma lista duplamente vinculada que requer dois ponteiros por nó. Também podemos usar um híbrido desses dois como uma lista não rolada que armazena apenas ponteiros entre cada grupo contíguo de N elementos.
Os ponteiros são usados em aplicativos críticos de pouca memória?
Sim, muito comumente, como muitos aplicativos críticos para o desempenho são escritos em C ou C ++ que são dominados pelo uso do ponteiro (eles podem estar atrás de um ponteiro inteligente ou de um contêiner como std::vector
ou std::string
, mas a mecânica subjacente se resume a um ponteiro usado para acompanhar o endereço de um bloco de memória dinâmico).
Agora, de volta a esta pergunta:
Como isso é compensado? (Parte dois)
Os ponteiros costumam ser muito baratos, a menos que você os armazene como um milhão deles (que ainda é um mísero * 8 megabytes em uma máquina de 64 bits).
* Observe como Ben apontou que um "mísero" 8 megas ainda é do tamanho do cache L3. Aqui eu usei "míseros" mais no sentido do uso total de DRAM e do tamanho relativo típico dos blocos de memória que um uso saudável dos ponteiros apontará.
Onde os ponteiros ficam caros não são os ponteiros, mas:
Alocação dinâmica de memória. A alocação dinâmica de memória tende a ser cara, pois precisa passar por uma estrutura de dados subjacente (por exemplo, alocador de amigos ou lajes). Embora muitas vezes sejam otimizados até a morte, eles são de uso geral e projetados para lidar com blocos de tamanho variável que exigem que eles façam pelo menos um pouco de trabalho semelhante a uma "pesquisa" (embora leve e possivelmente até constante) para encontre um conjunto gratuito de páginas contíguas na memória.
Acesso à memória. Isso costuma ser a maior sobrecarga para se preocupar. Sempre que acessamos a memória alocada dinamicamente pela primeira vez, há uma falha de página obrigatória e falhas de cache, movendo a memória para baixo na hierarquia de memória e para dentro de um registro.
Acesso à memória
O acesso à memória é um dos aspectos mais críticos do desempenho, além dos algoritmos. Muitos campos críticos de desempenho, como os mecanismos de jogos AAA, concentram grande parte de sua energia em otimizações orientadas a dados, que se resumem a padrões e layouts de acesso à memória mais eficientes.
Uma das maiores dificuldades de desempenho de linguagens de nível superior que desejam alocar cada tipo definido pelo usuário separadamente por meio de um coletor de lixo, por exemplo, é que eles podem fragmentar bastante a memória. Isso pode ser especialmente verdadeiro se nem todos os objetos forem alocados de uma só vez.
Nesses casos, se você armazenar uma lista de um milhão de instâncias de um tipo de objeto definido pelo usuário, o acesso a essas instâncias sequencialmente em um loop pode ser bastante lento, pois é análogo a uma lista de um milhão de ponteiros que apontam para regiões diferentes da memória. Nesses casos, a arquitetura deseja buscar a memória dos níveis superior, mais lento e mais alto da hierarquia em blocos grandes e alinhados, com a esperança de que os dados ao redor desses blocos sejam acessados antes da remoção. Quando cada objeto dessa lista é alocado separadamente, geralmente acabamos pagando por ele com falhas de cache, quando cada iteração subsequente pode ser carregada de uma área completamente diferente da memória, sem que objetos adjacentes sejam acessados antes da remoção.
Atualmente, muitos compiladores para esses idiomas estão fazendo um ótimo trabalho na seleção de instruções e na alocação de registros, mas a falta de controle mais direto sobre o gerenciamento de memória aqui pode ser fatal (embora muitas vezes menos propensa a erros) e ainda criar idiomas como C e C ++ bastante popular.
Otimizando indiretamente o acesso ao ponteiro
Nos cenários mais críticos para o desempenho, os aplicativos geralmente usam pools de memória que agrupam memória de partes contíguas para melhorar a localidade de referência. Nesses casos, mesmo uma estrutura vinculada, como uma árvore ou uma lista vinculada, pode ser otimizada para cache, desde que o layout da memória de seus nós seja de natureza contígua. Isso está efetivamente tornando a desreferenciação de ponteiros mais barata, embora indiretamente, melhorando a localidade de referência envolvida ao desmarcá-las.
Perseguindo indicadores
Suponha que tenhamos uma lista vinculada individualmente como:
Foo->Bar->Baz->null
O problema é que, se alocarmos todos esses nós separadamente em relação a um alocador de uso geral (e possivelmente nem todos de uma vez), a memória real poderá ser dispersada da seguinte maneira (diagrama simplificado):
Quando começamos a procurar por ponteiros e acessar o Foo
nó, começamos com uma falha obrigatória (e possivelmente uma falha de página) movendo um pedaço de sua região de memória de regiões mais lentas da memória para regiões mais rápidas da memória, como:
Isso nos leva a armazenar em cache (possivelmente também a página) uma região de memória para acessar apenas uma parte dela e expulsar o restante, à medida que procuramos indicadores nesta lista. Ao assumir o controle sobre o alocador de memória, no entanto, podemos alocar essa lista de forma contígua assim:
... e, assim, melhorar significativamente a velocidade com a qual podemos desreferenciar esses ponteiros e processar seus apontadores. Portanto, embora muito indiretos, podemos acelerar o acesso ao ponteiro dessa maneira. Obviamente, se apenas armazenássemos esses contíguos em uma matriz, não teríamos esse problema em primeiro lugar, mas o alocador de memória aqui, que nos fornece controle explícito sobre o layout da memória, pode salvar o dia em que uma estrutura vinculada é necessária.
* Nota: este é um diagrama e discussão muito simplificados demais sobre a hierarquia de memória e a localidade de referência, mas espero que seja apropriado para o nível da pergunta.