Por que esse comedor de memória realmente não come memória?

150

Quero criar um programa que simule uma situação de falta de memória (OOM) em um servidor Unix. Eu criei este super comedor de memória super simples:

#include <stdio.h>
#include <stdlib.h>

unsigned long long memory_to_eat = 1024 * 50000;
size_t eaten_memory = 0;
void *memory = NULL;

int eat_kilobyte()
{
    memory = realloc(memory, (eaten_memory * 1024) + 1024);
    if (memory == NULL)
    {
        // realloc failed here - we probably can't allocate more memory for whatever reason
        return 1;
    }
    else
    {
        eaten_memory++;
        return 0;
    }
}

int main(int argc, char **argv)
{
    printf("I will try to eat %i kb of ram\n", memory_to_eat);
    int megabyte = 0;
    while (memory_to_eat > 0)
    {
        memory_to_eat--;
        if (eat_kilobyte())
        {
            printf("Failed to allocate more memory! Stucked at %i kb :(\n", eaten_memory);
            return 200;
        }
        if (megabyte++ >= 1024)
        {
            printf("Eaten 1 MB of ram\n");
            megabyte = 0;
        }
    }
    printf("Successfully eaten requested memory!\n");
    free(memory);
    return 0;
}

Ele consome tanta memória quanto definida, na memory_to_eatqual agora são exatamente 50 GB de RAM. Ele aloca memória em 1 MB e imprime exatamente o ponto em que deixa de alocar mais, para que eu saiba qual valor máximo conseguiu consumir.

O problema é que funciona. Mesmo em um sistema com 1 GB de memória física.

Quando verifico na parte superior, vejo que o processo consome 50 GB de memória virtual e apenas menos de 1 MB de memória residente. Existe uma maneira de criar um comedor de memória que realmente o consome?

Especificações do sistema: Kernel do Linux 3.16 ( Debian ) provavelmente com a confirmação excessiva ativada (não sei como fazer check-out) sem troca e virtualizada.

Petr
fonte
16
talvez você precise realmente usar essa memória (por exemplo, gravar nela)?
ms
4
Não acho que o compilador o otimize, se isso fosse verdade, não alocaria 50 GB de memória virtual.
Petr
18
@ Magisch Eu não acho que é o compilador, mas o sistema operacional gosta de copiar na gravação.
cadaniluk
4
Você está certo, tentei escrever para ele e acabei de destruir minha caixa virtual ...
Petr
4
O programa original se comportará conforme o esperado, se você o fizer sysctl -w vm.overcommit_memory=2como root; consulte mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Observe que isso pode ter outras consequências; em particular, programas muito grandes (por exemplo, seu navegador da web) podem não gerar programas auxiliares (por exemplo, o leitor de PDF).
Zwol

Respostas:

221

Quando sua malloc()implementação solicita memória ao kernel do sistema (por meio de uma chamada sbrk()ou mmap()sistema), o kernel apenas anota que você solicitou a memória e onde ela deve ser colocada no seu espaço de endereço. Na verdade, ele ainda não mapeia essas páginas .

Quando o processo acessa a memória posteriormente na nova região, o hardware reconhece uma falha de segmentação e alerta o kernel para a condição. O kernel então consulta a página em suas próprias estruturas de dados e descobre que você deve ter uma página zero lá, para que ele mapeie em uma página zero (possivelmente expulsando uma página do cache de páginas) e retorne da interrupção. Seu processo não percebe que nada disso aconteceu, a operação dos kernels é perfeitamente transparente (exceto pelo pequeno atraso enquanto o kernel faz seu trabalho).

Essa otimização permite que a chamada do sistema retorne muito rapidamente e, o mais importante, evita que todos os recursos sejam comprometidos com o seu processo quando o mapeamento é feito. Isso permite que os processos reservem buffers bastante grandes que eles nunca precisam em circunstâncias normais, sem medo de consumir muita memória.


Portanto, se você deseja programar um comedor de memória, é absolutamente necessário fazer algo com a memória que você aloca. Para isso, você só precisa adicionar uma única linha ao seu código:

int eat_kilobyte()
{
    if (memory == NULL)
        memory = malloc(1024);
    else
        memory = realloc(memory, (eaten_memory * 1024) + 1024);
    if (memory == NULL)
    {
        return 1;
    }
    else
    {
        //Force the kernel to map the containing memory page.
        ((char*)memory)[1024*eaten_memory] = 42;

        eaten_memory++;
        return 0;
    }
}

Observe que é perfeitamente suficiente gravar em um único byte em cada página (que contém 4096 bytes no X86). Isso ocorre porque toda a alocação de memória do kernel para um processo é feita na granularidade da página de memória, o que, por sua vez, ocorre devido ao hardware que não permite a paginação em granularidades menores.

cmaster - restabelece monica
fonte
6
Também é possível confirmar a memória com mmape MAP_POPULATE(embora observe que a página de manual diz " MAP_POPULATE é suportado para mapeamentos privados somente desde o Linux 2.6.23 ").
quer
2
Isso é basicamente correto, mas acho que as páginas estão copiadas na gravação, mapeadas para uma página zerada, em vez de não estarem presentes nas tabelas de páginas. É por isso que você precisa escrever, não apenas ler, todas as páginas. Além disso, outra maneira de usar a memória física é bloquear as páginas. por exemplo, ligar mlockall(MCL_FUTURE). (Isso requer raiz, porque ulimit -lé apenas 64kiB para contas de usuário em uma instalação padrão do Debian / Ubuntu.) Eu apenas tentei no Linux 3.19 com o sysctl padrão vm/overcommit_memory = 0, e as páginas bloqueadas usam RAM física / de swap.
Peter Cordes
2
@cad Enquanto o X86-64 suporta dois tamanhos de página maiores (2 MiB e 1 GiB), eles ainda são tratados de maneira bastante especial pelo kernel do linux. Por exemplo, eles são usados ​​apenas mediante solicitação explícita e somente se o sistema tiver sido configurado para permitir isso. Além disso, a página de 4 kiB ainda permanece a granularidade na qual a memória pode ser mapeada. É por isso que não acho que mencionar páginas grandes acrescente algo à resposta.
cmaster - restabelece monica
1
@AlecTeal Sim, faz. É por isso que, pelo menos no linux, é mais provável que um processo que consome muita memória seja gravado pelo killer de falta de memória do que aquele que é malloc()chamado de retorno null. Essa é claramente a desvantagem dessa abordagem no gerenciamento de memória. No entanto, já é a existência de mapeamentos de cópia na gravação (pense em bibliotecas dinâmicas e fork()) que tornam impossível para o kernel saber quanta memória será realmente necessária. Portanto, se isso não comprometer demais a memória, você ficará sem memória mapeável muito antes de realmente usar toda a memória física.
cmaster - reinstate monica
2
@ BillBarth Para o hardware, não há diferença entre o que você chamaria de falha de página e segfault. O hardware vê apenas um acesso que viola as restrições de acesso estabelecidas nas tabelas de páginas e sinaliza essa condição ao kernel através de uma falha de segmentação. É apenas o lado do software que decide se a falha de segmentação deve ser tratada fornecendo uma página (atualizando as tabelas de páginas) ou se um SIGSEGVsinal deve ser entregue ao processo.
Cmaster - reinstate monica
28

Todas as páginas virtuais iniciam a cópia na gravação mapeada para a mesma página física zerada. Para usar páginas físicas, você pode sujá-las escrevendo algo em cada página virtual.

Se estiver executando como root, você pode usar mlock(2)ou fazer mlockall(2)com que o kernel ligue as páginas quando elas estiverem alocadas, sem precisar sujá-las. (usuários não-root normais têm ulimit -lapenas 64 KB.)

Como muitos outros sugeriram, parece que o kernel Linux realmente não aloca a memória, a menos que você escreva nele

Uma versão aprimorada do código, que faz o que o OP estava querendo:

Isso também corrige as incompatibilidades da cadeia de caracteres do formato printf com os tipos de memory_to_eat e eaten_memory, usando %zipara imprimir size_tnúmeros inteiros. O tamanho da memória a ser consumida, em kiB, pode opcionalmente ser especificado como um argumento de linha de comando.

O design confuso usando variáveis ​​globais e crescendo 1k em vez de 4k páginas é inalterado.

#include <stdio.h>
#include <stdlib.h>

size_t memory_to_eat = 1024 * 50000;
size_t eaten_memory = 0;
char *memory = NULL;

void write_kilobyte(char *pointer, size_t offset)
{
    int size = 0;
    while (size < 1024)
    {   // writing one byte per page is enough, this is overkill
        pointer[offset + (size_t) size++] = 1;
    }
}

int eat_kilobyte()
{
    if (memory == NULL)
    {
        memory = malloc(1024);
    } else
    {
        memory = realloc(memory, (eaten_memory * 1024) + 1024);
    }
    if (memory == NULL)
    {
        return 1;
    }
    else
    {
        write_kilobyte(memory, eaten_memory * 1024);
        eaten_memory++;
        return 0;
    }
}

int main(int argc, char **argv)
{
    if (argc >= 2)
        memory_to_eat = atoll(argv[1]);

    printf("I will try to eat %zi kb of ram\n", memory_to_eat);
    int megabyte = 0;
    int megabytes = 0;
    while (memory_to_eat-- > 0)
    {
        if (eat_kilobyte())
        {
            printf("Failed to allocate more memory at %zi kb :(\n", eaten_memory);
            return 200;
        }
        if (megabyte++ >= 1024)
        {
            megabytes++;
            printf("Eaten %i  MB of ram\n", megabytes);
            megabyte = 0;
        }
    }
    printf("Successfully eaten requested memory!\n");
    free(memory);
    return 0;
}
Magisch
fonte
Sim, você está certo, esse foi o motivo, mas não tenho certeza sobre a formação técnica, mas faz sentido. É estranho, porém, que me permita alocar mais memória do que realmente posso usar.
Petr
Eu acho que no nível do sistema operacional, a memória só é realmente usada quando você o escreve, o que faz sentido, considerando que o sistema operacional não mantém controle sobre toda a memória que teoricamente você tem, mas apenas sobre a que você realmente usa.
Magisch
@ Mind: Se eu marcar minha resposta como wiki da comunidade e você editar seu código para facilitar a leitura do usuário no futuro?
Magisch
@ Pet Não é nada estranho. É assim que o gerenciamento de memória nos sistemas operacionais atuais funciona. Uma característica importante dos processos é que eles têm espaços de endereço distintos, o que é conseguido fornecendo a cada um deles um espaço de endereço virtual. O x86-64 suporta 48 bits para um endereço virtual, com páginas de até 1 GB, portanto, em teoria, são possíveis alguns Terabytes de memória por processo . Andrew Tanenbaum escreveu ótimos livros sobre sistemas operacionais. Se você estiver interessado, leia-os!
cadaniluk
1
Eu não usaria a expressão "vazamento óbvio de memória". Não acredito que o comprometimento excessivo ou essa tecnologia de "cópia de memória na gravação" tenha sido inventada para lidar com vazamentos de memória.
Petr
13

Uma otimização sensata está sendo feita aqui. O tempo de execução não adquire a memória até você usá-la.

Um simples memcpyserá suficiente para contornar essa otimização. (Você pode achar que callocainda otimiza a alocação de memória até o ponto de uso.)

Bathsheba
fonte
2
Você tem certeza? Acho que se a quantidade de alocação atingir o máximo de memória virtual disponível, o malloc falhará, não importa o quê. Como malloc () saberia que ninguém vai usar a memória? Como não pode, deve chamar sbrk () ou qualquer outro equivalente em seu sistema operacional.
Peter - Restabelece Monica
1
Estou bastante certeza. (malloc não sabe, mas o tempo de execução certamente o faria). É trivial testar (embora não seja fácil para mim agora: estou de trem).
Bate-Seba
@Bathsheba Seria suficiente escrever um byte para cada página? Supondo que mallocaloque nos limites da página o que me parece bastante provável.
cadaniluk
2
@doron não há compilador envolvido aqui. É o comportamento do kernel do Linux.
22815 el.pescado
1
Eu acho que o glibc callocaproveita o mmap (MAP_ANONYMOUS), que fornece páginas zeradas, para que não duplique o trabalho de zerar páginas do kernel.
Peter Cordes
6

Não tenho certeza sobre este, mas a única explicação que posso fazer é que o linux é um sistema operacional de copiar em escrever. Quando se chama forkos dois processos, aponte para a mesma memória fisicamente. A memória é copiada apenas quando um processo realmente grava na memória.

Penso que aqui, a memória física real só é alocada quando se tenta escrever algo nela. Chamar sbrkou mmappode muito bem atualizar apenas a manutenção de livros em memória do kernel. A RAM real só pode ser alocada quando realmente tentamos acessar a memória.

Doron
fonte
forknão tem nada a ver com isso. Você veria o mesmo comportamento se inicializasse o Linux com este programa como /sbin/init. (ou seja, PID 1, o primeiro processo no modo de usuário). Você teve a ideia geral certa com a cópia na gravação: até que você as impeça, as páginas alocadas recentemente são todas as cópias na gravação mapeadas para a mesma página zerada.
Peter Cordes
saber sobre garfo me permitiu adivinhar.
21415