为什么这个吃记忆的人真的不吃记忆?


150

我想创建一个程序来模拟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,现在恰好是50 GB的RAM。它按1 MB分配内存,并精确打印无法分配更多内存的点,这样我就知道它设法吃掉了哪个最大值。

问题是它有效。即使在具有1 GB物理内存的系统上。

当我检查顶部时,我看到该进程占用了50 GB的虚拟内存,而占用的驻留内存不到1 MB。有没有办法创建确实消耗掉它的内存消耗者?

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


16
也许您必须实际使用此内存(即写入它)?
ms

4
我认为编译器不会对其进行优化,如果确实如此,它将不会分配50GB的虚拟内存。
彼得

18
@Magisch我不认为这是编译器,而是类似写时复制的操作系统。
cadaniluk

4
您是对的,我试图写信给我,但我只是把我的虚拟盒子放了一下……
Petr 2015年

4
如果您sysctl -w vm.overcommit_memory=2以root身份运行,则原始程序将按预期运行;请参阅mjmwired.net/kernel/Documentation/vm/overcommit-accounting。请注意,这可能会带来其他后果;特别是非常大的程序(例如您的Web浏览器)可能无法生成辅助程序(例如PDF阅读器)。
zwol

Answers:


221

当您的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个字节)就足够了。那是因为从内核到进程的所有内存分配都是以内存页面粒度完成的,这又是因为硬件不允许以较小粒度进行分页。


6
也可以使用mmap和来提交内存MAP_POPULATE(尽管请注意,手册页显示“ 仅从Linux 2.6.23开始,专用映射才支持MAP_POPULATE ”)。
Toby Speight 2015年

2
基本上是正确的,但是我认为所有页面都是写时复制映射到置零页面,而不是根本不在页面表中显示。这就是为什么您必须写每一页,而不仅仅是阅读。另外,耗尽物理内存的另一种方法是锁定页面。例如打电话mlockall(MCL_FUTURE)。(这需要root用户,因为ulimit -l在默认安装的Debian / Ubuntu中,用户帐户只有64kiB。)我刚刚在Linux 3.19上使用默认的sysctl进行了尝试vm/overcommit_memory = 0,并且锁定的页面占用了swap /物理RAM。
彼得·科德斯

2
@cad虽然X86-64支持两个较大的页面大小(2 MiB和1 GiB),但Linux内核仍然对它们进行特殊处理。例如,仅在显式请求上并且仅在系统已配置为允许它们时才使用它们。同样,4 kiB页面仍然保留可以映射内存的粒度。这就是为什么我不认为提及大页面会增加答案的原因。
cmaster-恢复莫妮卡2015年

1
@AlecTeal是的,确实如此。这就是为什么至少在linux上,内存消耗过大的杀手shot死了一个消耗过多内存的进程,而不是其中一个malloc()调用return的原因null。这显然是这种内存管理方法的缺点。但是,已经存在写时复制映射(认为是动态库和fork()),这使得内核无法知道实际需要多少内存。因此,如果它没有过量使用内存,那么在实际使用所有物理内存之前,您将耗尽可映射的内存。
cmaster-恢复莫妮卡2015年

2
@BillBarth对于硬件,您称页面错误和段错误之间没有区别。硬件仅看到违反页表中规定的访问限制的访问,并通过分段错误将状态通知内核。然后,只有软件方决定是否应该通过提供页面(更新页面表)来处理分段错误,或者是否SIGSEGV应该将信号传递给流程。
cmaster-恢复莫妮卡2015年

28

所有虚拟页面均以写时复制开始,映射到相同的清零物理页面。要用尽物理页面,可以通过在每个虚拟页面中写入内容来弄脏它们。

如果以root用户身份运行,则可以在分配页面时使用mlock(2)mlockall(2)让内核连接页面,而不必弄脏页面。(普通的非root用户ulimit -l只能使用64kiB。)

正如许多其他人所建议的那样,除非您进行写入,否则Linux内核似乎并没有真正分配内存。

代码的改进版本,可以执行OP所需的功能:

这也解决了printf格式字符串与memory_to_eat和eaten_memory类型不匹配的问题,%zi用于打印size_t整数。可以选择将要占用的内存大小(以kiB为单位)指定为命令行arg。

使用全局变量并且以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;
}

是的,您是对的,这是原因,虽然不确定技术背景,但这是有道理的。但是很奇怪,它允许我分配超出实际使用范围的内存。
彼得

我确实认为在OS级别上,只有在写入内存时才真正使用内存,考虑到OS不会在理论上拥有所有内存的标签,而是仅在实际使用的内存上保留标签,这才有意义。
Magisch

@Petr mind如果我将我的答案标记为社区Wiki,并且您为了将来的用户可读性而在代码中进行了编辑?
Magisch

@Petr一点也不奇怪。这就是当今操作系统上的内存管理的工作方式。进程的主要特点是它们具有不同的地址空间,这可以通过为每个进程提供一个虚拟地址空间来实现。x86-64的一个虚拟地址支持48位,甚至1GB页面,因此,从理论上讲,每个进程可能有TB级的内存。Andrew Tanenbaum写了一些关于OS的好书。如果您有兴趣,请阅读!
cadaniluk 2015年

1
我不会用“明显的内存泄漏”这样的措辞,我不相信过度使用内存或这种“写时复制内存”技术是完全用来处理内存泄漏的。
彼得

13

在这里进行明智的优化。在使用内存之前,运行时实际上不会获取内存。

一个简单的方法memcpy就足以避免这种优化。(您可能会发现,calloc直到使用点为止,它仍然可以优化内存分配。)


2
你确定吗?我认为,如果他的分配量达到可用虚拟内存的最大值,则无论如何malloc都会失败。malloc()如何知道没人会使用内存?它不能,因此必须调用sbrk()或他的操作系统中的任何等效项。
彼得-恢复莫妮卡2015年

1
确定 (malloc不知道,但是运行时肯定会知道)。测试很简单(尽管现在对我来说并不容易:我在火车上)。
Bathsheba 2015年

@Bathsheba是否可以在每个页面中写入一个字节就足够了?假设malloc在页面边界上分配对我来说似乎很可能。
cadaniluk

2
@doron这里没有编译器。这是Linux内核的行为。
el.pescado

1
我认为glibc calloc利用mmap(MAP_ANONYMOUS)提供归零页面,因此它不会重复内核的归零工作。
彼得·科德斯

6

对此并不确定,但我能做的唯一解释是linux是写时复制操作系统。当一个调用时fork,两个进程都指向相同的物理内存。仅当一个进程实际写入到存储器中时,才会复制该存储器。

我认为,实际的物理内存仅在尝试向其中写入内容时才分配。调用sbrkmmap可能仅更新内核的内存保留。仅当我们实际尝试访问内存时才可以分配实际的RAM。


fork与此无关。如果使用此程序启动Linux,则会看到相同的行为/sbin/init。(即PID 1,第一个用户模式过程)。但是,您对写时复制有一个正确的总体思路:在弄脏它们之前,新分配的页面都将写时复制都映射到相同的清零页面。
彼得·科德斯

了解叉子,使我可以猜测。
多伦(Doron)2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.