为什么malloc + memset比calloc慢?

297

众所周知,callocmalloc的区别在于前者会初始化分配的内存空间。使用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  

在Code 2中,将memset替换为bzero(buf[i], BLOCK_SIZE)会产生相同的结果。

我的问题是:为什么malloc+memsetcalloc慢这么多?calloc是如何做到的呢?


应该这样写:buf[i] = (char*)calloc(BLOCK_SIZE, sizeof(char)); - 但是一个聪明的优化编译器应该自动优化这个代码。 - undefined
3个回答

556
总之,应该总是使用calloc()而不是malloc()+memset()。在大多数情况下,它们将是相同的。在某些情况下,calloc()会做更少的工作,因为它可以完全跳过memset()。在其他情况下,calloc()甚至可以欺骗并且不分配任何内存!但是,malloc()+memset()将始终执行全部工作。
了解这一点需要对内存系统进行简短介绍。
内存分配器(如malloc()calloc())主要用于将小分配(从1字节到100 KB)组合成更大的内存池。例如,如果您分配16个字节,malloc()将首先尝试从其内存池中获取16个字节,然后在池耗尽时向内核请求更多内存。但是,由于所询问的程序一次性分配了大量内存,malloc()calloc()将直接从内核请求该内存。此行为的阈值取决于您的系统,但我已经看到1 MiB被用作门槛。
内核负责为每个进程分配实际的RAM,并确保进程不干扰其他进程的内存。这称为内存保护,自1990年代以来已经非常普遍,并且这是其中一个程序可能会崩溃而不会使整个系统崩溃的原因。因此,当程序需要更多内存时,它不能只获取内存,而必须使用像mmap()sbrk()这样的系统调用向内核请求内存。内核将通过修改页表为每个进程分配RAM。
页面表将内存地址映射到实际物理内存。您的进程地址,在32位系统上为0x00000000到0xFFFFFFFF,不是真正的内存,而是虚拟内存中的地址。处理器将这些地址分成4 KiB页面,每个页面可以分配给不同的物理RAM部分,方法是通过修改页表。只有内核被允许修改页表。
以下是分配256 MiB的错误方式:
1.您的进程调用calloc()并请求256 MiB。
2.标准库调用mmap()并请求256 MiB。
3.内核找到256 MiB未使用的RAM,并通过修改页表将其分配给您的进程。
4.然后标准库调用memset()将所有数据设置为零。

标准库使用memset()将RAM清零,并从calloc()返回。

进程最终退出,内核会回收RAM以便其他进程使用。

当进程从内核获取新的内存时,这些内存可能之前被其他进程使用过,存在安全风险。为了防止敏感数据泄露,内核在将内存提供给进程之前总是先擦除内存内容。我们可以通过将内存清零来进行擦除,如果新内存已经被清零,那么我们可以保证任何由mmap()返回的新内存都是清零的。

有很多程序在分配内存但不立即使用它。有时候内存被分配了却从未使用。内核知道这一点,所以当您分配新内存时,内核不会直接触碰页面表,也不会向进程提供任何RAM。相反,它找到您进程中的某个地址空间,并记录下该地址空间应该放置什么,并承诺在您的程序实际使用它时放置内存。当您的程序尝试从这些地址读取或写入数据时,处理器就会触发页错误,内核会赋值一个RAM给这些地址并恢复您的程序。如果您从不使用该内存,则页错误不会发生,您的程序也永远不会真正获得该RAM。

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

最终过程看起来更像是这样:

  1. 进程调用calloc()并请求256 MiB。
  2. 标准库调用mmap()并请求256 MiB。
  3. 内核找到256 MiB未使用的地址空间,记录该地址空间目前用于什么,并返回结果。
  4. 标准库知道mmap()的结果总是填充零值(或者一旦它实际获取了一些RAM就会这样),所以它不会触碰内存,因此没有页错误发生,RAM也从未提供给您的进程。
  • 进程最终退出,内核不需要回收 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提到的关于操作系统为calloc分配相同的零填充页面的虚拟内存解释很容易验证。只需要添加一个循环,在每个分配的内存页面中写入垃圾数据(每500字节写入一个字节就足够了)。总体结果应该会更接近,因为系统被迫在两种情况下真正分配不同的页面。 - kriss
    1
    @kriss:确实,虽然在绝大多数系统上每4096个字节只需要一个字节就足够了。 - Dietrich Epp
    1
    @mirabilos:实际上,实现往往更加复杂。由mmap()分配的内存是以大块分配的,因此malloc()/calloc()实现可以跟踪哪些块仍然是原始的且充满零。因此,即使calloc()没有从mmap()获取内存(即已经是堆的一部分但尚未使用),它也可以避免触及内存。 - Dietrich Epp
    1
    @mirabilos:我也看到过使用“高水位标记”的实现,其中超出某个点的地址被清零。如果你担心应用程序写入未分配的内存,那么除了在程序中加入mudflap之外,几乎没有什么方法可以防止隐蔽错误。 - Dietrich Epp
    10
    虽然与速度无关,但 calloc 也更少出现错误。也就是说,当 large_int * large_int 导致溢出时,calloc(large_int, large_int) 会返回 NULL ,但 malloc(large_int * large_int) 的行为未定义,因为你不知道返回的内存块的实际大小。 - Dunes
    显示剩余13条评论

    16

    在许多系统上,在空闲处理时间内,操作系统会自动将可用内存清零并标记为calloc() 的安全内存,因此当你调用 calloc()时,它可能已经有空闲的、清零的内存可以提供给你。


    2
    你确定吗?哪些系统会这样做?我认为大部分操作系统只是在闲置时关闭处理器,并根据进程的需要将内存清零,只有当它们写入该内存时(而不是在分配时)才会进行清零。 - Dietrich Epp
    17
    @Dietrich:FreeBSD应该在空闲时间内对页面进行零填充:请参阅其vm.idlezero_enable设置。 - Zan Lynx
    @ZanLynx vm.idlezero_enable本身很有趣,但它可能只是为了防止信息泄漏。是否有API可以获取libccalloc()零内存?我猜静态的只读零页就足够了;写入故障会将它们与零池交换。我猜池的另一部分是用于磁盘缓冲区的? - artless noise
    1
    @DietrichEpp 抱歉打扰,但例如Windows就会这样做。 - Andreas Grapentin
    在Windows上,calloc() 等同于 HeapAlloc(crtheap, HEAP_ZERO_MEMORY, size)。 - KindDragon
    显示剩余4条评论

    3
    在某些平台或模式下,malloc在返回内存之前会将其初始化为一些非零值,因此第二个版本可能会对内存进行两次初始化。

    malloc()不会初始化RAM。它会给你一个指向新分配的RAM的指针,其中包含以前存在的任何垃圾数据。calloc()分配并将其初始化为0。 - Felix An

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接