为什么这个记忆吞噬者不吃记忆呢?

我想创建一个程序来模拟 Unix 服务器上的内存不足(OOM)情况。我创造了这个超级简单的记忆吞噬者:

#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;
}

它消耗的内存相当于 memory_to_eat中定义的内存,而 memory_to_eat现在正好是50GB 的 RAM。它以1MB 的大小分配内存,并精确地打印出未能分配更多内存的点,这样我就知道它设法吞噬了哪个最大值。

问题在于它能正常工作,即使是在拥有1GB 物理内存的系统上。

当我检查 top 时,我看到进程消耗了50GB 的虚拟内存,而驻留内存只有不到1MB。有没有办法创造一个真正消耗记忆的吞噬者?

系统规范: Linux 内核3.16(Debian)很可能启用了过度提交(不确定如何检出) ,没有交换和虚拟化。

11051 次浏览

这里做了一个合理的优化,运行时在你使用内存之前并没有实际的 获得内存。

一个简单的 memcpy就足以规避这种优化。(您可能会发现,calloc在使用之前仍然会优化内存分配。)

所有虚拟页面都从映射到相同的零物理页面的即写即复制开始。要使用物理页面,可以通过向每个虚拟页面写入内容来清除物理页面。

如果以根用户身份运行,那么可以使用 mlock(2)mlockall(2)让内核在分配页面时将它们连接起来,而不必弄脏它们。(普通非 root 用户的 ulimit -l只有64KiB。)

正如许多其他人所建议的那样,Linux 内核似乎并不真正分配内存,除非您对它进行写操作

代码的一个改进版本,完成了 OP 想要的功能:

这也修正了与 memory _ to _ eat 和 eat _ memory 类型的格式化字符串不匹配,使用 %zi来打印 size_t整数。要吃的内存大小(以 kiB 为单位)可以选择指定为命令行参数。

混乱的设计使用全局变量,并增长了1k 而不是4k 页面,没有改变。

#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;
}

这个我不确定,但是我能解释的唯一一个原因就是 Linux 是一个写上就复制的操作系统。当调用 fork时,两个进程指向同一物理内存。只有当一个进程实际写入内存时,才会复制内存。

我认为在这里,实际的物理内存只有在尝试写入内存时才会被分配。调用 sbrkmmap可能只会更新内核的内存簿记。只有当我们真正尝试访问内存时,才可能分配实际的 RAM。

当您的 malloc()实现从系统内核请求内存时(通过 sbrk()mmap()系统调用) ,内核只会记录您已经请求了内存,以及它将放置在您的地址空间中的哪个位置。它实际上还没有映射这些页面.

当进程随后访问新区域内的内存时,硬件识别出一个内存区段错误,并向内核报警。然后,内核在它自己的数据结构中查找页面,发现那里应该有一个零页面,因此它映射到一个零页面(可能首先从页面缓存中驱逐一个页面) ,并从中断返回。您的进程没有意识到这些情况的发生,内核操作是完全透明的(除了内核工作时的短暂延迟)。

这种优化允许系统调用非常快速地返回,最重要的是,它避免了在进行映射时将任何资源提交给您的进程。这允许进程保留在正常情况下永远不需要的相当大的缓冲区,而不用担心占用太多内存。


所以,如果你想编写一个吞噬记忆的程序,你绝对必须对你分配的内存进行一些实际操作。为此,您只需在代码中添加一行:

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;
}
}

注意,写入每个页中的一个字节(在 X86上包含4096个字节)是完全足够的。这是因为从内核到进程的所有内存分配都是在内存分页粒度上完成的,而这又是因为硬件不允许以更小的粒度进行分页。

基本答案

正如其他人提到的,内存的分配,直到使用,并不总是提交必要的 RAM。如果分配的缓冲区大于一个页面(Linux 上通常为4Kb) ,就会出现这种情况。

一个简单的答案是,“吃内存”函数总是分配1Kb 而不是越来越大的块。这是因为每个分配的块都以一个头(分配块的大小)开始。因此,分配一个大小等于或小于一个页面的缓冲区将始终提交所有这些页面。

跟随你的想法

为了尽可能地优化代码,需要分配与1页大小对齐的内存块。

根据我在代码中看到的,您使用1024。我建议您使用:

int size;


size = getpagesize();


block_size = size - sizeof(void *) * 2;

这个 sizeof(void *) * 2是什么巫毒魔法? !当使用默认的内存分配库(比如 没有 SAN、 fence、 valglin,...)时,在 malloc()返回的指针之前有一个小的头,其中包括指向下一个块的指针和大小。

struct mem_header { void * next_block; intptr_t size; };

现在,使用 block_size,所有的 malloc()都应该与我们之前发现的页面大小对齐。

如果想正确地对齐所有内容,第一个分配需要使用对齐的分配:

char *p = NULL;
int posix_memalign(&p, size, block_size);

进一步的分配(假设您的工具只做这些)可以使用 malloc()

p = malloc(block_size);

注意: 请确认它确实在你的系统上对齐... 它在我的系统上工作。

因此,您可以简化您的循环:

for(;;)
{
p = malloc(block_size);
*p = 1;
}

在创建线程之前,malloc()不使用互斥锁。但是它仍然需要寻找一个空闲的内存块。但是,在您的情况下,它将是一个接一个的,并且在分配的内存中没有漏洞,因此它将非常快。

能再快点吗?

关于 Unix 系统中内存分配的进一步说明:

  • malloc()函数和相关函数将在堆中分配一个块; 该块在开始时非常小(可能为2Mb)

  • 当现有堆是 满了时,它使用 sbrk()函数增长; 对于进程而言,内存地址总是增加,这就是 sbrk()的作用(与 MS-Windows 相反,后者将块分配得到处都是)

  • 使用 sbrk()一次,然后击中内存每个“页面大小”字节会比使用 malloc()更快

    char * p = malloc(size); // get current "highest address"
    
    
    p += size;
    p = (char*)((intptr_t)p & -size);  // clear bits (alignment)
    
    
    int total_mem(50 * 1024 * 1024 * 1024); // 50Gb
    void * start(sbrk(total_mem));
    
    
    char * end((char *)start + total_mem);
    for(; p < end; p += size)
    {
    *p = 1;
    }
    

    请注意,上面的 malloc()可能会给你一个“错误的”起始地址。但是你的过程并没有多大帮助所以我觉得你永远都是安全的。然而,for()循环将尽可能快。正如其他人所提到的,每次写入 *p时,您将获得“即时”分配的虚拟内存的 total_mem和分配的 RSS 内存。

警告: 代码未经测试,使用风险自负。