Anteriormente, vimos como o kernel gerencia a memória virtual para um processo do usuário, mas arquivos e I/O foram deixados de fora. Este artigo cobre o importante – e muitas vezes mal compreendido – relacionamento entre arquivos e memória e suas consequências para a performance.
Dois problemas sérios devem ser resolvidos pelo SO quando falamos de arquivos. O primeiro é a incrível lentidão dos hard drives, e o que o disco procura em particular, relativo à memória. O segundo é a necessidade de carregar conteúdos de arquivo em memória física uma vez e compartilhá-los entre os programas. Se você usa o Process Explorer para intefirir nos processos Windows, você verá que existem ~15MB de DLLs comuns carregados em cada processo. Minha caixa Windows agora está rodando cem processos, então, sem compartilhar eu estaria usando até ~1,5GB de RAM físico somente para DLLs comuns. Isso não é nada bom. Da mesma maneira, quase todos os programas Linux precisam de ld.so e libc, além de outras bibliotecas comuns.
Felizmente, ambos problemas podem ser resolvidos com uma arma: o cache de página, onde o kernel armazena pedaços de arquivos do tamanho da página. Para ilustrar o cache de página, irei conjurar um programa Linux chamado Render, que abre um arquivo scene.dat e lê 512 bytes por vez, armazenando os conteúdos em um bloco de pilhas alocadas. A primeira leitura é assim:
Depois de 12KB lidos, a pilha do render e os frames relevantes da página se parecem com isto:
Nesse processo tem muita coisa acontecendo. Primeiro, apesar deste programa usar chamados de leituras normais, três quadros de 4KB da página agora estão no cache da página armazenando parte do scene.dat. Às vezes, as pessoas se surpreendem com isto, mas todo arquivo normal I/O acontece através do cache de página. No Linux x86, o kernel pensa em um arquivo como uma sequência de pedaços de 4KB. Se você ler um único byte de um arquivo, todo o pedaço de 4KB contendo o byte que você requisitou é lido do disco e colocado no cache de página. Isso faz sentido, já que a tranferência de disco sustentada é muito boa e os programas geralmente lêem mais do que apenas alguns bytes de uma região de arquivo. O cache da página sabe a posição de cada pedaço de 4KB dentro do arquivo, representado acima por #0, #1, etc. O Windows usa 256KB de visualizações análogas às páginas no cache de página Linux.
Infelizmente, em uma leitura normal, o kernel deve copiar os conteúdos do cache da página em um buffer do usuário – o que não somente precisa de tempo da CPU e afeta os caches da cpu, mas também gasta memória física com dados duplicados. Pelo diagrama acima, os conteúdos do scene.dat são armazenados duas vezes e cada instância do programa armazenaria aos conteúdos um tempo adicional. Mitigamos o problema de latência do disco, mas falhamos miseravelmente em todos o resto. Arquivos mapeados de memória são o caminho para sair dessa loucura:
Quando você usa o mapeamento de arquivo, o kernel mapeia as páginas virtuais do seu programa diretamente para o cache da página. Isso pode gerar um aumento significantivo de performance: o Windows System Programming mostra melhora de 30% no tempo de execução, quando se trata de leitura normal de arquivo, enquanto figuras similares são reportadas para o Linux e pára o Solaris em Advanced Programming in the Unix Environment. Você também pode salvar grandes quantidades de memória física, dependendo da natureza da sua aplicação.
Em performance, a avaliação é tudo; mas é válido deixar o mapeamento de memória mantido na caixa de ferramentas do programador. A API também é interessante, pois ela permite que você acesse um arquivo por bytes de memória e não requer legibilidade de código em troca. Preocupe-se com seu espaço de endereço e experimente com o mmap em sistemas Unix-like CreateFileMapping em Windows, ou os vários wrappers disponíveis em linguagens de alto nível. Quando você mapear um arquivo, seus conteúdos não são trazidos para a memória de uma vez, mas, sim, de acordo com a demada, através de falhas de páginas. O manipulador de falhas mapeia suas páginas virtuais no cache da página, depois de obter o frame da página com os conteúdos do arquivo necessários. Isso envolve saber se os conteúdos do disco I/O não foram colocados em cachê no começo da história.
Agora faça um teste: imagine que a última instância do nosso programa de renderização existe. As páginas que estão armazenando o scene.dat no cache da página seriam libertadas imediatamente? As pessoas muitas vezes pensam que sim, mas isso seriam uma má ideia. Quando você pensa sobre isso, é muito comum criar um arquivo em um programa, sair e, então, usar o arquivo em um outro programa. O cache de página deve lidar com este caso. Indo um pouco além: por que o kernel deveria se livrar dos conteúdos de cache da página? Lembre-se de que o disco é cinco vezes menor do que o RAM, em termos de magnitude. Assim, acertar um cache de página é uma grande vitória. Então, desde que exista uma memória física livre disponível, o cache deve ser mantido cheio. Portanto, ele não é dependente de um processo em particular, mas, sim, um recurso do sistema como um todo. Se você renderizar daqui a uma semana e o scene.dat ainda estiver em cache, é um bônus! É por isso que o tamanho do cache do kernel sobe constantemente até que atinja seu máximo. Não é porque o SO é lixo é enche o seu RAM. É, na verdade, um bom comportamento, já que de certa maneira, memória física livre é um desperdício. É melhor usar a maioria das coisas para, se possível, fazer mais cache.
Devido à arquitetura de cache da página, quando um programa chama write(), os bytes são simplesmente copiados para o cache da página, e ela é marcada como suja. O disco I/O normalmente não abre imediatamente, assim, o programa não bloqueia esperando pelo disco. Por outro lado, se o computador der problema, suas escritas nunca serão feitas. Assim, os arquivos críticos, como logs de transação de banco de dados, devem ser fsync() (apesar de você ainda ter que se preocupar com os caches do controlador do driver). Os leitores, por outro lado, normalmente bloqueiam seu programa até que os dados estejam disponíveis. Os kernels empregam um carregamento intenso para mitigar este problema. Um exemplo é o read-ahead, onde o kernel pré-carrega algumas páginas em uma página de cache, antes mesmo das leituras. Você pode ajudar o kernel a ajustar seu comportamento de carregamento ao fornecer dicas sobre como você planeja ler um arquivo sequencialmente ou randomicamente (veja madvise(), readahead(), Windows cache hints). O Linux faz o read-ahead para arquivos mapeados por memória, mas não tenho certeza quanto ao Windows. Finalmente, é possível evitar o cache de página usando O_DIRECT, no Linux, ou NO_BUFFERING, no Windows – algo que um software de banco de dados muitas vezes faz.
Um mapeamento de arquivo pode ser privado ou compartilhado. Isso somente se refere à atualizações feitas nos conteúdos que estão na memória: em mapeamentos privados as atualizações não estão comprometidas com o disco ou são tornadas visíveis a outros processos, enquanto no mapeamento compartilhado são. Os kernels usam o mecanismo de copy-on-write, habilitado pelas entradas de tabelas de páginas, para implementar mapeamentos privados. No exemplo abaixo, tanto o Render quanto outro programa chamado Render3d mapearam o scene.dat privadamente. O Render então escreve para sua área de memória virtual e mapeia o arquivo:
As entradas de página somente para leitura mostradas acima não significam que o mapeamento é somente para leitura, isso é só um truque do kernel para compartilhar memória física até o último momento possível. Uma consequência deste design é que a página virtual que mepeia um arquivo privadamente vê as mudanças feitas no arquivo por outros programas – desde que a página tenha sido somente de leitura. Uma vez que o copy-on-write é feito, as mudanças feitas por outros não são mais vistas. Este comportamento não é garantido pelo kernel, mas é o que você tem no x86 e faz sentido da perspectiva da API. Como contraste, um mapeamento compartilhado é simplesmente mapeado no cache da página e está pronto. Atualizações são visíveis para outros processos e acabam no disco. Finalmente, se o mapeamento acima fosse somente leitura, as faltas na página iriam acionar a falha de segmentação ao invés de um copy-on-write.
Bibliotecas dinamicamente carregadas são trazidas para o espaço do endereço do seu programa através do mapeamento de arquivo. Não tem nada de mágico nisso, é o mesmo mapeamento privado de arquivo disponível por você através de APIs normais. Abaixo está um exemplo mostrando parte dos espaços de endereços de duas instâncias do programa de renderização do arquivo de mapeamento rodando junto com a memória física, para agregar muitos dos conceitos que vimos.
Isso conclui mais uma parte de nossa série sobre os básicos da memória. Espero que essas séries tenham sido úteis e tenham fornecido bons modelos mentais desses tópicos SO.
***
Texto original disponível em: http://duartes.org/gustavo/blog/post/page-cache-the-affair-between-memory-and-files