简短版本:始终使用calloc()
代替malloc()+memset()
。在大多数情况下,它们是相同的。在某些情况下,calloc()
会做更少的工作,因为它可以跳过memset()
完全。在其他情况下,calloc()
甚至可以作弊而不分配任何内存!然而,malloc()+memset()
总是会做足量的工作。
要理解这一点,需要简单浏览一下记忆系统。
快速浏览记忆
这里有四个主要部分:程序、标准库、内核和页表。你已经知道你的程序了,所以...
内存分配器如malloc()
and calloc()
主要用于进行小型分配(从 1 字节到 100 KB 的任何内容)并将它们分组到更大的内存池中。例如,如果分配 16 个字节,malloc()
首先会尝试从其中一个池中获取 16 字节,然后当池耗尽时向内核请求更多内存。但是,由于您询问的程序会立即分配大量内存,malloc()
and calloc()
只会直接从内核请求该内存。此行为的阈值取决于您的系统,但我见过 1 MiB 用作阈值。
内核负责为每个进程分配实际的 RAM,并确保进程不会干扰其他进程的内存。这就是所谓的内存保护,自 20 世纪 90 年代以来,这种情况就很常见,这就是为什么一个程序可能崩溃而不会导致整个系统瘫痪的原因。因此,当程序需要更多内存时,它不能仅仅获取内存,而是使用系统调用向内核请求内存,例如mmap()
or sbrk()
。内核会通过修改页表的方式将RAM分配给每个进程。
页表将内存地址映射到实际的物理 RAM。您的进程的地址(32 位系统上的 0x00000000 到 0xFFFFFFFF)不是真实内存,而是以下地址虚拟内存。处理器将这些地址划分为 4 KiB 页,每个页可以通过修改页表分配给不同的物理 RAM。只有内核才被允许修改页表。
怎么不起作用
以下是分配 256 MiB 的方法not work:
-
您的流程调用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()
总是用零填充(或will be一旦它实际获得了一些 RAM),因此它不会触及内存,因此不会出现页面错误,并且 RAM 永远不会提供给您的进程。
-
您的进程最终会退出,并且内核不需要回收 RAM,因为它从来没有被分配过。
如果你使用memset()
将页面归零,memset()
将触发页面错误,导致 RAM 被分配,然后将其清零,即使它已经被零填充了。这是大量的额外工作,并解释了原因calloc()
比malloc()
and memset()
。如果你最终还是使用了内存,calloc()
仍然比malloc()
and memset()
但差异并不那么荒谬。
这并不总是有效
并非所有系统都有分页虚拟内存,因此并非所有系统都可以使用这些优化。这适用于像 80286 这样非常旧的处理器以及对于复杂的内存管理单元来说太小的嵌入式处理器。
这也并不总是适用于较小的分配。通过较小的分配,calloc()
从共享池获取内存而不是直接进入内核。一般来说,共享池中可能会存储来自旧内存的垃圾数据,这些垃圾数据是使用和释放的free()
, so calloc()
可以使用该内存并调用memset()
将其清除。常见的实现将跟踪共享池的哪些部分是原始的并且仍然填充零,但并非所有实现都这样做。
纠正一些错误答案
根据操作系统的不同,内核可能会也可能不会在其空闲时间内将内存归零,以防您稍后需要获取一些归零的内存。 Linux 不会提前清零内存,并且Dragonfly BSD 最近也从其内核中删除了此功能。然而,其他一些内核提前将内存清零。无论如何,在空闲期间清零页面不足以解释巨大的性能差异。
The 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()
and calloc()
).
派对把戏
不要循环 10 次,而是编写一个分配内存直到malloc()
or calloc()
返回 NULL。
如果添加会发生什么memset()
?