上篇 《踩坑记:go服务内存暴涨》好像还挺受欢迎的。虽然文中的核心内容很少,但是为了让大多数人能读懂,中间花了很大的篇幅来解释。
尽管如此,我仍然觉得讲得不够透,思来想去觉得还是文中提到的《What a C programmer should know about memory》[1]讲得好,想借着假期翻译一下,也借机再学习一遍(顺便练习英文)。
内容有点长,我会分成几篇推送,感兴趣的话请关注这个号。
以下是正文。
# C程序员应该知道的内存知识
2007年,Ulrich Drepper 大佬写了一篇“每个程序员都应该知道的内存知识”[2],特别长,但干货满满。
但过去了这么多年(译注:原文写于2015年2月),“虚拟内存”这个概念对很多人依然很迷,就像某种魔法。呃,实在是忍不住引用一下(译注:应该是指皇后乐队的 A Kind of Magic)。
即使是该文的正确性,这么多年以后仍然被质疑[3](译注:有人在stackoverflow上提问文中内容有多少还有效)。出了什么事?
北桥?这是什么鬼?这可不是街头械斗。
(译注:北桥是早年电脑主板上的重要芯片,用来处理来自CPU、内存等设备的高速信号)
我会试着展现学习这些知识的实用性(即你可以做什么),包括“学习锁的基本原理”,和更多有趣的东西。你可以把这当成那篇文章和你日常使用的东西之间的胶水。
文中例子会使用 Linux 下的 C99 来写(译注:1999年版的c语言标准),但很多主题都是通用的。注:我对 Windows 不太熟悉,但我会很高兴附上能解释它的文章链接(如果有的话)。我会尽量标注哪些方法是特定平台相关的。但我只是个凡人,如果你发现有出入,请告诉我。
# 理解虚拟内存 - 错综复杂
除非你在处理某些嵌入式系统或内核空间代码,否则你会在保护模式下工作。(译注:指的是x86 CPU提出的保护模式,通过硬件提供的一系列机制,操作系统可以用低权限运行用户代码)。这太棒了,你的程序可以有独立的 [虚拟] 地址空间。“虚拟”这个词在这里很重要。这表示,包括其他一些情况,你不会被可用内存限制住,但也没有资格使用任何可用内存。想用这个空间,你得找OS要一些真东西来做“里子”,这叫映射(mapping)。这个里子(backing)可以是物理内存(并不一定需要是RAM),或者持久存储(译注:一般指硬盘 )。前者被称为“匿名映射”。别急,马上讲重点。
虚拟内存分配器(VMA,virtual memory allocator)可能会给你一段并不由他持有的内存,并且徒劳地希望你不去用它。就像如今的银行一样(译注:应该是指银行存款)。这被称为 overcommiting [4](译注:指允许申请超过可用空间的内存),有一些正当的应用有这种需求(例如稀疏数组),这也意味着内存分配不会简单被拒绝。
char *block = malloc(1024 * sizeof(char));
if (block == NULL) {
return -ENOMEM; /* 难过 :( */
}
检查 NULL 返回值是个好习惯,但已经没有过去那么强大了。由于 overcommit 机制的存在,OS可能会给你的内存分配器一个有效的指针,但是当你要访问它的时候 —— 铛*。这里的“铛”是平台相关的,但是通常表现为你的进程被 OOM Killer [5] 干掉。(译注:OOM即 Out Of Memory,当内存不足时,Linux会根据一定规则挑出一个进程杀掉,并在 dmesg 里留下记录)
—— 这里有点过度简化了;在后面的章节里有进一步的解释,但我倾向于在钻研细节之前先过一遍这些更基础的东西。
## 进程的内存布局
进程的内存布局在 Gustavo Duarte 的《Anatomy of a Program in Memory》 [6] 里解释得很好了,所以我只引用原文,希望这算是合理使用。我只有一些小意见,因为该文只介绍了 x86-32 的内存布局,不过还好 x86-64 变化不大,除了进程可以用大得多的空间 —— 在 Linux 下高达 48 位。
来源:Linux地址空间布局 - by Gustavo Duarte
译注:针对上图加一些解释备查
图中显示的地址空间是由高到低,0x00000000在底部,最高0xFFFFFFFF,一共4GB(2^32)。
高位的1GB是内核空间,用户代码[不能]读写,否则会触发段错误。图右侧标注的 0xC0000000 即 3GB;TASK_SIZE 是Linux内核编译配置的名称, 表示内核空间的起始地址。
Random stack offset:加上随机偏移量以后可以大幅降低被栈溢出攻击的风险。
Stack(grows down): 进程的栈空间,向下增长,栈底在高位地址,PUSH指令会减小CPU的SP寄存器(stack pointer)。图右侧的 RLIMIT_STACK 是内核对栈空间大小的限制,一般是8MB,可以用 setrlimit 系统调用修改。
Memory Mapping Segment:内存映射区,通过mmap系统调用,将文件映射到进程的地址空间(包括 libc.so 这样的动态库),或者匿名映射(不需要映射文件,让OS分配更多有里子的地址空间)。
Heap:我们常说的堆空间,从下往上增长,通过brk/sbrk系统调用扩展其上限
BSS段:包含未初始化的静态变量
Data段:代码里静态初始化的变量
Text段(ELF):进程的可执行文件(机器码)
这里说的段(segment)的概念,源于x86 cpu的段页式内存管理
图中也展示了 内存映射段(memory mapping segment, MMS)是向下增长的,但并不总是这样。MMS通常(详见Linux 内核代码 x86/mm/mmap.c:113 和 arch/mm/mmap.c:1953)开始于栈的最低地址(译注:即栈底)以下的某个随机地址。注意是“通常”,因为它也可能在栈的上方 ,如果栈空间限制很大(或无限;译注:可用setrlimit修改),或者启用了兼容布局。这一点有多重要?——不重要,但可以让你了解到自由地址范围(free address ranges)。
在上图中,你可以看到3个不同的变量存放区:进程的数据段(静态存储,或堆内存分配),内存映射段,和栈。我们从这里开始。
## 理解栈上的内存分配
装备箱:
alloca() - 在调用方的栈帧上分配内存
getrlimit() - 获取/设置 resource limits
sigaltstack() - 设置或获取信号栈上下文
栈相对比较容易理解,毕竟每个人都知道如何在栈上放一个变量,对吧 ?比如:
int stairway = 2;
int heaven[] = { 6, 5, 4 };
变量的有效性受到作用域的限制。在 C 里,作用域指的就是一对大括号 {}。因此每次遇到一个右大括号,对应的变量作用域就结束了。
然后是 alloca(),在当前 栈帧 上动态分配内存。栈帧和内存帧(也叫做物理页)不太一样,它只是一组被压到栈上的数据(函数,参数,变量等)。由于我们在栈顶(译注:SP寄存器总是指向栈顶),我们可以使用剩下的栈空间,只要不超过栈大小限制。
这就是变长数组(variable-length,VLA)和 alloca 的原理,区别在于 ,VLA受限于作用域,alloca分配的内存的有效性可以持续到当前函数返回。这里没有语言律师业务(译注:没人管你,爱咋咋地),但如果你在循环里用alloca可能会踩坑,因为你没办法释放它分配的空间:
void laugh(void) {
for (unsigned i = 0; i < megatron; ++i) {
char *res = alloca(2);
memcpy(res, "ha", 2);
char vla[2] = {'h','a'}
} /* vla dies, res lives */
} /* all allocas die */
如果要申请大量内存,VLA和alloca都不太好使,因为你几乎无法控制可用的栈空间,如果分配内存超过栈限制,就会遇到令人喜闻乐见的stack overflow。有两种办法可以绕过它,但都不太实用:
第一种是用 sigaltstack() 来捕获并处理 SIGSEGV 信号,但这只能让你捕获栈溢出(译注:程序仍然无法获得所需的内存)。
另一种是编译时指定“split-stacks”,这会将一个大的stack分割成用链表组织的“栈碎片”(stacklet)。就我所知,GCC 和 clang 编译器可以用 "-fsplit-stasck" 选项来启用这个特性。理论上这会改善内存消耗,并降低创建线程的开销,因为刚开始的时候栈可以很小,并按需扩展。但实际上可能会遇到兼容问题,因为这需要一个支持 split-stack 的链接器(例如 gold;译注:这是GNU的ELF链接器,不同于我们常用的链接器 ld,针对ELF链接性能更好)、而这是对库透明的,还可能有性能问题,例如 Go 的 hot-split 问题,在 Agis Anastasopoulos 的这篇文章[7]中有详细解释。(译注:Go 1.3 之前用 split stack,即前述用链表串起来的栈,在某些情况可能因反复的栈扩展和收缩带来性能问题;1.3 开始改成使用连续的栈空间,空间不够时重新分配、拷贝内容、修改指向栈空间的指针,因此也要求编译器能准确分析指针逃逸的情况)
休息一下,第一篇就到这里。
下一篇接着翻译下一节 Understanding heap allocation,感兴趣的记得关注,等不及的推荐文末点击“阅读原文”。
顺便再贴下之前推送的几篇文章,祝过个充实的五一假期~
参考链接:
[1] What a C programmer should know about memory
https://marek.vavrusa.com/memory/
[2] What every programmer should know about memory
http://www.akkadia.org/drepper/cpumemory.pdf
[3] stackoverflow.com - What Every Programmer Should Know About Memory?
https://stackoverflow.com/questions/8126311/what-every-programmer-should-know-about-memory
[4] Kernel - overcommit accounting
https://www.kernel.org/doc/Documentation/vm/overcommit-accounting
[5] Linux - Overcommit and OOM
https://www.win.tue.nl/~aeb/linux/lk/lk-9.html#ss9.6
[6] anatomy of a program in memory
http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory/
[7] Contiguous stacks in Go
http://agis.io/2014/03/25/contiguous-stacks-in-go.html