团团虾声明:基于对 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--p | 0x6026d90d0000 - 0x6026d90d1000 | — |
| 代码段可执行部分 | r-xp | 0x6026d90d1000 - 0x6026d90d2000 | main (0x6026d90d1249) |
| 数据段 | rw-p | 0x6026d90d3000 - 0x6026d90d5000 | g_init (0x6026d90d4010) |
堆 [heap] | rw-p | 0x6026fcdd9000 - 0x6026fcdfa000 | heap_var (0x6026fcdd9010) |
栈 [stack] | rw-p | 0x7ffed66b6000 - 0x7ffed66d7000 | stack_var (0x7ffed66d3f34) |
[vsyscall] | --xp | 0xffffffffff600000 - 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、calloc或new动态申请的内存,统统叫”堆内存”,不关心底层怎么分。 - 狭义的堆(操作系统视角,即 maps 中的
[heap]):特指由brk()(或sbrk())管理的、紧挨数据段上方、连续向上生长的那一块。
区别在底层分配机制。malloc 申请内存时,分配器(glibc 的 ptmalloc、tcmalloc、jemalloc)会按大小决定向操作系统要内存的方式:
| 申请大小 | 分配路径 | 系统调用 | 位置 |
|---|---|---|---|
| 小块(glibc 默认阈值 128KB 以下) | ptmalloc 等 | brk() | 狭义 [heap],brk 指针上推 |
| 大块(超过 128KB) | 同上 | mmap(MAP_ANONYMOUS | MAP_PRIVATE) | 堆栈之间的空闲地址空间 |
大块走 mmap 是为了避免内存碎片。“匿名”的由来,是它没有映射到磁盘上的任何真实文件。
两者的直观表现也完全不同:
[heap]:位置固定在 BSS 段之上,线性生长。释放了中间某块内存,归还给系统的难度很大(容易产生碎片),通常要等最高处的内存释放了,brk 指针才能降下来。- mmap 匿名映射区:位于栈和堆之间,在 maps 文件里没有名字,只显示一段
rw-p的匿名地址(如7f8a10000000-7f8a10080000)。用完munmap()就能立刻精准归还给操作系统,非常干净。
回到 brpc 协程栈的问题。bthread 栈通常相对较大(比如默认 1MB,或者内存池一次性申请一大块),底层分配器极大概率走 mmap。所以这些协程栈并没有落在那个名叫 [heap] 的狭小段里,而是散布在广阔的无名 mmap 匿名映射区中。但从”它是不是动态分配出来的”这个角度看,说它们属于”堆内存”,完全没有问题。
那岂不是每次启动协程都有缺页中断?
直觉很敏锐:如果每次启动协程都向操作系统申请新内存,确实会引发缺页中断(Page Fault),协程的创建会变得极其缓慢,完全违背”轻量级”的设计初衷。
先看理论上为什么会有。Linux 对内存采用惰性分配(Lazy Allocation):
- 程序
mmap或malloc一大块内存做协程栈时,内核只在页表里划出一块虚拟地址空间,不真正分配物理内存。 - 协程开始执行,RSP 移到这块区域并尝试写入(局部变量、函数返回地址)时,硬件才发现这块虚拟内存没有映射物理页。
- 此时触发次缺页中断(Minor Page Fault),陷入内核态,内核分配物理页框、建立映射,再恢复执行。
如果每次建协程都走一遍这个流程,开销是巨大的——进入内核态的开销在微秒级别。
brpc 的解法是栈池化(Stack Pooling):
- 一个
bthread运行结束(函数 return)后,它用的栈内存不会被munmap或free归还给操作系统,而是放进内部的空闲栈池(Free Stack Pool)。 - 新启动的
bthread优先从栈池里弹出一个现成的、之前别人用过的栈。
所以真实的缺页表现是:只有”第一次”会痛。
- 第一次分配:进程刚启动,或突发极高并发把池子里的栈用光了,brpc 只能向系统
mmap申请新栈。这个新栈第一次被协程踩上去时,发生次缺页中断。 - 后续复用:一旦这块栈内存被物理映射过、归还到池子里,下一个接手的新协程直接运行在已分配好物理内存的虚拟地址上——全程用户态,没有缺页中断,没有内核态切换。
有了内存池的加持,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 的工作不止分配内存,还要调用构造函数,底层分两步:
- 调用
operator new分配内存(底层通常也是malloc或相似接口); - 在这块内存上调用
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 服务器上,你极少能真正捕捉到它们:
- Linux 的”谎言”:Linux 默认开启内存超售(Memory Overcommit)。申请 1GB 内存时,只要虚拟地址空间够,Linux 都会说”没问题,申请成功”——
malloc返回非空指针,new也不抛异常。 - OOM Killer:但物理内存并没有真正分配。直到程序真正读写这块内存、触发缺页中断、内核去分配物理页框时,才发现物理内存已被耗尽。
- 结果:内核的 OOM Killer 直接挑选一个进程(通常是占内存最多的那个),发
SIGKILL——进程被瞬间强制秒杀。
所以在绝大多数现代后端服务中,catch (const std::bad_alloc& e) 或者 if (!p) 根本没机会执行,进程就已经被操作系统击杀了。
小结
| 维度 | malloc | new |
|---|---|---|
| 失败信号 | 返回 NULL | 抛 std::bad_alloc |
| 错误处理范式 | 状态码判断 | 异常控制流 |
| 不判空的后果 | 解引用段错误 | 异常未捕获直接 terminate |
| 可选的”兼容”写法 | — | new (std::nothrow) 返回 nullptr |
| 现代 Linux 上的现实 | Overcommit 下极少触发,OOM Killer 直接杀进程 | 同左 |
除非写嵌入式系统(内存有限且不支持虚拟内存),或者申请巨大连续内存块(超过虚拟地址限制),否则由于 Overcommit 的存在,过度纠结单次对象分配的 OOM 处理往往意义不大。更现代的做法是用 RAII(如 std::unique_ptr)防内存泄漏,把精力放在控制整体系统的内存水位上。
几点心得
- 地址空间布局不是教科书插图,是可实测的。一组
/proc/self/maps加四个变量地址,比任何示意图都有说服力——代码段、数据段、堆、栈、vsyscall 各就各位,ASLR 只动基址不动结构。 - “堆”是个分层概念。广义的堆是程序员视角的动态内存,狭义的堆只是
brk()管理的那一段。聊内存布局时先对齐视角,能省掉一大半争论。 - 协程栈的本质是堆上的普通内存,靠切换
RSP指针”变成”栈。Guard Page 是它和原生线程栈在 maps 里最显眼的区别。 - 栈池化是协程框架的标配优化。缺页中断只在冷启动发生,稳态下创建协程是 200 纳秒级的纯用户态操作——这是”轻量级”三个字真正的工程含义。
- OOM 处理在 Overcommit 面前形同虚设。判空和 catch 都要写对(尤其是别给
new判空),但真正的防线是内存水位监控,不是某一次分配的返回值。
以上是笔者在学习内存布局与协程实现时的整理,如果有什么不对的地方,欢迎指正。