Gerenciamento de memória no Linux

INSTITUTO TECNOLÓGICO DE AERONÁUTICA
Alunos: José G A Jr e Diego Alvarez
Curso: CES-33 - Sistemas Operacionais
Professor: Edgar Toshiro Yano

Proposta do Blog

Neste Blog é descrito o gerenciamento da memória no Linux. Trata-se de um assunto importante, pois é base, por exemplo, para a tomada de decisões como a escolha do tamanho da partição de swap no momento da instalação do sistema, fornece um entendimento do funcionamento básico da memória, etc.
A proposta de se publicar os conhecimentos adquiridos favorece os interessados no assunto de modo geral na medida em que fornece fácil acesso a uma sólida base de informações.

Memória no Linux: características principais

O Linux é um sistema operacional com memória virtual paginada, isto quer dizer que podemos ter programas em execução cujo tamanho é maior que a memória física disponível para executá-los. O sistema operacional passa a ser responsávelpor manter na memória as partes dos programas efetivamente em uso, deixando o resto no disco rígido. Por exemplo, um programa de 16MB pode ser executado em uma máquina de 4MB de memória, com o sistema operacional selecionando os 4MB do programa que deverão ser mantidos na memória a cada instante, com as suas partes sendo copiadas do disco rígido para a memória e vice-versa, quando necessário.

MemoryMapping

Ou podemos ter, por exemplo, oito programas de 16MB sendo alocados em seções de 4MB de memória em um computador de 32MB de memória, com o sistema operacional novamente selecionando os 4MB de cada programa que deverão ser mantidos nas seções de memória a cada instante, com as suas partes sendo copiadas do disco rígido para a memória e vice-versa, quando necessário. A utilização da memória virtual torna o computador mais lento, embora faça com que ele aparente ter mais memória RAM do que realmente tem.

No Linux, a memória funciona com prioridade para processos que estão em execução. Quando um processo termina, havendo espaço na memória, o sistema mantém resíduos desse processo na memória para que uma possível volta a processo seja mais rápida. Caso essa memória RAM esteja lotada com processos que estão em execução, aí faz-se uso da memória SWAP (troca).

Cada processo do Linux, em uma máquina de 32 bits, dispõe de 3GB de espaço de endereçamento virtual para si próprio, com 1GB restante reservado para suas tabelas de páginas e outros dados do núcleo. O 1GB do núcleo não é visível quando o processo executa no modo usuário, mas torna-se acessível quando o processo faz uma chamada ao núcleo. O espaço de endereçamento é gerado quando o processo é criado e sobrescrito em uma chamada ao sistema exec.

Paginação e proteção

O espaço de endereçamento virtual é dividido em áreas ou regiões organizadas em páginas. Contíguas e homogêneas. Isso quer dizer que cada área consiste de uma série de páginas consecutivas com proteção e propriedades de paginação idênticas. O segmento de código e os arquivos mapeados são exemplos de áreas. Podem haver vazios no espaço de endereçamento virtual entre essas áreas. Qualquer referência à memória para um vazio resulta em uma falta de página fatal. O tamanho de página é fixo.

O Linux usa um esquema de paginação de três níveis. Embora tenha sido utilizado no processador Alpha, esse esquema também é empregado de maneira modificada em todas as arquiteturas. Cada endereço virtual é quebrado em até quatros campos. O campo diretório é usado como índice do diretório global, sendo que existe um privado para cada processo. O valor encontrado é um ponteiro para um dos diretórios intermediários de página, o qual é indexado por um campo do endereço virtual. A entrada selecionada aponta para a tabela de página final, a indexada pelo campo página do endereço virtual. A entrada encontrada aponta para a página requisitada. No Pentium, que usa paginação em dois níveis, cada diretório intermediário de página tem somente uma entrada, de modo que, efetivamente, a entrada do diretório global é quem escolhe a tabela de página a usar.

Para a proteção existe um gerenciador de memória virtual evitando que processos no modo Kernel e no modo User se misturem.

Paginação de memória

A memória virtual é usualmente implementada pela divisão da memória em páginas, onde em sistemas Unix são de tipicamente (embora não necessariamente) de 4kB cada. A tabela de páginas é a estrutura de dados que engloba o mapeamento da memória virtual para endereços físicos.

O tratamento mais simples seria uma longa tabela de página com uma entrada por página (Essas entradas são conhecidas como entradas de tabela de páginas ou PTE - page table entries). No entanto, esta solução resultaria em uma tabela de página que seria muito grande para ser encaixada na MMU, dado que tem que ser na memória. A solução, portanto são tabelas de páginas de multiníveis. Desse modo, a medida que o tamanho dos processos crescem, novas páginas são alocadas e, quando o são, a parte da memória associada à tabela de página é preenchida.

Esse método de gestão de memória que permite que o espaço de armazenamento seja não contíguo. A paginação é suportada por hardware ou por uma combinação do hardware com o software, dividindo-se a memória física em blocos de tamanho fixo, chamados frames, cujo tamanho é uma potência de 2. A memória lógica é dividida em blocos do mesmo tamanho, as chamadas páginas.

Um endereço virtual é dividido em 5 campos: diretório de páginas (PGD), diretório superior de páginas (PUD), diretório intermediário de páginas (PMD), tabela de páginas (PTE) e deslocamento (offset). A arquitetura x86 possui um espaço de endereçamento de 32 bits; quando são utilizadas páginas de 4 KiB (o padrão) o PUD e o PMD não são utilizados; o PGD e o PTE usam 10 bits cada, e o deslocamento usa 12 bits.

PageTables

Outro ponto importante é que a tradução de endereços virtuais para físicos tem de ser rápida. Isso requer que a tradução seja feita, tanto quanto possível no hardware. Como não é nada prático colocar a tabela de página por completo na MMU, a MMU apresenta o que é chamado de TLB: translation lookaside buffer

Implementação
  • A tabela de páginas é guardada na memória principal
  • Page-table base register (PTBR) aponta para a tabela de páginas
  • Page-table length register (PRLR) indica o tamanho da tabela de páginas
  • Qualquer acesso a dados/instruções requer 2 acessos à memória: um para a tabela de páginas, outro para os dados/instruções
  • O problema dos dois acessos à memória pode ser resolvido através duma cache de pesquisa rápida, designada por memória associativa ou Translation Look-aside Buffers (TLBs)
Memória associativa ou TLB
PageTableTLB

A MMU (memory managemen unit) da CPU armazena o mapeamento das tabelas de página mais recentemente usadas. Esse processo é chamado Translation Lookaside Buffer (TLB). Quando um endereço virtual precisa ser traduzido em um endereço físico, a busca é feita inicialmente na TLB. Se a requisição for encontrada, o endereço físico é retornado e o acesso à memória continua. No entanto, se não foi encontrado, a CPU gera uma page fault e o sistema operacional terá um interrupt handler para lidar com elas.

Algoritmos para substituição de páginas

Os algoritmos de substituição de páginas são políticas definidas para escolher qual(is) página(s) da memória dará lugar a página que foi solicitada e que precisa ser carregada. Isso é necessário quando não há espaço disponível para armazenar a nova página. Políticas de substituição de páginas devem ser utilizadas em sistemas que fazem uso de memória virtual paginada com o objetivo de melhorar o desempenho do sistema computacional.

No fluxograma abaixo vemos a seguência de ações desenvolvidas por um sistema operacional quando da solicitação de uma página, que envolve os conceitos de Tabela de páginas e TLB até aqui vistos. Como se vê, a substituição de página é requerida quando a página desejada não está na TLB e quando a memória está cheia.

PageRequest

Os algoritmos de substituição de páginas podem ser classificados, basicamente, em: algoritmos com espaço fixo e algoritmos com espaço variável. A diferença entre estes dois tipos de algoritmos é que o de espaço fixo trabalha sobre uma área de memória sempre constante, enquanto que os de espaço variável podem modificar o tamanho da memória alocada dinamicamente. Nos items abaixo estão descritos os principais algoritmos de substituição:

Algoritmo FIFO

O FIFO (First-in, First-out) é um algoritmo de substituição de páginas de baixo custo e de fácil implementação que consiste em substituir a página que foi carregada há mais tempo na memória (a primeira página a entrar é a primeira a sair). Esta escolha não leva em consideração se a página está sendo muito utilizada ou não, o que não é muito adequado pois pode prejudicar o desempenho
do sistema. Por este motivo, o FIFO apresenta uma deficiência denominada anomalia de Belady: a quantidade de falta de páginas pode aumentar quando o tamanho da memória também aumenta. Por estas razões, o algoritmo FIFO puro é muito pouco utilizado. Contudo, sua principal vantagem é a facilidade de implementação: uma lista de páginas ordenada pela “idade”. Dessa forma, na ocorrência de uma falta de página a primeira página da lista será substituída e a nova será
acrescentada ao final da lista.

Algoritmo LRU

O LRU (Least Recently Used) é um algoritmo de substituição de página que apresenta um bom desempenho substituindo a página menos recentemente usada. Esta política foi definida baseada na seguinte observação: se a página está sendo intensamente referenciada pelas instruções é muito provável que ela seja novamente referenciada pelas instruções seguintes.

Apesar de o LRU apresentar um bom desempenho ele também possui algumas deficiências quando o padrão de acesso é sequencial (em estruturas de dados do tipo vetor, lista, árvore), dentro de loops, etc. A implementação do LRU também pode ser feita através de uma lista, mantendo as páginas mais referenciadas no início (cabeça) e a menos referenciadas no final da lista. Portanto, ao substituir retira-se a página que está no final da lista. O maior problema com esta organização é que a lista deve ser atualizada a cada nova referência efetuada sobre as páginas, o que torna alto o custo dessa manutenção.

Algoritmo Ótimo

O algoritmo ótimo, proposto por Belady em 1966, é o que apresenta o melhor desempenho computacional e o que minimiza o número de faltas de páginas. No entanto, sua implementação é praticamente impossível. A idéia do algoritmo é retirar da memória a página que vai demorar mais tempo para ser referenciada novamente. Para isso, o algoritmo precisaria saber, antecipadamente,
todos os acessos à memória realizados pela aplicação, o que é impossível em um caso real. Por estes motivos, o algoritmo ótimo só é utilizado em simulações para se estabelecer o valor ótimo e analisar a eficiência de outras propostas elaboradas.

Algoritmo MRU

O algoritmo MRU (Most Recently Used) faz a substituição da última página acessada. Este algoritmo também apresenta algumas variações, semelhante ao LRU. Por exemplo, o MRU-n escolhe a n-última página acessada para ser substituída. Dessa forma, é possível explora com mais eficiência o princípio de localidade temporal apresentada pelos acessos.

Algoritmo CLOCK

Este algoritmo mantém todas as páginas em uma lista circular (em forma de relógio). A ordem mantida segue a seqüência em que elas foram carregadas em memória. Além disso, é adicionado um bit de uso que indica se a página foi referenciada novamente depois de ter sido carregada. Ao precisar substituir uma página o algoritmo verifica se a página mais antiga está com o bit zerado (o
que significa que a página não foi mais referenciada) para ser substituída. Se ela não estiver o bit é zerado e a próxima página da fila mais antiga será verificada. Esse processo continua até que uma página antiga com o bit zerado seja encontrada para ser substituída.

Algoritmo NRU

O algoritmo NRU (Not Recently Used) procura por páginas que não foram referenciadas nos últimos acessos para serem substituídas. Tal informação é mantida através de um bit. Este algoritmo também verifica, através de um bit de modificação, se a página teve seu conteúdo alterado durante sua permanência em memória. Esta informação também vai ajudar a direcionar a escolha da página. As substituições das páginas seguem a seguinte prioridade: páginas não referenciadas e não modificadas, páginas não referenciadas, páginas não modificadas e páginas referenciadas e modificadas.

Algoritmo LFU

O LFU (Least Frequently Used) escolhe a página que foi menos acessada dentre todas as que estão carregas em memória. Para isso, é mantido um contador de acessos associado a cada página (hit) para que se possa realizar esta verificação. Esta informação é zerada cada vez que a página deixa a memória. Portanto, o problema desse algoritmo é que ele prejudica as páginas recém-carregadas, uma vez que por estarem com o contador de acessos zerado a probabilidade de serem substituídas é maior. Qual uma possível solução para este problema? (Estabelecer um tempo de carência) Só páginas fora desse tempo é que podem ser substituídas. Tal estratégia deu origem ao algoritmo FBR (Frequency-Based Replacement).

Algoritmo MFU

O MFU (Most Frequently Used) substitui a página que tem sido mais referenciada, portanto, o oposto do LFU. O controle também é feito através de contadores de acesso. O maior problema deste algoritmo é que ele ignora o princípio de localidade temporal.

Algoritmo WS

O algoritmo WS (Working Set) possui a mesma política do LRU. No entanto, este algoritmo não realiza apenas a substituição de páginas ele também estabelece um tempo máximo que cada página pode permanecer ativa na memória. Assim, toda página que tem seu tempo de permanência esgotado ela é retirada da memória. Portanto, o número de páginas ativas é variável. O WS assegura que as páginas pertencentes ao working set processo permanecerão ativas em memória. Os algoritmos apresentados são alguns dos disponíveis na literatura. Outras implementações ou variações dos destacados aqui podem ser encontradas também.

Interfaces para o gerenciamento de memória

Existem basicamente quatro momentos em que o sistema operacional trata de paginação. Tais momentos os respectivos tratamentos estão descritos a seguir:

  1. Criação do processo
          • Determina o tamanho do processo
          • Cria a tabela de página
  1. Quando processo ganha CPU
          • Reseta MMU para novo processso
          • Limpa TLB
  1. Interrupção de falta de página
          • Identifica qual é o endereço lógico causador da falta
          • Escolhe página a ser substituida
          • Carrega página requisitada para memória
          • Completa a instrução de máquina
  1. Quando processo termina
          • Desaloca tabela de página e páginas na memória e na área de swaping

Compartilhamento de memória

O compartilhamento de uma região de memória entre dois ou mais processos (executando programas) corresponde a maneira mais rápida deles efetuarem uma troca de dados. A zona de memória compartilhada (denominada segmento de memória compartilhada) é utilizada por cada um dos processos como se ela fosse um espaço de endereçamento que pertencesse a cada um dos programas. Em outras palavras, o compartilhamento de memória permite aos processos de trabalhar sob um espaço de endereçamento comum em memória virtual. Em conseqüência, este mecanismo é dependente da forma de gerenciamento da memória; isto significa que as funcionalidades deste tipo de comunicação interprocessos são fortemente ligadas ao tipo de arquitetura (máquina) sobre a qual a implementação é realizada.

Princípio da memória compartilhada

Um processo pode criar um segmento de memória compartilhada e suas estruturas de controle através da função shmget(). Durante essa criação, os processos devem definir: as permissões de acesso ao segmento de memória compartilhada; o tamanho de bytes do segmento e; a possibilidade de especificar que a forma de acesso de um processo ao segmento será apenas em modo leitura. Para poder ler e escrever nessa zona de memória, é necessário estar de posse do identificador (ID) de memória comum, chamado shmid. Este identificador é fornecido pelo sistema (durante a chamada da função shmget()) para todo processo que fornece a chave associada ao segmento. Após a criação de um segmento de memória compartilhada, duas operações poderão ser executadas por um processo:

* acoplamento (attachment) ao segmento de memória compartilhada, através da função shmat();

* desacoplamento da memória compartilhada, utilizando a função shmdt().

O acoplamento à memória compartilhada permite ao processo de se associar ao segmento de memória: ele recupera, executando shmat(), um ponteiro apontando para o início da zona de memória que ele pode utilizar, assim como todos os outros ponteiros para leitura e escrita no segmento.

O desacoplamento da memória compartilhada permite ao processo de se desassociar de um segmento quando ele não desejar mais utilizá-lo. Após esta operação, o processo perde a possibilidade de ler ou escrever neste segmento de memória compartilhada.

Mapeamento de arquivos na memória virtual

Um arquivo mapeado na memória é o resultado da asociação de o conteúdo de um arquivo com a porção do espaço de um endereço virtual de um processo. Pode ser utilizado para compartilhar um arquivo ou memória entre dois ou mais processos.

MappedFiles

Mapeamento de arquivo possibilita que uma aplicação se beneficie da memória virtual do sistema e suplemente o espaço de memória alocado para ele com memória paginável adicional. Esse mecanismo funciona pela implementação de um mapeamento biunívoco entre o intervalo de endereços de memória mapeada alocados para a aplicação e o conteúdo de um arquivo no disco. Uma vez que um intervalo de endereços de arquivos mapeados foi alocado para uma aplicação, a aplicação pode usar aquele intervalo da memória de endereços como se ele fosse qualquer outro bloco da memória.

Uma aplicação é livre para usar tantos arquivos mapeados quanto quiser. Da mesma forma, arquivos mapeados podem ser alocados e desalocados em quando em execução durante operações particulares de processamento. Por exemplo, digamos que uma particular função em uma aplicação requeriu acesso a uma grande tabela durante a execução, mas aquela função não era chamada muito frequentemente. Neste caso, a tablea poderia armazenar num arquivo ao invés de na memória e a função poderia abrir apenas uma visão de memória mapeada read-only para o arquivo e então acessar elementos armazenados na tabela no arquivo como se estivesse acessando um elemento num vetor armazenado na memória.

Tratamento de áreas de memória fixas

Partições Fixas

Os sistemas operacionais modernos permitem que mais de um processo seja carregado em memória, de modo que quando um fica bloqueado esperando por uma operação de E/S outro, que esteja carregado em memória, poderá usar a CPU. Dessa forma, a multiprogramação ajuda a melhorar a utilização da CPU evitando desperdícios de ciclo de processamento.

Para que seja possível a multiprogramação, podemos dividir a memória em n partições (provavelmente de tamanhos diferentes). Os jobs serão colocados em filas de entrada associadas à menor partição capaz de armazená-lo. Pelo fato de usarmos partições de tamanho fixo, todo o restante de espaço de memória não utilizado pelo job será perdido. Este desperdício de memória é chamado de fragmentação interna (espaço de memória perdido dentro da área alocada ao processo). Por outro lado, imagine que exista duas partições livres, uma de 25 e outra de 100 Kbytes, não contíguas. Nesse instante é criado um processo de 110 Kbytes que não poderá ser carregado em memória pela forma como ela é gerenciada. Este problema ocasiona o que chamamos de fragmentação externa (memória perdida fora da área ocupada por um processo). A
figura abaixo ilustra o esquema de organização com partições fixas:

ParticoesFixas

O problema da organização em múltiplas filas é que jobs pequenos podem precisar esperar pela liberação de memória (partição mais adequada para o mesmo), embora exista memória disponível (partição grande), como é o caso da partição 1 e 3. Por outro lado, isso não ocorre no esquema de uma única fila. Nesta organização (b) sempre que uma nova partição é liberada o job mais próximo do início da fila e que caiba nessa partição pode ser carregado nela para ser executado pela CPU. No entanto, esta estratégia pode desperdiçar muito espaço ao armazenar um job pequeno em uma partição grande. Assim, uma opção mais interessante seria pesquisar em toda a fila de entrada e alocar a partição disponível ao maior job que pudesse ser carregado. Qual o problema dessa solução? (Discriminar jobs pequenos!) Qual a solução? (Ter pelo menos uma partição pequena!).

Existe uma outra possibilidade consiste em estabelecer uma quantidade máxima k de vezes que um job pudesse ser excluído da escolha de receber uma partição. Assim, sempre que ele fosse preterido teria seu contador incrementado e, ao chegar em k vezes, ele teria que receber uma partição.

Problemas
  • Desperdício de memória provocando a fragmentação interna ( quando o espaço da partição é maior do que o necessário para executar o programa, sobra uma área livre de memória que não pode ser reaproveitada por outro processo)
  • Não é possível o uso de 2 partições para um mesmo processo. Isto gera a fragmentação externa quando temos memória disponível mas não podemos executar o processo pois não temos uma partição grande o suficiente para executar o processo

Segurança

Realocação e Proteção

Há a necessidade de realocações, pois processos diferentes executam em posições diferentes de memória e com endereços diferentes. Uma possível solução é modificar as instruções conforme o programa é carregado na memória (quando o SO carrega o programa, adiciona a todas as instruções que se referenciarem a endereços, o valor do ponto inicial de carga do programa). Esta solução exige que o linker coloque no início do código do programa, uma tabela que apresente as indicações das posições no programa que devem ser modificadas no carregamento. Mas isso não resolve a proteção, pois um programa malicioso ou errado pode ler ou alterar posições na memória de outros usuários, já que as referências são sempre as posições absolutas de memória.

Uma solução adotada para isso foi dividir a memória em unidades de 2 KB e associar um código de proteção de 4 bits a cada uma dessas regiões. Durante a execução de um processo, o PSW contém um código de 4 bits que é testado com todos os acessos à memória realizados pelo processo, e gera uma interrupção se tentar acessar uma região de código diferente.

Uma solução alternativa para o problema da realocação e da proteção é a utilização de registradores de base e limite. Sempre que um processo é carregado na memória, o SO ajusta o valor do registrador de base de acordo com a disponibilidade de memória. Toda vez que um acesso é realizado na memória pelo processo, o valor do registrado é automaticamente somado, assim não há necessidade de que o código do programa seja modificado durante o carregamento. O registrador de limite indica o espaço de memória que o processo pode executar, então todo acesso realizado pelo processo à memória é testado com o valor do registrador limite para a validação do seu acesso. O método dos registradores permite que um programa seja movido na memória, mesmo após já estar em execução, o que antes não era possível sem antes alterar os endereços novamente.

Área de Swap

Mesmo com o aumento da eficiência da multiprogramação e, particularmente, da gerência de memória, muitas vezes um programa não podia ser executado por falta de uma partição livre disponível. A técnica de swapping foi introduzida para contornar o problema da insuficiência de memória principal.

O swapping é uma técnica aplicada à gerência de memória para programas que esperam por memória livre para serem executados. Nesta situação, o sistema escolhe um processo residente, que é transferido da memória principal para a memória secundária (swap out), geralmente disco. Posteriormente, o processo é carregado de volta da memória secundária para a memória principal (swap in) e pode continuar sua execução como se nada tivesse ocorrido.

swap

O algoritmo de escolha do processo a ser retirado da memória principal deve priorizar aquele com menores chances de ser executado, para evitar o swapping desnecessário de um processo que será executado logo em seguida. Os processos retirados da memória estão geralmente no estado de espera, mas existe a possibilidade de um processo no estado de pronto também ser selecionado. No primeiro caso, o processo é dito no estado de espera outswapped e no segundo caso no estado de pronto outswapped.

Para que a técnica de swapping seja implementada, é essencial que o sistema ofereça um loader que implemente a relocação dinâmica de programas. Um loader relocável que não ofereça esta facilidade permite que um programa seja colocado em qualquer posição de memória, porém a relocação é apenas realizada no momento do carregamento. No caso do swapping, um programa pode sair e voltar diversas vezes para a memória, sendo necessário que a relocação seja realizada pelo loader a cada carregamento.

A relocação dinâmica é realizada através de um registrador especial denominado registrador de relocação. No momento em que o programa é carregado na memória, o registrador recebe o endereço inicial da posição de memória que o programa irá ocupar. Toda vez que ocorrer uma referência a algum endereço, o endereço contido na instrução será somado ao conteúdo do registrador, gerando, assim, o endereço físico. Dessa forma, um programa pode ser carregado em qualquer posição de memória.

O conceito de swapping permite maior compartilhamento da memória principal e, conseqüentemente, maior utilização dos recursos do sistema operacional. Seu maior problema é o elevado custo das operações de entrada/saída (swap in/out). Em situações críticas, quando há pouca memória disponível, o sistema pode ficar quase que dedicado à execução de swapping, deixando de realizar outras tarefas e impedindo a execução dos processos residentes.

Os primeiros sistemas operacionais que implementaram esta técnica surgiram na década de 1960, como o CTSS do MIT e OS/360 da IBM. Com a evolução dos sistemas operacionais, novos esquemas de gerência de memória passaram a incorporar a técnica de swapping, como a gerência de memória virtual.

Problemas
  • Grande custo em termos de tempo de execução
  • Mais aceitável para sistemas batch ou sistemas com um pequeno número de usuários
  • Pode ser usado tanto em partições fixas como variáveis
  • No momento do swap-in de memória, é necessário corrigir os endereços de memória do processo

Testando os limites do sistema

Por padrão, o Linux limita os recursos que cada processo pode ter. Isto é, quanto de recursos do sistema ele pode utilizar. Isso é uma proteção para que caso o usuário faça algo errado, não prejudique a estabilidade do sistema. Esses limites são:

  • RLIMIT_AS: O tamanho máximo que um processo pode ter em bytes.
  • RLIMIT_CORE: Quando um processo é abortado, o kernel pode gerar um arquivo core contendo as informações desse aborto. Este valor é utilizando para limitar o tamanho desse arquivo. Caso o valor seja zero O, o arquivo não é criado.
  • RLIMIT_CPU: O tempo máximo em segundos que um processo pode ser executado.
  • RLIMIT_DATA: O tamanho máximo do heap ou memória de dados em bytes.
  • RLIMIT_FSIZE: O tamanho máximo em bytes permitido para um arquivo.
  • RLIMIT_LOCKS: O número máximo de arquivos que um processo pode dar lock.
  • RLIMIT_MEMLOCK: O tamanho máximo em bytes de memória que não permite swap.
  • RLIMIT_NOFILE: O número máximo de descritores de arquivos abertos.
  • RLIMIT_NPROC: O número máximo de processos que um usuário pode ter.
  • RLIMIT_RSS: A quantidade máxima de memória física que um processo pode ter.
  • RLIMIT_STACK: O tamanho máximo em bytes da stack.

Neste Blog exploraremos alguns desses limites com a execução de alguns testes, dentre eles: o número máximo de processos criados (RLIMIT_NPROC), o tamanho máximo de um processo (RLIMIT_AS), a maior área de heap (RLIMIT_DATA), a maior área de pilha (RLIMIT_STACK), além de uma breve discussão sobre o esgotamento da área de swap.

Alguns desses valores podem ser explorados com o uso do comando "ulimit" na linha de comando, que nos auxilia dando uma direção quanto aos valores esperados. O help de "ulimit" nos fornece o seguinte detalhamento de uso da função:

junior@junior:~$ help ulimit
ulimit: ulimit [-SHacdfilmnpqstuvx] [limit]
    Ulimit provides control over the resources available to processes
    started by the shell, on systems that allow such control.  If an
    option is given, it is interpreted as follows:

        -S    use the `soft' resource limit
        -H    use the `hard' resource limit
        -a    all current limits are reported
        -c    the maximum size of core files created
        -d    the maximum size of a process's data segment
        -e    the maximum scheduling priority (`nice')
        -f    the maximum size of files written by the shell and its children
        -i    the maximum number of pending signals
        -l    the maximum size a process may lock into memory
        -m    the maximum resident set size
        -n    the maximum number of open file descriptors
        -p    the pipe buffer size
        -q    the maximum number of bytes in POSIX message queues
        -r    the maximum real-time scheduling priority
        -s    the maximum stack size
        -t    the maximum amount of cpu time in seconds
        -u    the maximum number of user processes
        -v    the size of virtual memory
        -x    the maximum number of file locks

    If LIMIT is given, it is the new value of the specified resource;
    the special LIMIT values `soft', `hard', and `unlimited' stand for
    the current soft limit, the current hard limit, and no limit, respectively.
    Otherwise, the current value of the specified resource is printed.
    If no option is given, then -f is assumed.  Values are in 1024-byte
    increments, except for -t, which is in seconds, -p, which is in
    increments of 512 bytes, and -u, which is an unscaled number of
    processes.

Portanto, vemos que o uso de ulimit com as opções -d, -l, -u e -s nos fornece os valores de RLIMIT_DATA, RLIMIT_AS, RLIMIT_NPROC e RLIMIT_STACK, respectivamente:

RLIMIT_DATA
junior@junior:~$ ulimit -d
unlimited
RLIMIT_AS
junior@junior:~$ ulimit -l
32
RLIMIT_NPROC
junior@junior:~$ ulimit -u
24499
RLIMIT_STACK
junior@junior:~$ ulimit -s
8192

Nos próximos itens serão realizados testes buscando explorar tais limites.

Número máximo de processos

Para a checagem no número máximo de processos permitidos, executamos o seguinte código em C:

#include <stdio.h>
 
main() {
    int i = 0;
    while (fork() != -1) {        // Enquanto não houver falha na criação de processos,
        i++;                // Incrementa i
        printf("\n%d",i);        // Imprime o número de fork's encadeados
        sleep(10);            // Delay inserido para possibilitar a impressão na tela antes do travamento do sistema
    }
    while(1);                // Permite que o processo continue executanto num loop infinito.
}

Ao executar o programa observou-se que o último número impresso no console foi 14, significando que o número máximo de processos está entre 214 e 215, confirmando o valor de RLIMIT_NPROC = 24499 obtido pelo comando ulimit -u.

Para obter esse resultado houve dificuldades devido ao travamento do sistema antes de qualquer impressão na tela. O uso do delay sleep(10) foi crucial para que o valor impresso pudesse ser observado.

Tamanho máximo de processo

O tamanho de um processo está relacionado diretamente com a complexidade das tarefas que ele executa. Não sendo prática a tarefa de aumentar a complexidade de tais tarefas a fim de testar os limites do tamanho de um processo, aceitaremos o valor RLIMIT_AS fornecido pela função ulimit -l vista anteriormente. Sendo assim, temos um limite de 32kB para o tamanho de um processo.

junior@junior:~$ ulimit -l
32

Maior área de heap

A área de heap permite a alocação dinâmica de memória por meio de chamadas, por exemplo, malloc. A área de heap cresce em sentido oposto à pilha e em direção a esta. Uma maneira de testar sua capacidade é, portante, alocando memória continuamente, até seu esgotamento, como mostrado no programa abaixo:

#include<stdio.h>
#include<stdlib.h>
 
main(){
    int i = 0, *p;
    while(1){
        i++;
        p = (int*)malloc(sizeof(int));
        printf("\n%d Mb alocados até o momento...", sizeof(int)*i/1024/1024);
    }
}

Resultado da execução:
(...)
359 Mb alocados até o momento...
359 Mb alocados até o momento...
359 Mb alocados até o momento...
(...)

Depois de alguns bons minutos de espera, obtivemos as linhas mostradas acima e o programa continuou executando sem nenhum erro. Portanto, a área de heap é maior que 359MB, valor já bastante elevado quando comparado aos outros limites do sistema e, por isso, considerada unlimited pela resposta do comando ulimit -d.

Maior área de pilha

Chamamos área de pilha um espaço de memória especialmente reservado para organização de uma pilha de dados, usada como memória auxiliar durante a execução de uma aplicação. Ao chamarmos um procedimento, passam-se dados e controle de uma parte do código para outra. Nessa passagem de controle, armazena-se antes endereço de retorno para que o controle possa retornar para este endereço ao final da execução da função. Este armazenamento é feito na pilha, assim como a passagem de parâmetros dessas funções.

Desse modo, uma forma de testar a capacidade da área de pilha é justamente empilhar sucessivamente vários endereços de retorno, via chamadas recursivas, atá que sua capacidade seja esgotada. O seguinte programa foi utilizado levando-se em conta esse raciocínio:

#include<stdio.h>
#define T_ADRESS 32
//Variáveis Globais
int nChamadas = 0;
float capacidadeUtilizada = 0;
void fRecursiva();
 
main(){
    fRecursiva();
}
 
void fRecursiva(){
    nChamadas++;
    capacidadeUtilizada = capacidadeUtilizada + T_ADRESS;
    printf("\n%da chamada:\t %f kB da pilha foram utilizados...", nChamadas, capacidadeUtilizada/1024);
    fRecursiva();
}

Resultado da execução:
(...)
261734a chamada:     8179.187500 kB da pilha foram utilizados...
261735a chamada:     8179.218750 kB da pilha foram utilizados...
261736a chamada:     8179.250000 kB da pilha foram utilizados...
261737a chamada:     8179.281250 kB da pilha foram utilizados...
Falha de segmentação

O resultado acima nos diz que, consirerando um uso de 32bytes da pilha a cada chamada recursiva obtemos a capacidade de pilha de 8MB esperada com o uso de ulimit -s. Deve-se levar em conta que, antes da execução do programa já havia algo na pilha.

Esgotamento da área de swap

Os kernels 24 e 26 conseguem gerenciar partições de swap de até 2Gb. Assim esse é o limite superior no momento da escolha do tamanho da partição de swap. Por ser uma área de memória, o SO terá que gerenciar esse espaço constantemente, mesmo que não esteja sendo utilizado, gerando uma queda de performance da máquina em caso de utilização desnecessária de grandes partições de swap. Dado que há um limite superior de capacidade de gerenciamento pelo sistema operacional e não havendo sentido em ter áreas de swap superiores a essa capacidade, o conceito de esgotamento é, portanto, não cabível em relação à área de swap.

Referências Bibliográficas

Livros:

  • TANENBAUM, Andrew S., Modern Operation Systems.
  • Mota Filho, J.E., Descobrindo o Linux.
  • RUBEM E. FERREIRA. Linux Guia do Administrador do Sistema, 2a Edição.

Sites:

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License