Skip to content
团子云技术 Lite 1.048596
Go back

从 /proc/self/maps 说起:堆、协程栈、缺页中断与 OOM

团团虾声明:基于对 Linux 进程内存布局、brpc bthread 栈机制的讨论整理而成。

一张来自 /proc/self/maps 的实测布局

笔者在跑一个小实验程序时,dump 出了一组真实的运行时地址,还有对应的 /proc/self/maps 内容。先看数据:

main 函数(代码段)      : 0x6026d90d1249
g_init (已初始化数据)  : 0x6026d90d4010
heap_var(堆)           : 0x6026fcdd9010
stack_var(栈)          : 0x7ffed66d3f34
6026d90d0000-6026d90d1000 r--p ... /tmp/exp1_memmap <- 代码段起点 (只读)
6026d90d1000-6026d90d2000 r-xp ... /tmp/exp1_memmap <- 代码段 (可执行) main 在这
6026d90d3000-6026d90d5000 rw-p ... /tmp/exp1_memmap <- 数据段 (g_init/g_bss 在这)
6026fcdd9000-6026fcdfa000 rw-p ... [heap]           <- 堆! heap_var 落在这段
7ffed66b6000-7ffed66d7000 rw-p ... [stack]          <- 栈! stack_var 落在这段
ffffffffff600000-ffffffffff601000 --xp ... [vsyscall]

把这几行映射摆到一张图上,就是经典的 64 位 Linux 进程虚拟地址空间布局。地址是随机的(ASLR),但相对结构每次都一样:

区域权限本次实测地址范围落点
代码段只读部分r--p0x6026d90d0000 - 0x6026d90d1000—
代码段可执行部分r-xp0x6026d90d1000 - 0x6026d90d2000main (0x6026d90d1249)
数据段rw-p0x6026d90d3000 - 0x6026d90d5000g_init (0x6026d90d4010)
堆 [heap]rw-p0x6026fcdd9000 - 0x6026fcdfa000heap_var (0x6026fcdd9010)
栈 [stack]rw-p0x7ffed66b6000 - 0x7ffed66d7000stack_var (0x7ffed66d3f34)
[vsyscall]--xp0xffffffffff600000 - 0xffffffffff601000—

几个值得留意的地方:

代码段。 位于地址空间最低端区域,分成两个小段:纯只读段(r--p)和可执行段(r-xp)。main(0x6026d90d1249)精确落在 0x6026d90d1000-0x6026d90d2000 这个带 x 权限的区间内——函数指针指向哪里,一目了然。

数据段。 紧挨着代码段上方,权限 rw-p。已初始化的全局变量 g_init(0x6026d90d4010)落在这里,由加载器在程序载入时赋初值。

堆。 在数据段上方。heap_var(0x6026fcdd9010)落在这段,后续继续 malloc/new 时,堆区向高地址方向生长。

栈。 位于高地址区域(0x7ffe... 开头),与堆之间隔着巨大的地址空洞。stack_var(0x7ffed66d3f34)落在此处。栈向低地址生长,函数嵌套调用过深时,栈顶指针会逐渐向堆的方向靠拢。

vsyscall。 地址空间的极高位(0xffffffffff600000)。内核映射到用户空间的一块内存,用来加速 gettimeofday 这类高频系统调用,用户态只有执行权限,无法读写。

brpc 的协程栈,到底在哪个位置

既然每个线程都有栈,那 brpc 的协程(bthread)栈是哪块?

先给结论:它绝对不会落在系统的 [stack] 区域。 它会出现在 mmap 匿名映射区,或者堆区 [heap]。

原因要说清楚。操作系统的原生线程(pthread)创建时,内核为其在虚拟地址空间的高位专门划定栈空间——主线程在 [stack],子线程通常在紧挨着栈的 mmap 区域。而 bthread 是用户态协程,从操作系统的视角来看,bthread 根本不存在,它只是用户进程里的一段代码。所以它的”栈”,其实是 brpc 框架在运行时通过 malloc、mmap 或自带内存池(butil::ResourcePool)动态申请的一块普通内存 Buffer。

在 /proc/self/maps 里找它,大概率长这样:

最常见:mmap 匿名映射区。 bthread 的默认栈大小通常是 32KB 或 1MB。分配器(tcmalloc、jemalloc 或 glibc malloc)申请这类较大内存时,底层往往走 mmap。在 maps 文件中,它显示为 [heap] 和 [stack] 之间巨大空洞里、没有任何名字后缀的 rw-p 区域:

7f8a10000000-7f8a10080000 rw-p 00000000 00:00 0   <-- 类似这种,可能就是协程栈或内存池分配的区域

有时:堆区 [heap]。 如果分配器决定从现有堆空间里划一块给 bthread 做栈,这块栈空间就直接落在 [heap] 范围内。

还有一个很有辨识度的特征——Guard Page(防溢出页)。为了防止协程栈溢出踩坏其他协程的数据,brpc 分配栈内存时通常会用 mprotect 把这块内存最顶端(或最底端,取决于生长方向)的一个页(通常 4KB)设置为不可读写(---p)。在 maps 里看到大量”一个无权限页 + 一个读写页”交替出现的结构,基本就是协程(或子线程)的栈群:

7f8a10000000-7f8a10001000 ---p 00000000 00:00 0   <-- Guard Page (不可读写,踩到就报段错误)
7f8a10001000-7f8a10080000 rw-p 00000000 00:00 0   <-- 真正的 bthread 栈空间

总结成一句:当 brpc 切换协程时,底层汇编会把 CPU 的栈寄存器(RSP)强行指向堆或 mmap 区域里申请好的那块 Buffer 的末尾。在那一刻,这块原本是”堆”的内存,就在逻辑上变成了该 bthread 的”栈”。

mmap 匿名区不就是堆吗?

那问题来了:mmap 匿名映射区和堆区,不都是动态分配的内存?mmap 匿名区难道不算堆?

这里的关键,是”程序员视角的堆”和”操作系统视角的堆”在概念上存在差异:

区别在底层分配机制。malloc 申请内存时,分配器(glibc 的 ptmalloc、tcmalloc、jemalloc)会按大小决定向操作系统要内存的方式:

申请大小分配路径系统调用位置
小块(glibc 默认阈值 128KB 以下)ptmalloc 等brk()狭义 [heap],brk 指针上推
大块(超过 128KB)同上mmap(MAP_ANONYMOUS | MAP_PRIVATE)堆栈之间的空闲地址空间

大块走 mmap 是为了避免内存碎片。“匿名”的由来,是它没有映射到磁盘上的任何真实文件。

两者的直观表现也完全不同:

回到 brpc 协程栈的问题。bthread 栈通常相对较大(比如默认 1MB,或者内存池一次性申请一大块),底层分配器极大概率走 mmap。所以这些协程栈并没有落在那个名叫 [heap] 的狭小段里,而是散布在广阔的无名 mmap 匿名映射区中。但从”它是不是动态分配出来的”这个角度看,说它们属于”堆内存”,完全没有问题。

那岂不是每次启动协程都有缺页中断?

直觉很敏锐:如果每次启动协程都向操作系统申请新内存,确实会引发缺页中断(Page Fault),协程的创建会变得极其缓慢,完全违背”轻量级”的设计初衷。

先看理论上为什么会有。Linux 对内存采用惰性分配(Lazy Allocation):

如果每次建协程都走一遍这个流程,开销是巨大的——进入内核态的开销在微秒级别。

brpc 的解法是栈池化(Stack Pooling):

所以真实的缺页表现是:只有”第一次”会痛。

有了内存池的加持,brpc 创建一个新协程(复用栈)只是几次简单的指针操作,耗时通常在 200 纳秒左右。缺页中断只发生在冷启动阶段,稳定高并发服务里这部分开销被完美平摊掉了。

malloc 判空和 new 抛异常,什么联系?

顺着内存分配聊下去,绕不开一个经典问题:malloc 后判断 NULL,和 new 抛异常,到底什么关系?

本质上,两者都是程序向操作系统申请内存失败(OOM, Out Of Memory)时的错误报告机制。C 和 C++ 设计哲学不同,表现形式差异巨大。更残酷的是,在现代 Linux 上,这两种机制很多时候都形同虚设。

C 的哲学:malloc 与 NULL(基于状态码)

C 语言没有异常机制,处理错误的哲学是”用特殊返回值传递错误状态”。malloc 底层通过 brk() 或 mmap() 向系统申请空间,系统拒绝时(比如虚拟地址空间耗尽)返回 NULL。程序员必须在每次分配后立刻且手动写 if (p == NULL) 做错误处理——忘了写,后续对 p 的解引用就是段错误(Segmentation Fault)。

C++ 的哲学:new 与异常(基于控制流中断)

C++ 引入了异常机制。new 的工作不止分配内存,还要调用构造函数,底层分两步:

  1. 调用 operator new 分配内存(底层通常也是 malloc 或相似接口);
  2. 在这块内存上调用 T 的构造函数。

如果内存分配失败,第 2 步根本无法执行——没有内存怎么构造对象?而 C++ 的构造函数没有返回值,无法像 C 那样返回错误码。所以标准规定:new 分配内存失败必须抛出 std::bad_alloc 异常。

一个极其常见的初学者陷阱由此而来:

int* p = new int[1024];
if (p == nullptr) { // ❌ 这里的判断是毫无意义的(死代码)
    // 处理内存分配失败
}

这段代码逻辑是错的。如果 new 失败,它直接抛异常,控制流跳到 catch 块(或直接崩溃),根本不会执行到 if (p == nullptr) 这一行。如果执行到了这一行,说明 p 绝对不可能为空。

两者的联系就在这里:malloc 判空和 new 抛异常,是 C 和 C++ 面对同一个问题(OOM)的两种世界观——一个靠状态码,一个靠控制流中断。

桥梁:new (std::nothrow)

C++ 也考虑到了抛异常开销大、或遗留代码/内核驱动完全禁用异常的场景,提供了不抛异常的版本:

#include <new>

// 分配失败不抛异常,返回 nullptr
int* p = new (std::nothrow) int[1024];
if (p == nullptr) { // ✅ 此时这个判断就是正确且必须的了
    // 处理失败
}

终极现实:Linux 的超售机制

结合前面聊过的惰性分配,还有更残酷的一层。无论 malloc 返回 NULL,还是 new 抛出 bad_alloc,在现代 Linux 服务器上,你极少能真正捕捉到它们:

所以在绝大多数现代后端服务中,catch (const std::bad_alloc& e) 或者 if (!p) 根本没机会执行,进程就已经被操作系统击杀了。

小结

维度mallocnew
失败信号返回 NULL抛 std::bad_alloc
错误处理范式状态码判断异常控制流
不判空的后果解引用段错误异常未捕获直接 terminate
可选的”兼容”写法—new (std::nothrow) 返回 nullptr
现代 Linux 上的现实Overcommit 下极少触发,OOM Killer 直接杀进程同左

除非写嵌入式系统(内存有限且不支持虚拟内存),或者申请巨大连续内存块(超过虚拟地址限制),否则由于 Overcommit 的存在,过度纠结单次对象分配的 OOM 处理往往意义不大。更现代的做法是用 RAII(如 std::unique_ptr)防内存泄漏,把精力放在控制整体系统的内存水位上。

几点心得

  1. 地址空间布局不是教科书插图,是可实测的。一组 /proc/self/maps 加四个变量地址,比任何示意图都有说服力——代码段、数据段、堆、栈、vsyscall 各就各位,ASLR 只动基址不动结构。
  2. “堆”是个分层概念。广义的堆是程序员视角的动态内存,狭义的堆只是 brk() 管理的那一段。聊内存布局时先对齐视角,能省掉一大半争论。
  3. 协程栈的本质是堆上的普通内存,靠切换 RSP 指针”变成”栈。Guard Page 是它和原生线程栈在 maps 里最显眼的区别。
  4. 栈池化是协程框架的标配优化。缺页中断只在冷启动发生,稳态下创建协程是 200 纳秒级的纯用户态操作——这是”轻量级”三个字真正的工程含义。
  5. OOM 处理在 Overcommit 面前形同虚设。判空和 catch 都要写对(尤其是别给 new 判空),但真正的防线是内存水位监控,不是某一次分配的返回值。

以上是笔者在学习内存布局与协程实现时的整理,如果有什么不对的地方,欢迎指正。


Share this post on:

Previous Post
emplace_back 省了什么,vector 扩容为什么是 1.5 倍:一次容器基础的重挖
Next Post
brpc 里 ptmalloc 反超 tcmalloc:不是玄学,是 bthread 的锅