总之,应该总是使用
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页面,该页面填充了零值。如果您尝试写入该内存,则处理器触发另一个页错误,内核会提供一个未与任何其他程序共享的新的零值页面。
最终过程看起来更像是这样:
- 进程调用
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()
会发生什么?
buf[i] = (char*)calloc(BLOCK_SIZE, sizeof(char));
- 但是一个聪明的优化编译器应该自动优化这个代码。 - undefined