What is malloc doing, anyway?
258 points
• 3 days ago
• Article
Link
malloc 函数为内存管理提供了一个看似简单的接口,但它隐藏了程序与操作系统交互时背后的复杂真实情况。开发者只需申请字节并在之后释放,而系统为了避免频繁 syscall 带来的高昂开销,必须完成大量的簿记工作。通过观察一个进程的内存布局可以发现,它被划分为代码段、堆 heap 、栈 stack 等不同区域。当程序需要内存时,通常会移动 program break(也就是堆的末尾边界),而不是为每一次小分配都发起新的 syscall 。
实现一个基础的分配器可以说明简单方案的低效之处。每次请求都移动 program break 的 bump allocator 在功能上是正确的,但由于每次 syscall 都会带来显著的性能惩罚,因此表现不佳。像 glibc 这样的现代分配器会从内核一次性请求大块内存,然后在用户态将其切分使用。为了管理这些切片,系统会在每个内存块上附加 headers,保存必要的 metadata,使 free 函数能够识别块的大小,并维护可用于将来复用的空闲内存列表。
进一步完善这一过程需要实现 splitting 和 coalescing 。当手上有足够大的空闲块时,分配器可以将其 split 以满足小请求;而当内存被释放时,free 函数还能将相邻的空闲块合并,从而减少 fragmentation 。但这又带来了一个难题:如何把内存真正归还给操作系统。由于内核采用 demand paging,只在需要时才把虚拟内存映射到物理 RAM,仅仅在分配器里把内存标记为 free,并不会自动降低进程的 resident set size 。开发者必须手动触发 trim 来让内存释放回内核,不过这通常只有在空闲块位于堆的顶部时才可行。
对于特别大的请求,分配器往往会改用 mmap 系统调用,直接把内存映射到 address space,从而完全绕开堆。这种做法有利之处在于:一旦内存被释放,munmap 可以立即把这部分内存归还给内核,不受它在进程地址空间中位置的影响。虽然这种方式更灵活,但它还需要为新页创建映射,带来额外开销。因此,分配器通常只把这种策略留给大块分配,而把较小、频繁的分配留在堆上完成。
将自定义分配器与生产级的 glibc 实现进行对比,就能看出性能差距从何而来。 glibc 通过使用 bins 、 thread-local caches 等更复杂的结构来获得极快的速度,使系统能迅速找到合适大小的空闲内存,而不必遍历很长的链表。尽管这些优化能显著提升性能,但它们也凸显了内存管理的根本权衡:分配器为了避免与内核交互带来的延迟,会把 chunks 保留在 caches 或 pools 中,从而以"优先保证应用性能"为代价,延后未使用内存的即时释放。
The function malloc provides a deceptively simple interface for memory management, hiding the complex reality of how a program interacts with the operating system. While developers simply request bytes and eventually free them, the system must perform significant bookkeeping to avoid the high cost of frequent syscalls. By examining the memory layout of a process, we see that it is divided into distinct regions like the code, heap, and stack. When a program needs memory, it often moves the program break, which defines the end of the heap, rather than making a new syscall for every small allocation.
Creating a basic allocator demonstrates the inefficiency of simple approaches. A bump allocator that moves the program break on every request is functionally correct but performs poorly because each syscall carries a significant performance penalty. Modern allocators like those in glibc avoid this by requesting large blocks of memory from the kernel and slicing them up in userspace. To manage these slices, the system uses headers attached to each chunk of memory, storing essential metadata that allows the free function to identify the block size and manage lists of available memory for future reuse.
Refining this process involves implementing splitting and coalescing. When a large chunk is available, the allocator can split it to satisfy a small request, and when memory is released, the free function can merge adjacent empty chunks to prevent fragmentation. However, this raises the challenge of returning memory to the operating system. Because the kernel uses demand paging to map virtual memory to physical RAM only when necessary, simply marking memory as free in an allocator does not automatically lower the process's resident set size. Developers must manually trigger a trim to release memory back to the kernel, though this is only possible if the free chunks are at the top of the heap.
For very large requests, allocators often pivot to the mmap syscall, which bypasses the heap entirely by mapping memory directly into the address space. This approach is beneficial because munmap can return this memory to the kernel immediately upon being freed, regardless of its position in the process memory. While this offers greater flexibility, it also carries the overhead of creating new page mappings, which is why allocators generally reserve this strategy for large allocations while keeping smaller, frequent requests on the heap.
Comparing custom allocators to the production-grade glibc implementation highlights why performance gaps exist. Glibc achieves remarkable speed by using complex structures like bins and thread-local caches, which allow the system to quickly locate appropriately sized free memory without traversing long lists. While these optimizations significantly improve speed, they also illustrate the fundamental trade-off of memory management: allocators spend memory by keeping chunks in caches or pools to avoid the latency of kernel interactions, effectively prioritizing application performance over the immediate release of unused memory.
65 comments • Comments Link
• 与传统 `malloc` 抽象相关的复杂性和开销,让人们对"仅栈"或强调生命周期管理的语言产生了兴趣。这类语言试图用更确定的内存模型来取代堆分配。
• 栈式方案并非没有难点:它们通常需要大量预分配、依赖编译器层面的"魔法",而且面对链表等复杂数据结构时,仍可能需要自定义分配器。
• 生产级分配器(例如 `glibc` 中的实现或像 `mimalloc` 这样的专用库)经过高度优化,速度远胜于简单实现。它们通常还能以极低开销应对频繁的小额分配。
• 显式分配器管理是一种宝贵的工程实践。正如在 `Zig` 中看到的那样,强迫开发者明确选择分配策略,比依赖默认的全局堆更能促使人们认真思考并高效处理资源。
• 目标未必是彻底废弃堆内存,而是要打破"共享堆"模型,改用词法作用域内存或由用户显式管理的内存,从而避免许多常见的内存相关错误。
• 通过预先分配大块内存或使用静态缓冲区来规避堆分配,是嵌入式系统和高性能场景中常见的做法;在这些环境里,可预测性往往至关重要。
• 一些语言项目宣称,借助特定的内存模型和稳定的数据布局能够带来性能收益。即便这些说法看起来能回避向量结构中普遍存在的架构权衡,人们通常仍会保持怀疑。
• 阅读分配器的源码,或亲自实现一个简单分配器,能让人更清楚地理解高级应用逻辑与底层系统接口(如 `mmap` 或 `brk`)之间的衔接方式。
• 内核负责最初的虚拟内存供给与物理页映射,但用户空间分配器才承担了绝大多数"魔法":它决定应用程序的内存如何被切分与管理。
• 现代开发在很大程度上已经不再使用 `brk` 和 `sbrk`,而是转向 `mmap` 。原因在于前者已不适用,并且缺少当代内存管理所需的便携性与灵活性。
这场讨论凸显了传统堆分配的便利性,与人们希望获得更确定、更高性能内存控制之间的普遍拉扯。尽管不少人看到了"仅栈"语言以及手动选择分配器的潜力,大家的共识仍很明确:要达到生产可用的内存管理,就不能满足于复杂但抽象度很高、且尚未经过实战检验的方案。深入研究底层实现细节的价值正在变得清晰——这不是为了重复造轮子,而是为了获得编写更稳健、更高效代码所必需的架构清晰度。归根结底,开发者越来越希望减少对不透明的全局堆状态的依赖,转而采用那些能让生命周期更透明、并对内存布局作出更明确定义的方法。 • The complexity and overhead associated with traditional `malloc` abstractions have sparked interest in "stack-only" or lifetime-managed languages, which aim to replace heap allocation with more deterministic memory models.
• Stack-oriented approaches are not without challenges, as they often require significant pre-allocation, compiler-level magic, and can still necessitate custom allocators to handle complex data structures like linked lists.
• Production-grade allocators, such as those found in glibc or specialized libraries like mimalloc, are highly optimized and significantly faster than naive implementations, often handling small, frequent allocations with minimal overhead.
• Explicit allocator management is a valuable coding practice, as seen in Zig, where forcing developers to choose an allocation strategy encourages more deliberate, efficient resource handling compared to relying on a default global heap.
• The goal may not be to abolish heap memory entirely, but rather to eliminate the "shared heap" model in favor of lexically scoped or user-managed memory, which can prevent many common memory-related bugs.
• It is possible to avoid the heap by allocating large blocks upfront or utilizing static buffers, a strategy common in embedded systems and high-performance environments where predictability is paramount.
• Some language projects claim performance gains through specific memory models and stable data layouts, though these assertions are often met with skepticism when they appear to defy standard architectural trade-offs like those inherent in vector structures.
• Studying the source code of allocators or implementing a toy allocator provides critical insight into the bridge between high-level application code and low-level system interfaces like `mmap` or `brk`.
• While the kernel handles the initial provision of virtual memory and physical page mapping, user-space allocators are responsible for the vast majority of "magic" in how memory is subdivided and managed for applications.
• Modern development has largely deprecated the `brk` and `sbrk` syscalls in favor of `mmap`, as the former are outdated and lack the portability and flexibility required for contemporary memory management needs.
The discussion reflects a broader tension between the convenience of traditional heap allocation and the desire for more deterministic, performant memory control. While many see the potential in stack-only languages and manual allocator selection, there is a clear consensus that production-ready memory management requires sophisticated, battle-tested solutions that exceed simple abstractions. Patterns emerge around the value of studying low-level implementation details—not necessarily to reinvent the wheel, but to gain the architectural clarity needed to write more robust and efficient code. Ultimately, developers are increasingly focused on reducing the reliance on opaque, global heap states in favor of approaches that favor lifetime transparency and better-defined memory layouts.