[译] C程序员该知道的内存知识 (1)

2020-05-02 14:57:05 +08:00
 felix021

上篇 《踩坑记: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 )可能会给你一段并不由他持有的内存,并且徒劳地希望你不去用它。就像如今的银行一样(译注:应该是指银行存款)。这被称为 overcomiting[4](译注:指允许申请超过可用空间的内存),有一些正当的应用有这种需求(例如稀疏数组),这也意味着内存分配不会简单被拒绝。

char *block = malloc(1024 * sizeof(char));
if (block == NULL) {
    return -ENOMEM; /* sad :( */
}

检查 NULL 返回值是个好习惯,但已经没有过去那么强大了。由于 overcommmit 机制的存在,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)

译注:针对上图加一些解释备查

  1. 图中显示的地址空间是由高到低,0x00000000 在底部,最高 0xFFFFFFFF,一共 4GB ( 2^32 )。
  2. 高位的 1GB 是内核空间,用户代码 不能 读写,否则会触发段错误。图右侧标注的 0xC0000000 即 3GB ; TASK_SIZE 是 Linux 内核编译配置的名称,表示内核空间的起始地址。
  3. Random stack offset:加上随机偏移量以后可以大幅降低被栈溢出攻击的风险。
  4. Stack ( grows down ): 进程的栈空间,向下增长,栈底在高位地址,PUSH 指令会减小 CPU 的 SP 寄存器( stack pointer )。图右侧的 RLIMIT_STACK 是内核对栈空间大小的限制,一般是 8MB,可以用 setrlimit 系统调用修改。
  5. Memory Mapping Segment:内存映射区,通过 mmap 系统调用,将文件映射到进程的地址空间(包括 libc.so 这样的动态库),或者匿名映射(不需要映射文件,让 OS 分配更多有里子的地址空间)。
  6. Heap:我们常说的堆空间,从下往上增长,通过 brk/sbrk 系统调用扩展其上限
  7. BSS 段:包含未初始化的静态变量
  8. Data 段:代码里静态初始化的变量
  9. Text 段( ELF ):进程的可执行文件(机器码)
  10. 这里说的段( segment )的概念,源于 x86 cpu 的段页式内存管理

图中也展示了 内存映射段( memory mapping segment, MMS )是向下增长的,但并不总是这样。MMS 通常(详见 Linux 内核代码 x86/mm/mmap.c:113 和 arch/mm/mmap.c:1953 )开始于栈的最低地址(译注:即栈底)以下的某个随机地址。注意是“通常”,因为它也可能在栈的上方 ,如果栈空间限制很大(或无限;译注:可用 setrlimit 修改),或者启用了兼容布局。这一点有多重要?——不重要,但可以让你了解到自由地址范围( free address ranges )。

在上图中,你可以看到 3 个不同的变量存放区:进程的数据段(静态存储,或堆内存分配),内存映射段,和栈。我们从这里开始。

理解栈上的内存分配

装备箱:

栈相对比较容易理解,毕竟每个人都知道如何在栈上放一个变量,对吧 ?比如:

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

5433 次点击
所在节点    程序员
21 条回复
teawithlife
2020-05-02 15:40:16 +08:00
通俗易懂,手动点赞
LU35
2020-05-02 15:48:46 +08:00
感谢分享,已收藏
cy476571989
2020-05-02 16:12:32 +08:00
感谢分享,楼主。

对于翻译,不知道你对你现在的翻译方式体验如何,我自己做了一个翻译工具,专门用于翻译开源项目文档,如果你有需求,可以尝试一下:www_breword_com

我会先将文档调用 Google translate 的机器翻译接口,然后你直接在机翻的基础上修改就好了,翻译完成之后,你可以下载译文,然后就可以自己随意的发布了,希望对你有用。
felix021
2020-05-02 18:05:51 +08:00
@cy476571989 我比较喜欢这种逐字逐句的翻译,自己琢磨用词,和背后的梗
xupefei
2020-05-02 18:52:55 +08:00
“检查 NULL 返回值是个好习惯,但已经没有过去那么强大了。”

这琢磨出来的翻译还是不太行。我猜“强大”的原文是 critical ?
felix021
2020-05-02 19:11:26 +08:00
@xupefei 原文是 powerful,意思是有了 overcommit 以后,很难遇到返回 null 的情况了。
msg7086
2020-05-02 19:28:39 +08:00
顺便一提,Windows 上没有 memory overcommit,所以 Windows 下还是能检测内存指针的。
felix021
2020-05-02 20:20:01 +08:00
@msg7086 查了下,stackoverflow 上说“Windows will allow a program to allocate more (virtual) memory than there is RAM on the machine, but ONLY if there is enough free disk space to be able to back the virtual memory requested by the program by disk if necessary.”,也就是仅限于 RAM + 虚拟内存
aneureka
2020-05-02 20:25:14 +08:00
楼主有点高产呀🤣
felix021
2020-05-02 20:38:45 +08:00
@aneureka 勉强按一周一篇……翻译相对好点,相比自己写,不那么费脑
lsj8924
2020-05-02 21:52:16 +08:00
我觉得应该是每个程序员该知道的的操作系统知识更恰当。抛开操作系统去理解内存有点扯。虚拟内存本身就是操作系统的一个单元。
felix021
2020-05-02 22:01:31 +08:00
@lsj8924 OS 的话题就有点大了,进程调度,设备管理等,这篇关注点还是在内存上。
cabing
2020-05-02 22:16:24 +08:00
这个内容,不用这么看吧。。。太细节了。

看看操作系统,虚拟内存那一章,如果想了解 linux,看看 linux 虚拟内存那一章。

推荐《现代操作系统》
felix021
2020-05-02 22:18:53 +08:00
@cabing 定位不同,书本通常都很枯燥,而且需要大量基础概念的铺垫(需要循序渐进)
xupefei
2020-05-02 22:35:43 +08:00
@felix021 这样的话,powerful 应该翻译成 必要 有用 之类的词。这个 powerful 可以换成 important 或 essential 。
felix021
2020-05-02 23:42:07 +08:00
@xupefei 确实“有用”读起来更通顺一点
scnace
2020-05-03 01:06:06 +08:00
没有冒犯的意思,但是文章中出现了两次 overcommit,都拼错了 hhh (不过给翻译点赞,我也倾向于正确的手法应该是一些术语不要强行翻译,而是尽量保持 native,然后加上注解👍
felix021
2020-05-03 01:25:30 +08:00
@scnace 嗯,我也发现了,我的 mbp 的 M 键最近不灵光,打字挺痛苦。。。
korokke
2020-05-03 06:33:24 +08:00
感谢,关注了
AllenHua
2020-05-03 20:32:40 +08:00
感谢楼主分享 点赞

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/668069

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX