为什么malloc + memset比calloc慢?


256

众所周知,这与初始化分配的内存calloc不同malloc。使用calloc,将内存设置为零。使用malloc,不会清除内存。

因此,在日常工作中,我将其calloc视为malloc+ memset。顺便说一句,为了娱乐,我为基准测试编写了以下代码。

结果令人困惑。

代码1:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)calloc(1,BLOCK_SIZE);
                i++;
        }
}

代码1的输出:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  

代码2:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)malloc(BLOCK_SIZE);
                memset(buf[i],'\0',BLOCK_SIZE);
                i++;
        }
}

代码2的输出:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  

memsetbzero(buf[i],BLOCK_SIZE)代码2 替换将产生相同的结果。

我的问题是:为什么malloc+ memset比+ 慢得多calloc?那怎么calloc

Answers:


455

简短版本:始终使用calloc()代替malloc()+memset()。在大多数情况下,它们将是相同的。在某些情况下,calloc()由于它可以memset()完全跳过,因此将减少工作量。在其他情况下,calloc()甚至可以作弊并且不分配任何内存!但是,malloc()+memset()将始终做所有的工作。

要了解这一点,需要对存储系统进行短暂浏览。

快速浏览内存

这里有四个主要部分:程序,标准库,内核和页表。您已经知道您的程序,所以...

内存分配器喜欢malloc()calloc()大部分用于分配小分配(从1字节到100s的KB),并将它们分组到更大的内存池中。例如,如果分配16个字节,malloc()则将首先尝试从其一个池中获取16个字节,然后在池干dry时从内核请求更多内存。然而,由于该方案你问是在一次分配了大量的内存,malloc()并且calloc()将只从内核请求内存直接。此行为的阈值取决于您的系统,但是我已经看到1 MiB用作阈值。

内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存。这就是所谓的内存保护,自1990年代以来一直很普遍,这就是一个程序崩溃而又不导致整个系统崩溃的原因。因此,当程序需要更多内存时,它不能仅仅占用内存,而是使用诸如mmap()或的系统调用从内核中请求内存sbrk()。内核将通过修改页表为每个进程提供RAM。

页表将内存地址映射到实际的物理RAM。您的进程的地址(在32位系统上为0x00000000到0xFFFFFFFF)不是实内存,而是虚拟内存中的地址 处理器将这些地址分为4 KiB页,并且可以通过修改页表将每个页分配给不同的物理RAM。只允许内核修改页表。

它怎么不起作用

这是分配256 MiB 无效的方法

  1. 您的过程将调用calloc()并要求256 MiB。

  2. 标准库调用mmap()并要求256 MiB。

  3. 内核找到256 MiB的未使用RAM,并通过修改页表将其提供给您的进程。

  4. 标准库将RAM清零memset()并从返回calloc()

  5. 您的进程最终退出,内核回收RAM,以便其他进程可以使用它。

实际运作方式

上面的过程是可行的,但是这种方式不会发生。有三个主要区别。

  • 当您的进程从内核获取新内存时,该内存可能先前已被其他某个进程使用。这是安全隐患。如果该内存具有密码,加密密钥或秘密莎莎食谱,该怎么办?为了防止敏感数据泄漏,内核始终在将内存交给进程之前先清除内存。我们也可以通过将内存清零来清理内存,如果新内存被清零,我们也可以保证它为好,因此可以mmap()保证返回的新内存始终为零。

  • 有很多程序可以分配内存,但不立即使用内存。有时,内存已分配但从未使用过。内核知道这一点并且很懒。当您分配新的内存时,内核根本不会触摸页表,也不会为进程提供任何RAM。相反,它在进程中找到一些地址空间,记下应该去的地方,并保证如果程序实际使用它,它将在其中放置RAM。当您的程序尝试从这些地址读取或写入时,处理器将触发页面错误,并且内核会逐步将RAM分配给这些地址并继续执行程序。如果您从不使用内存,则不会发生页面错误,并且程序也不会实际获得RAM。

  • 一些进程分配内存,然后从中读取而不修改它。这意味着跨不同进程的内存中的许多页面可能填充有从返回的原始零mmap()。由于这些页面都是相同的,因此内核使所有这些虚拟地址指向填充有零的单个共享4 KiB内存页面。如果您尝试写入该内存,则处理器会触发另一个页面错误,内核会介入,从而为您提供一个新的零页面,该页面不会与任何其他程序共享。

最终过程看起来像这样:

  1. 您的过程将调用calloc()并要求256 MiB。

  2. 标准库调用mmap()并要求256 MiB。

  3. 内核找到256 MiB的未使用地址空间,记下该地址空间现在的用途,然后返回。

  4. 标准库知道的结果mmap()总是用零填充(或者一旦它实际获得一些RAM 便会填充),因此它不会触及内存,因此不会出现页面错误,并且RAM永远不会分配给您的进程。

  5. 您的进程最终退出,并且内核不需要回收RAM,因为它从来没有首先分配过。

如果用于memset()将页面清零,memset()将触发页面错误,导致分配RAM,然后将其清零,即使它已经用零填充。这是大量的额外工作,并解释了为什么calloc()malloc()和更快memset()。如果最终还是使用了内存,calloc()仍然比malloc()和快,memset()但差别并不是那么可笑。


这并不总是有效

并非所有系统都有页面调度的虚拟内存,因此并非所有系统都可以使用这些优化。这适用于非常老的处理器(例如80286)以及对于复杂的内存管理单元而言太小的嵌入式处理器。

这也不总是适用于较小的分配。使用较小的分配,可calloc()从共享池获取内存,而不是直接进入内核。通常,共享池中可能有旧数据存储在其中的旧数据(该旧数据已被并使用释放)free(),因此calloc()可以占用该内存并调用memset()清除它。通用实现将跟踪共享池的哪些部分是原始数据,并且仍然填充零,但是并非所有实现都这样做。

消除一些错误的答案

根据操作系统的不同,内核可能会在空闲时间内将内存归零,也可能不会为零,以防万一您稍后需要将内存归零。Linux不会提前将内存归零,Dragonfly BSD最近也从内核中删除了此功能。但是,其他一些内核会提前执行零内存。将页面闲置闲置归零还不足以解释巨大的性能差异。

calloc()函数未使用的某些特殊的内存对齐版本memset(),因此无论如何都不会使其更快。memset()现代处理器的大多数实现看起来像这样:

function memset(dest, c, len)
    // one byte at a time, until the dest is aligned...
    while (len > 0 && ((unsigned int)dest & 15))
        *dest++ = c
        len -= 1
    // now write big chunks at a time (processor-specific)...
    // block size might not be 16, it's just pseudocode
    while (len >= 16)
        // some optimized vector code goes here
        // glibc uses SSE2 when available
        dest += 16
        len -= 16
    // the end is not aligned, so one byte at a time
    while (len > 0)
        *dest++ = c
        len -= 1

因此,您可以看到memset()它非常快,并且对于大容量的内存,您并没有真正获得更好的结果。

memset()调零已经调零的内存的事实确实意味着内存被调零了两次,但这仅解释了2倍的性能差异。这里的性能差异要大得多(我malloc()+memset()和系统之间在系统上测得的幅度超过三个数量级calloc())。

派对把戏

而不是循环10次,而是编写一个分配内存直到malloc()calloc()返回NULL的程序。

如果添加,会发生什么memset()


7
@Dietrich:Dietrich关于OS为calloc多次分配相同的零填充页面的虚拟内存解释很容易检查。只需添加一些循环,即可在每个分配的内存页中写入垃圾数据(每500字节写入一个字节就足够了)。然后,由于两种情况下系统将被迫真正分配不同的页面,因此总体结果应该更加接近。
kriss

1
@kriss:的确,尽管在绝大多数系统上,每4096个字节就足够了
Dietrich Epp 2010年

实际上,calloc()它通常是malloc实现套件的一部分,因此经过了优化,可以在从获取内存时不进行调用。bzerommap
mirabilos 2014年

1
谢谢您的编辑,这几乎就是我的想法。早期,您声明总是使用calloc而不是malloc + memset。请声明为1。默认为malloc2。如果需要将缓冲区的一小部分清零,请将该部分设为3。否则,请使用calloc。特别是不要malloc + memset整个大小(为此使用calloc),并且不要默认对所有东西进行callocing,因为它会阻碍诸如valgrind和静态代码分析器之类的事情(所有内存突然初始化)。除此之外,我认为这很好。
当月的雇员

5
尽管与速度无关,但calloc也较不容易出现错误。也就是说,large_int * large_int将导致溢出的地方calloc(large_int, large_int)返回NULL,但是它malloc(large_int * large_int)是未定义的行为,因为您不知道要返回的内存块的实际大小。
沙丘

12

因为在许多系统上,在空闲的处理时间中,操作系统会自行将可用内存设置为零并将其标记为安全calloc(),因此,当您调用时calloc(),它可能已经有可用的可用内存清零了。


2
你确定吗?这是哪个系统?我认为大多数操作系统只是在闲置时关闭处理器,并在写入内存后立即对分配的进程按需将内存清零(但在分配内存时不这样做)。
Dietrich Epp 2010年

@Dietrich-不确定。我听过一次,这似乎是calloc()提高效率的合理(且相当简单)的方法。
克里斯·卢茨

@Pierreten-我找不到关于calloc()特定优化的任何好信息,而且我不想解释OP的libc源代码。您可以查找任何内容以表明此优化不存在/不起作用吗?
克里斯·卢茨

13
@Dietrich:FreeBSD应该在空闲时间对页面进行零填充:请参阅其vm.idlezero_enable设置。
Zan Lynx

1
@DietrichEpp向necro致歉,但是例如Windows会这样做。
Andreas Grapentin

1

在某些平台上,在某些模式下,malloc将内存初始化为一些通常为非零的值,然后再将其返回,因此第二个版本可以将内存初始化两次

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.