为什么malloc+memset比calloc慢?

众所周知,callocmalloc不同,因为它初始化分配的内存。使用calloc,内存被设置为0。使用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

在代码2中用bzero(buf[i],BLOCK_SIZE)替换memset产生相同的结果。

为什么malloc+memsetcalloc慢这么多?calloc如何做到这一点?

62105 次浏览

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

在某些平台上,在某些模式下,malloc在返回内存之前初始化内存为一些典型的非零值,因此第二个版本可以很好地初始化内存两次

简短的版本:总是使用calloc()而不是malloc()+memset()。在大多数情况下,它们是一样的。在某些情况下,calloc()将做更少的工作,因为它可以完全跳过memset()。在其他情况下,calloc()甚至可以欺骗,不分配任何内存!然而,malloc()+memset()总是会完成全部的工作。

要理解这一点,需要对记忆系统做一个简短的介绍。

快速记忆之旅

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

malloc()calloc()这样的内存分配器主要用于进行小的分配(从1个字节到100个KB),并将它们分组到更大的内存池中。例如,如果你分配了16个字节,malloc()将首先尝试从它的一个池中获取16个字节,然后在池耗尽时向内核请求更多的内存。然而,由于你要求的程序是一次性分配大量内存,malloc()calloc()将直接从内核请求内存。这种行为的阈值取决于您的系统,但我曾见过使用1mib作为阈值。

内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存。这被称为内存保护,,自20世纪90年代以来一直非常普遍,这就是为什么一个程序崩溃而不会导致整个系统崩溃的原因。因此,当程序需要更多内存时,它不能直接获取内存,而是使用mmap()sbrk()这样的系统调用从内核请求内存。内核将通过修改页表为每个进程提供RAM。

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

为什么它不起作用

下面是分配256个MiB 的工作方式:

  1. 你的进程调用calloc()并请求256个MiB。

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

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

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

  5. 您的进程最终退出,内核回收RAM,以便它可以被另一个进程使用。

它是如何工作的

上述过程是可行的,但它不是这样发生的。有三个主要的区别。

  • 当你的进程从内核获得新的内存时,这些内存可能已经被其他进程使用过了。这是一个安全隐患。如果内存中有密码、加密密钥或秘密的萨尔萨食谱呢?为了防止敏感数据泄漏,内核总是在将内存分配给进程之前对其进行清理。我们也可以通过将内存归零来清除内存,如果新的内存归零,我们也可以将其作为一个保证,因此mmap()保证它返回的新内存总是归零。

  • 有很多程序分配内存,但不立即使用内存。有时内存被分配但从未使用过。内核知道这一点,所以是懒惰的。当您分配新的内存时,内核根本不会触及页表,也不会给您的进程任何RAM。相反,它在你的进程中找到一些地址空间,记下应该去那里的内容,并承诺如果你的程序实际使用它,它将把RAM放在那里。当你的程序试图从这些地址读或写时,处理器触发页面错误,内核介入给这些地址分配RAM,并恢复你的程序。如果您从未使用内存,页面错误就永远不会发生,您的程序也永远不会实际获得RAM。

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

最终的过程是这样的:

  1. 你的进程调用calloc()并请求256个MiB。

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

  3. 内核找到未使用的地址空间,的256个MiB,记录该地址空间现在用于什么,并返回。

  4. 标准库知道mmap()的结果总是被0填充(或一旦它实际获得一些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()会发生什么?