为什么malloc + memset比calloc慢?

kin*_*kai 249 c malloc

众所周知,它与初始化分配的内存calloc不同malloc.使用时calloc,内存设置为零.使用时malloc,内存不会被清除.

所以在日常工作中,我认为callocmalloc+ 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++;
        }
}
Run Code Online (Sandbox Code Playgroud)

代码1的输出:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  
Run Code Online (Sandbox Code Playgroud)

代码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++;
        }
}
Run Code Online (Sandbox Code Playgroud)

代码2的输出:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  
Run Code Online (Sandbox Code Playgroud)

更换memsetbzero(buf[i],BLOCK_SIZE)在代码2产生相同的结果.

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

Die*_*Epp 443

简短版本:始终使用calloc()而不是malloc()+memset().在大多数情况下,它们将是相同的.在某些情况下,calloc()会减少工作量,因为它可以memset()完全跳过.在其他情况下,calloc()甚至可以作弊而不分配任何内存!但是,malloc()+memset()将始终做全部工作.

理解这一点需要对内存系统进行简短的浏览.

快速浏览记忆

这里有四个主要部分:程序,标准库,内核和页表.你已经了解你的程序,所以......

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

内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存.这被称为内存保护,自20世纪90年代以来一直很常见,这就是为什么一个程序在不关闭整个系统的情况下崩溃的原因.因此,当一个程序需要更多的内存时,它不仅可以占用内存,而是使用系统调用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分配给这些地址并恢复程序.如果你从不使用内存,页面错误永远不会发生,你的程序永远不会真正获得内存.

  • 某些进程会分配内存,然后从中进行读取而不进行修改.这意味着不同进程的内存中的很多页面可能会被返回的原始零填充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
Run Code Online (Sandbox Code Playgroud)

所以你可以看到,memset()非常快,你不会真正为大块内存获得更好的东西.

memset()已经归零的内存归零的事实意味着内存被归零两次,但这只能解释2倍的性能差异.这里的性能差异要大得多(我测量幅度超过三个订单我的系统之间malloc()+memset()calloc()).

党的把戏

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

添加后会发生什么memset()

  • @Dietrich:Dietrich的虚拟内存解释关于OS为calloc多次分配相同的零填充页面很容易检查.只需添加一些循环,在每个分配的内存页面中写入垃圾数据(每500字节写一个字节就足够了).然后整体结果应该变得更加接近,因为系统将被迫在两种情况下真正分配不同的页面. (7认同)
  • 虽然速度不相关,但是`calloc`也不容易出错.也就是说,`large_int*large_int`会导致溢出,`calloc(large_int,large_int)`返回`NULL`,但`malloc(large_int*large_int)`是未定义的行为,因为你不知道实际的大小返回的内存块. (4认同)

Chr*_*utz 12

因为在许多系统上,在备用处理时间内,操作系统自行设置空闲内存为零并标记为安全calloc(),所以当你打电话时calloc(),它可能已经有免费的零内存给你.

  • @Dietrich:FreeBSD应该在空闲时间内填充页面:请参阅其vm.idlezero_enable设置. (13认同)
  • 我想你可能错了,但我不确定. (4认同)
  • 你确定吗?这是哪个系统?我认为大多数操作系统只是在闲置时才关闭处理器,并在写入内存后立即将对分配的进程的内存归零(但不是在分配内存时)。 (2认同)