Gerenciamento de Memória

Propaganda
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.
Download