Seminário SO 1 Sistemas Operacionais 18 de novembro de 2008 Gerenciamento de Memória Implementação de Sistemas Operacionais Contemporâneos. Eduardo Felipe Castegnaro Juliano Krieger Heloisa Simon Seminário SO 2 Sumário Introdução 3 A chamada brk 3 Funcionamento do malloc() 4 Gerenciamento de Memória em Sistemas sem MMU 5 A Solução POSIX para aquisição de memória. 6 Modelos de memória 9 Algoritmos de Alocação de Memória 11 Referências 15 Seminário SO 3 Introdução Motivados pelo interesse em saber o funcionamento de alocações de memória em sistemas operacionais modernos, decidimos abordar neste seminário sobre as funcionalidades da chamada break e seu desempenho em sistemas com memória virtual, solução POSIX, assim como os modelos de memórias e os algoritmos de alocação de memória usadas realmente na prática e não os algoritmos apresentados em livros clássicos. A chamada brk “The brk and sbrk functions are historical curiosities left over from earlier days before the advent of virtual memory management” BSD System Calls Manual A system call brk() define o fim do segmento de dados, ou seja, controla um grande pedaço de memória contíguo alocada para um determinado processo, que pode ser aumentado ou diminuído posteriormente. Esse controle é permitido apenas para uma das extremidades, pois a Heap cresce em apenas em uma direção (do menor para o maior endereço). É sempre possível expandir, desde que haja espaço no endereçamento. Porém encolher pode ser custoso e ineficiente. Basta que apenas um byte no final deste espaço esteja sendo usado para tornar impossível o encolhimento deste área, mesmo que todos os outros bytes antes deste estejam livres. Seminário SO 4 Esta chamada de sistema depende de um layout de memória. Ela pode ser utilizada em um processo particular no Unix mas pode não ser usada em outros ambientes e pode ser difícil de emular. Além disso, programas que precisam de chamadas tão diretas à memória são muito raros. Até 2003, Os sistemas Linux Kernel, Debian entre outros estavam vulneráveis, pois não limitavam o endereço passado como parametro na chamada brk(), podendo assim qualquer processo ter acesso a área de código do kernel, permitindo um atacante local ter acesso a privilégios de root. O padrão POSIX (Portable Operating System Interface) é uma família de normas que tem o objetivo de garantir a portabilidade do código-fonte de um programa a partir de um sistema operacional que atenda as normas POSIX, desta forma as regras atuam como uma interface entre sistemas operacionais distintos. Por estas e outras razões citadas acima, a chamada brk(), mesmo sendo utilizada em sistemas UNIX, não faz parte do padrão POSIX, que almeja a padronização e melhor desempenho dos Sistemas Operacionais, atributos esses que seriam impossíveis com o brk(). Funcionamento do malloc() Quando um processo precisa de mais memória, aloca-se mais espaço usando o system call brk() ou sbrk(), como citado acima. Em termos de uso da CPU, uma system call é cara. Uma boa estratégia seria chamar a brk() para pegar um grande pedaço de memória para então separá-la em pedaços menores de acordo com o necessário. Seminário SO 5 Isso é exatamente o que o malloc() faz. Ele agrega um série de chamadas malloc() menores afim de diminuir a grande chamada brk(). Isso gera uma melhora significativa no desempenho. O malloc() chamando a ele mesmo é muito mais barato do que o brk(), porque é uma chamada de biblioteca e não uma chamada de sistema. Um comportamento similar é adotado quando a memória é liberada pelo processo. Os blocos de memória não são imediatamente devolvidos para o sistema, que chamaria o brk() com um argumento negativo, em vez disso, a biblioteca C agrega essas chamadas até sejam suficientemente grande para serem liberadas tudo de uma só Gerenciamento de Memória em Sistemas sem MMU A implementação de memória virutal em um sistema operacional é fortemente baseado na arquitetura do processador, mais especificamente, na MMU (Memory Management Unit), um hardware dedicado e acoplado com o processador, que suporta endereçamento para memória virtual. Em Sistemas embarcados, é possível encontrar arquiteturas onde não há uma MMU (Memory Management Unit). Para estes sistemas a memória virtual tem de ser emulada por software, Dentro vários existentes, três são mostrados em detalhes abaixo: VM pura – imita um hardware com MMU. Todo acesso a memória está em um espaço de endereçamento virtual que é traduzido para endereço físico em tempo de processamento. Este método é transparente para a aplicação. No entanto, todo acesso a memória virtualizada resulta em uma chamada a função de mapeamento de memória virtual física, tantas vezes quanto forem necessárias instruções de load/store no programa. VM com endereço fixo – neste método, uma região da memória é marcada como virtualizada. Qualquer acesso à memória (load/store) que pertence a esta região é transfor- Seminário SO 6 mada. Neste método é necessário que o programador indique ao vm-assembler a região marcada como virtual. Em oposiçao ao VM puro, neste caso, o overhead de transformar todo endereço virtual em endereço físico é reduzido apenas à região de memória marcada como virtual. Assim também exige que, em tempo de execuçao, todo acesso a memória seja checado, para conferir se está marcado. VM Seletiva – Similar ao método anterior, mas um pouco mais refinada, em termos de determinar quais trexos da memória estão virtualizados. No caso anterior, uma checagem em tempo de execução era precisa a cada acesso á memória. VM Seletiva evita a checagem em tempo de execução anotando as estruturas de dados que são virtualmente alocadas no código-fonte. Necessita que o progamador marque as estruturas como pertencentes ao espaço de endereçamento virtual (em vez da região inteira). Esta anotação é feita na declaraçao da variável, usando diretivas. Toda vez que a variável é usada, o código gerado é modificado para chamar a função de mapeamento de memória virtual para memória física. Este método reduz significantemente o overhead em tempo de execução restringindo o mapeamento apenas às grandes estruturas de dados que se beneficiam da virtualização. Este método dá ao programador mais controle em o que está sendo virtualizado. Todavia, é o menos transparente ao programador, comparado com os dois outros métodos. A Solução POSIX para aquisição de memória. O maior problema de chamadas como brk() é a exigência de layouts específicos e contíguos de memória dentro de um address space. Sendo assim foi necessário criar novas chamadas que não possuem esse requisito. Seminário SO 7 Após considerar várias outras alternativas, a chamada mmap(), inicialmente encontrada no System V, release 4 (SVR4), foi decidida para adoção pelo padrão POSIX. A definição original é minima, no sentido que apenas descreve o que já havia sido implementado e o que aparenta ser necessário para um mapeador genérico e portátil. Mesmo mmap() tendo sido originalmente desenvolvido para mapear arquivos em memória, na realidade ele é um mapeador de uso geral, podendo mapear em memória qualquer coisa, como dispositivos, arquivos, e inclusive a própria memória, através do conceito de mapeamento anônimo. Mapeamento anônimo ocorre quando páginas da memória são mapeadas mas não pertencem a nenhum recurso, ou seja, elas servem apenas para reservar um determinado pedaço dentro do address space do processo. Memória mapeada tem a vantagem que inicialmente apenas o address space é modificado, com cada página mapeada efetivamente reservada no primeiro acesso, permitindo uma melhor utilização em casos onde preemptivamente é necessário alocar buffers grandes, mas eles não necessariamente serão utilizados. Caso comum em momentos de instanciação de aplicações. Chamadas individuais de mmap não necessariamente pertencem a blocos contíguos no espaço de endereçamento, mas é garantido que em um bloco mapeado todos os endereços serão contíguos. Isso causa grande flexibilidade, pois é possível mapear pedaços grandes para casos específicos e manter um buffer de mapeamento pequeno, tornando muito eficiente o uso de memória dos programas. Opcionalmente, é possível mapear páginas com proteção, para leitura, escrita, ou execução. Esse mapeamento é dependente de hardware, e não necessariamente garantido. Também é possível mapear de maneira pública permitindo fácil comunicação entre processos que mapearam o mesmo pedaço. Efetivamente é assim que chamadas para alocação de memória compartilhada (shr_malloc) são implementadas. É possível mapear Seminário SO 8 páginas anônimas que possuem primitivas de sincronização, utilizando a flag MAP_HASSEMAPHORE. A função mmap estabelece uma ligação entre o espaço de endereçamento do processo e a memória, ligada por n bytes a partir do endereço retornado pela função. Quando um mapeamento é realizado, é possível que a implementação necessite mapear mais que o solicitado, visto que o mapeamento ocorre apenas com páginas inteiras. Mesmo assim aplicativos não devem contar com esse comportamento. Implementações que não usam memória por paginação podem simplesmente alocar um pedaço de tamanho variável, não causando o comportamento de páginas discretas. Se um programa aplicativo solicita um mapeamento em um address space previamente mapeado, seria desejável que a implementação detectasse isso, e informasse a aplicação. Entretanto, foi especificado que não é possível remapear pedaços já mapeados. Muitas implementações não sequem esse padrão, pois seria muito caro solicitar uma chamada para desmapear o endereço antes de cada chamada para mapeá-lo. Na prática novos mapeamentos sobrescrevem mapeamentos antigos, no mesmo endereço, liberando os antigos para reuso pelo sistema operacional. Mesmo sendo um alocador genérico, caso seja solicitado um endereço em intervalo reservado ao kernel ele irá falhar, pois se trabalha sob a presunção que o kernel gerencia sua própria memória através de outros mecanismos. Da mesma maneira caso não haja suporte nativo para endereços virtuais, intervalos fixos devem ser definidos. A especificação suporta múltiplos tipos de mapeamentos, que implementações podem escolher suportar ou não. Implementações como do BSD são conhecidamente mais conservadoras, não suportando chamadas com grande custo, como com sincronização implícita. Seminário SO 9 A chamada mmap permite uma maior otimização de operações de paginação pelo Sistema Operacional. Considere, por exemplo um programa A que cria um buffer de 1MB em memória não mapeada (usando brk), e um programa B que mapeia 1MB (utilizando mmap). Se o sistema operacional tiver que enviar parte da memória de A para swap ele precisa escrever o conteúdo do buffer no swap antes de reutilizar a memória. No caso de B as páginas podem ser reutilizadas automaticamente, porque o SO sabe restaurá-las da area de swap automaticamente. Para realizar isso sistemas operacionais como MacOS e BSD originalmente mapeiam páginas como apenas de leitura e tratam a interrupção da MMU quando os processos nela escrevem, detectando assim páginas sujas. Algumas implementações de mmap(), especificamente a pertencente ao Darwin (base do MacOS), possuem bugs relativos a devolver páginas não utilizadas e manter o address range reservado pela chamada original. É possível liberá-las utilizando munmap(), mas essa é uma chamada custosa e pode causar condições de corrida caso haja algum acesso a memória durante a chamada de munmap(). O comportamento observado é que paginas mapeadas que não foram escritas são automaticamente capturadas pelo sistema operacional, mas páginas mapeadas ocupam espaço até serem desmapeadas. Já que alocadores reusam espaço, geralmente todas as páginas mapeadas acabam tendo sido escritas em algum ponto, dificultando a captura das mesmas. Modelos de memória O propósito de um modelo de memória é descrever como um leituras e escritas em memória pode ser executadas por um processador, de maneira relativa a sua ordem no programa original e como escritas feitas por um processador ficam visíveis a outros pro- Seminário SO 10 cessadores. Ambos aspectos afetam profundamente as otimizações dependentes de hardware permitidas ao compilador bem como toda a estrutura de cache e gerenciamento de memória via hardware (MMU). Uma solução passível de adoção deveria ser rígida o suficiente para facilitar a progamabilidade e flexível o suficiente para permitir a reorganização de certos acessos a memória, facilitando a otimização. Devido a difícil tarefa de definir tal modelo originalmente linguagens de sistema, como C e C++, não possuem um modelo de memória que leve em consideração o multiprocessamento ou o compartilhamento de memória. O modelo utilizado não é consistente, e tende a ser o que quer que tenha sido definido pela combinação particular de compilador e hardware existente em uma determinada plataforma. Isso causa problemas, pois atualmente programadores não podem, de maneira consistente e multiplataforma, escrever código lock-free, uma vez que otimizações podem introduzir leituras ou escritas inexistentes no código original, e consequentemente não podem ser asseguradas pelo programador. O modelo existente possui as seguintes características: • Especifica o comportamento de operações de memória observáveis pelo programa atual. • Não possui noção implícita de memória compartilhada entre processos, consequentemente não especifica parâmetros de acesso concorrente. • É extremamente flexível em acessos a memória, permitindo grandes otimizações pelo compilador e processador. • Não determina regras de acesso a memória compartilhada entre threads do mesmo processo. O novo standard de C++, também conhecido como C++0x, contém suporte explícito a threads na linguagem, obrigando a definição de um modelo de memória consistente. Há Seminário SO 11 duas partes envolvidas na solução atualmente proposta: A definição de um modelo que permita multiplas threads coexistirem em um programa, e o fornecimento de bibliotecas de suporte que permirtam a criação e o sincronismo de múltiplas threads de execução. Também foi proposto um sistema de atomicidade relativa a tipos nativos em C++, definidos pela palavra reservada atomic. Tipos atômicos garantem que operações neles executadas não possuam sua representação reordenada por compiladores ou processadores. Pós incremento, pré-incremento e operações com referência ao próprio valor, como soma-ao-valor (+=) também tem a garantia de atomicidade. Sendo assim podemos esperar que futuramente novas bibliotecas possam surgir que permitam a criação de programas eficientes, multiplataformas, em linguagens de sistema. Há indicações no comitê ISO que o próximo Standard da linguagem C siga uma abordagem similar a do C++0x. Algoritmos de Alocação de Memória Conforme visto em aulas, os sistemas mais comuns de alocação de memória involvem o gerenciamento de um segmento contínuo de memória de tamanho arbitrário. A família de algoritmos mais comum para esse caso é conhecida como ʻbuddy algorithmsʼ que involve a fusão de segmentos livres contínuos e sua separação duarante a alocação. Claramente é disperdicio se mapear uma página para a utilização de alguns bytes, pois se subutiliza a memória e se executa chamadas complexas de sistema. Uma solução comum é se alocar um pedaço contíguo e gerenciar o seu uso, mas isso tente a causar fragmentação interna, ou ainda a solução adotada for versões anteriores a 2.4 do Kernel do Linux, que consistia em prover segmentos de memória geometrica- Seminário SO 12 mente distribuidos, ou seja, seu tamanho depende de potências de 2, ao invés do tamanho que é realmente necessário. Fica claro que rodar um sistema de alocação de memória em cima desses algoritmos não é particularmente eficiente. Podemos criar subsistemas que utilizam internamente algoritmos Buddy, aumentando a eficiência, como demostrado a seguir para o kernel 2.6 O algoritmo implementado nas ultimas versões do Kernel Linux são derivadas de um algoritmo conhecido como Slab Allocator. Esse algoritmo aloca objetos em cestos, conhecidos como slabs, conforme os seguintes quesitos: • O tipo de dados a ser guardado pode afetar a maneira de alocado. • As áreas de memória podem ser vistas como objetos, cada um dentro de um cache. • A tendência é solicitar áreas de memória do mesmo tipo consecutivamente. Por exemplo na criação de processos é necessária a alocação de tabelas, como o descritor de Seminário SO 13 processo. Uma vez que o processo acaba essa tabela não é dealocada, mas sim utilizada como cache para futuras utilizações. • Aumenta a localidade de dados, visto que objetos dentro da mesma cache podem ser acessados frequentemente, espalhando em linhas de cache separadas. Outra grande vantagem desse algoritmo é que cada slab pode ser mapeado separadamente, fazendo dele excelente escolha em sistemas POSIX. Estrutura do alocador Slab. Internamente cada Slab consiste em uma area sequencial de memória, gerenciada via Buddy. Opcionalmente os slabs podem ser com descritores internos ou externos. Seminário SO • 14 Seminário SO 15 Referências BOVET, Daniel. Understanding the Linux Kernel. 3a Edição. O’Reilly, Nov. 2005. BOEHM, Hans-J. C++ Atomic Types and Operations, Postado em 10/09/2007 Disponível em: <http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2427.html> Acesso em 17/11/2008.