内存管理相关的 API

这是 《Operating System: Three Easy Pieces》 的第 3 篇读书笔记。

有关 内存管理的 API

主要介绍的是基于 Unix 系列系统的内存管理相关的 API。

关键点:如果分配和管理内存?

在 Unix里,或者说 C 程序里,理解如何分配和管理内存对于构建健壮和可靠的软件是非常关键的。

那么,哪些接口是经常使用的呢?哪些常犯的错误可以避免呢?

内存的类型

拿 C 程序来举例子,通常有两种内存可以用来被分配。

栈上的内存 ( Stack Memory )

这种类型的内存由编译器隐式地 (implicitly) 代替我们管理(分配和销毁),因为这个原因,有时候也被叫做 “自动内存” ( automatic memory )。

void func() {
    int x;  // declares an integer on the stack
}

如上面的一小段代码,在函数里声明了一个整形变量x。

当调用这个函数时,编译器会为此编译准备好了一系列指令 —— 设置函数栈帧,设置返回值,然后向操作系统在栈上申请一块儿整形大小的内存,让程序可以 x 这个变量名引用(使用)。

而当函数运行即将结束时,编译器也已经为此做好了准备 —— 返回到上一个栈帧前将申请的内存释放掉,读取返回地址,跳转回上一个函数的栈帧。

所以,在栈上分配的内存 生命周期很短,随函数调用和返回而朝生夕灭。

堆上的内存 ( Heap Memory )

如果我们需要生命周期长一点的内存该怎么申请呢?

答案是通过操作系统向 中申请。堆上的内存分配和释放都交由由程序员显示地(explicitly)负责,这毫无疑问是个沉重的任务,哈!而且容易由此引起很多 bugs。

void func() {
    int* x = (int*) malloc(sizeof(int));
}

上面这段代码,在一行代码里,同时向栈和堆都申请了内存:

  1. 先是在栈上申请了能容纳指针变量 x 的内存,准备容纳向堆上申请的内存的地址;
  2. 接着调用 glibc 提供的 malloc() 库函数,向堆上申请一个整形大小的内存空间 —— 如果这个库函数调用成功,会返回所申请内存空间大小的起始地址,如果失败的话,会返回 NULL。

malloc() 函数

#include <stdlib.h>
void* malloc(size_t size);

malloc() 函数只需要一个参数,这个参数描述了“你想申请多少个字节的内存”。

你可能会注意到该函数返回了一个 void* 类型的指针,这是为了方便程序程序员接下来的操作。比如说我申请了一个 int64_t 大小的内存,但是我可以通过 强制类型转换 将其准换成 char*, 这样我就可以一个字节一个字节的操作这小小的8个字节内存(通常 sizeof(int64_t) = 8 )。

这种情况下,程序员可能清楚地知道(或者不知道)他要对这块内存做什么 :)

free() 函数

如上所示,想在堆上申请内存是非常简单的,同时,归还这块内存也是的。

int* x = (int*) malloc(10 * sizeof(int));
// ...
free(x);

通过传给 free() 函数一个指针,这个指针存放着 由 malloc 申请成功返回的内存地址。

同时注意的是,并没有传入一个参数表明 需要释放多少内存大小,那这肯定是在别的地方记录着啦。

还记得么,我们说 malloc()free() 都是库函数,这个 通常就是指 glibc 了,对于内存分配,它通常不只是做分配的工作而已,还做了 cache 和记录已分配情况等其他的事情,所以,当你通过 malloc()free() 管理你的内存时,实际上更多的细节由 glibc 里内存管理相关的库帮你打理着。

常见的错误

没有分配内存就使用

char* src = "hello";
char* dst;
strcpy(dst, src);

dst 的内容还没有被准确初始化(通常只声明但并不初始化的变量的值是不确定的,由各家编译器实现决定)呢,就要被使用了,所以会将 src 所指向的字符串拷贝到未知的区域去。

正确的做法应该是:

char* src = "hello";
char* dst = (char*) malloc(strlen(str) + 1);
strcpy(dst, src);

也可以用 strdup() 函数来实现。

没有分配足够的内存

可以尝试下上面代码的修改版本,编译、运行之后看下会发生上面:

char* src = "hello";
char* dst = (char*) malloc(strlen(str));
strcpy(dst, src);

有可能会 core,也有可能什么都没发生 :(

忘记初始化已分配的内存

malloc() 函数只分配内存,但是并不负责为你初始化或者清0分配给你的内存。

试想,刚释放了一块儿内存,然后又申请了一块儿内存,如果glibc 给你的是是同一块儿...(谁知道是不是同一块儿呢!)

那么未正确初始化这块儿内存就进行读取,读取到的将是啥?

忘记释放内存

这个常见的错误通常会导致内存泄漏 ( memory leak ),它发生于你忘记释放了内存。

对于运行时间长的程序来说(比如说操作系统它自己)这将是一个巨大的隐患,缓慢的内存泄漏有可能直到内存耗尽。

注意,即使使用了带垃圾回收功能的程序语言,也有可能避免不了这一点。比如说一块儿使用引用计数标记的内存,其引用总是不为0而且这类的内存还在增长,就有可能造成内存泄漏。

但是,对于运行时间很短的程序来说,在程序结束之后,操作系统会回收跟这个进程有关的一切资源,那么就算有泄漏的内存,也会被操作系统一并回收了,此时的内存泄漏造成的影响可能就很小。

但是,将不用的内存及时释放掉是个编程的好习惯。

还没使用完内存就提前释放

如果是这样的话,那么后续的对这块儿内存的操作有可能会使程序 Crash,或者覆盖了原本有效且不属于你管理的内存,后果很严重。

还指向这块儿内存的指针,也被称为 悬挂指针 ( dangling pointer ) ,它指向了一个现在不合适的内存地址。

重复释放

这种行为的结果是不确定的。内存管理的库可能会不知所措而干些蠢事,或者程序直接 Crash。

不正确地调用了 free()

传给 free() 的值如果不正确的话结果也可能是灾难性的。

一些帮助检测内存使用问题的工具

操作系统对内存分配的底层支持

malloc() 函数和 free() 函数其实都是 glibc 提供的 库函数,而不是 系统调用

关于 系统调用库函数 的区别,这里有一个 参考

malloc()free() 的介绍请参见 GNU的文档:Basic-Allocation

关于 glibc 是什么?

库函数得要在 OS 提供的系统调用基础上进行编写。

sbrk() / brk() 系统调用

sbrk()brk() 这两个系统调用都可以用来改变堆的地址。也是 malloc() 实现所依赖使用的库函数之一。

它们通过增加或者减少堆顶的地址来调整堆的大小。
这里的堆顶地址是进程独有的,每个进程都不同,其实 * 堆* 也是每个进程独有的,是操作系统通过 对内存进行抽象、 向上层提供的一段长度可变的 虚拟内存

通常将堆的当前内存边界称为 “program break”。

#include <unistd.h>

// return 0 on success, -1 on error
int brk(void* end_data_segment); 

// return previous program break on success, or (void*)-1 on error
void* sbrk(intptr_t increment); 

在进程的 program break 位置被抬升后,程序就可以访问新的新分配区域内的内存空间了。

需要注意的是,单纯抬升 program break 的地址并不一定会让操作系统立刻分配内存并把物理内存页面加载进来,内核会在进程首次访问新分配区域的任何内存地址时将尚未在内存里的页面加载进来。

另外,它并没有说明会负责将新申请的堆初始化。因此,这也是 常见的错误:忘记初始化已分配的内存 提醒的雷区。(当然可能有些库函数会帮你做这个操作)

通常不建议直接操作这两个系统调用来申请和释放内存,除非是非常有经验的、且详细地知道许多内存分配细节的程序员。

较之 brk()sbrk()malloc() 有不少优点:

  • 属于 C 语言标准的一部分
  • 更容易在多线程环境中使用
  • 接口简单,允许分配小块儿内存
  • 允许随意释放内存块儿,他们被维护于一张空闲列表中,在后续内存分配调用时可能可以被循环使用

同样的,free() 函数也并不一定会马上降低 program break 的位置,而是将这块内存添加到空闲内存列表中,供后续的 malloc() 函数循环使用,这么做可能是出于:

  • 被释放的内存块通常会位于堆的中间,而降低 program break 是不可能的
  • 它尽可能的减少了程序调用 sbrk() 系统函数的次数。系统调用的开销虽小,但也颇为可观
  • 大多数情况下,降低 program break 的位置不会对那些分配大量内存的程序有多少帮助,它们通常倾向于持有已分配的内存或是反复释放和重新分配内存,而非释放内存后再持续运行一段时间

mmap() 系统调用

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

mmap() 函数提供的是一种内存映射文件的方式,它可以将一个文件或者 swap space 的一段映射到进程的地址空间,实现磁盘文件内容和进程虚拟地址空间的一段 虚拟 映射关系,之后可以通过操作指针的方式操作这段内存从而实现直接操作文件的目的。

但并不是说直接对这块内存的操作会马上反应在磁盘上,操作系统有自己的调度策略,会在合适的时机将这块 脏页面 刷回磁盘,当然也能使用 msync() 来强制 OS 马上将修改了的内存内容同步到磁盘。

创建这块内存创建有诸多属性可以选择,比如说需要申请的内存字节数 ( 即 length 参数 ),这块映射内存的保护信息 ( 即 prot 参数,可读?可写?还是可执行? ),还有可见性 ( 即 flags 参数,进程私有的还是进程间共享的? ),其他的还有对齐等。剩余的参数 fd 和 offset 用于文件映射,如果是想创建一个匿名映射的内存区域,可以忽略它们。

mmap() 好处多多:

  • 相比传统的文件读写,躺在磁盘上的文件数据大致需要经过两次复制才能到达用户空间:第一次是从磁盘到内核空间,第二次是从内核空间到用户指定的内存空间,而 mmap() 建立映射之后只需要一次加载数据。
  • 提供了 进程间共享内存 和 互相通信 的一种方式
  • 当内存空间不足时,可以通过磁盘空间将映射成为内存区域,利用外存当内存,补充内存空间的不足
  • 给其他诸如 数据库 等数据一致性敏感的软件系统提供了更加自由的操作方式,可以绕过操作系统的一些缓存策略。

其他也可以用于内存分配的库函数

calloc() 函数

#include <stdlib.h>
// allocate memory and also zeroes it before returning
void* calloc(size_t numiterms, size_t size);

realloc() 函数

#include <stdlib.h>
void* realloc(void* ptr, size_t size);

通常情况下,当想增大已分配的内存时,realloc() 会试图去合并在空闲列表中紧随其后且满足大小要求的内存块。若原内存块位于堆顶,则 realloc() 将对堆空间进行扩展。若这块儿内存位于堆中部,且紧邻其后的空闲内存空间大小不足,realloc()会分配一块儿新内存,并复制数据过去。
最后这种情况比较常见,当然,这会占用大量的CPU资源。一般情况下,应尽量避免调用 realloc()

由于 realloc() 可能会移动内存块,所以任何指向该内存块内部的指针在调用 realloc() 之后都可能不再可用。

分配对齐的内存:memalign()posix_memalign()

#include <malloc.h>
void* memalign(size_t boundary, size_t size);

在成功的情况下,这个函数会分配size个字节的内存,起始地址是参数 boundary 的整数倍,而 boundary 必须是 2 的整数次幂。函数返回已分配的内存地址。

注意,memalign() 并非在所有的 UNIX 实现上都存在。

#include <stdlib.h>
int posix_memalign(void** memptr, size_t alignment, size_t size);

这个函数会将已分配的内存通过参数 memptr 返回,内存与 alignment 参数的整数倍对齐,alignment 必须是 sizeof(void*) 与 2 的整数次幂两者间的乘积。

在栈上分配内存:alloca()函数

#include <alloca.h>
void* alloca(size_t size);

这个函数是通过调整 栈指针,在当前帧(栈顶上的帧)的上方扩展空间来达到的。通过 alloca() 函数分配的内存不需要调用 free() 显式释放,同时也不可能调用 realloc() 函数来调整通过 alloca() 函数分配的内存大小。

总结 Summary

本篇,仅仅是介绍了些内存管理操作相关的 API,但是 API 背后的很多设计和实现细节并没有过多介绍,后面再随着这本书的展开慢慢补充。

参考
Operating System: Three Pieces of Easy Way - Interlude: Memory API
The Linux Programming Interface: Memory Allocation

Comments
Write a Comment