在UNIX/C 程序中,理解如何分配和管理内存是构建健壮和可靠软件的重要基础。

内存类型

在运行一个C 程序的时候,会分配两种类型的内存。第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。

栈内存在程序运行过程中有编译器帮我们处理,函数处理结束,编译器释放内存,对于希望长期存在的信息,就要使用到堆内存。C语言进行堆空间分配的就是 malloc 调用。

malloc() 调用

malloc 函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回NULL。

malloc 不是系统调用,而是库调用。因此,malloc 库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或者将一些内容释放回系统。

一个这样的系统调用叫作 brk,它被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。

此外,还有 mmap() 从操作系统获取内存。

malloc(size_t size) 分配的连续内存空间,对应的虚拟内存信息由 struct mm_struct *mm 描述:

1
2
3
4
5
6
struct mm_struct {
  ...
  unsigned long start_brk, brk, start_stack; 
  /* 栈区 的起始地址,堆区 起始地址和结束地址 */
  ...
};

start_brkbrk 分别是堆的起始和终止地址(malloc 动态分配的内存就在这之间)。系统调用 brk(void *addr) 可以改变这 brk 的值,从而改变堆的大小。

内核态交互方案

由于 brk/sbrk/mmap 属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即cpu从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的。其次,这样申请的内存容易产生碎片。

鉴于以上理由,malloc 采用的是内存池的实现方式:先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可。

内存池

不管具体的分配算法是怎样的,为了减少系统调用,减少物理内存碎片,malloc() 的整体思想是先向操作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool)。

内存池的研究重点不是向操作系统申请内存,而是对已申请到的内存的管理,这涉及到非常复杂的算法,是一个永远也研究不完的课题,除了C标准库自带的 malloc(),还有一些第三方的实现,比如 Goolge 的 tcmalloc 和 jemalloc。

池化技术

在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。

所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。