简短版本:始终使用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 无效的方法:
您的过程将调用calloc()
并要求256 MiB。
标准库调用mmap()
并要求256 MiB。
内核找到256 MiB的未使用RAM,并通过修改页表将其提供给您的进程。
标准库将RAM清零memset()
并从返回calloc()
。
您的进程最终退出,内核回收RAM,以便其他进程可以使用它。
实际运作方式
上面的过程是可行的,但是这种方式不会发生。有三个主要区别。
当您的进程从内核获取新内存时,该内存可能先前已被其他某个进程使用。这是安全隐患。如果该内存具有密码,加密密钥或秘密莎莎食谱,该怎么办?为了防止敏感数据泄漏,内核始终在将内存交给进程之前先清除内存。我们也可以通过将内存清零来清理内存,如果新内存被清零,我们也可以保证它为好,因此可以mmap()
保证返回的新内存始终为零。
有很多程序可以分配内存,但不立即使用内存。有时,内存已分配但从未使用过。内核知道这一点并且很懒。当您分配新的内存时,内核根本不会触摸页表,也不会为进程提供任何RAM。相反,它在进程中找到一些地址空间,记下应该去的地方,并保证如果程序实际使用它,它将在其中放置RAM。当您的程序尝试从这些地址读取或写入时,处理器将触发页面错误,并且内核会逐步将RAM分配给这些地址并继续执行程序。如果您从不使用内存,则不会发生页面错误,并且程序也不会实际获得RAM。
一些进程分配内存,然后从中读取而不修改它。这意味着跨不同进程的内存中的许多页面可能填充有从返回的原始零mmap()
。由于这些页面都是相同的,因此内核使所有这些虚拟地址指向填充有零的单个共享4 KiB内存页面。如果您尝试写入该内存,则处理器会触发另一个页面错误,内核会介入,从而为您提供一个新的零页面,该页面不会与任何其他程序共享。
最终过程看起来像这样:
您的过程将调用calloc()
并要求256 MiB。
标准库调用mmap()
并要求256 MiB。
内核找到256 MiB的未使用地址空间,记下该地址空间现在的用途,然后返回。
标准库知道的结果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()
?