在阅读 brpc 代码,我注意到文档中有这样一段描述:
栈使用 mmap 分配,bthread 还会用 mprotect 分配 4K 的 guard page 以检测栈溢出。看的一头雾水, 做了一番探究, 主要是学习 bthread 如何使用 mmap + mprotect
当程序使用的栈空间超过分配的大小时,就会发生栈溢出。如果没有任何保护措施,后果可能是灾难性的:
┌─────────────────────────────────────────────────────┐
│ 没有 Guard Page 的危险情况 │
├─────────────────────────────────────────────────────┤
│ │
│ bthread A 的栈 bthread B 的栈 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ │ │
│ │ 正常数据 │ │ 正常数据 │ │
│ │ ↓ │ │ │ │
│ │ 栈增长 │ │ │ │
│ │ ↓ │ │ │ │
│ │ 💥溢出! │←──────│ 被悄悄覆盖! │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 结果:B 的数据被破坏,导致神秘的、难以调试的 bug │
└─────────────────────────────────────────────────────┘
栈溢出最可怕的地方在于:它可能悄无声息地破坏相邻内存的数据,程序不会立即崩溃,而是在之后的某个时刻表现出诡异的行为,让 debug 变成噩梦。
Guard Page 的核心思想很简单:在栈的边界放置一块禁止访问的内存区域。当栈溢出试图访问这个区域时,CPU 会立即触发 SIGSEGV 信号,程序崩溃并报告错误位置。
┌─────────────────────────────────────────────────────┐
│ 有 Guard Page 的安全情况 │
├─────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 可用栈空间 │ ← PROT_READ | PROT_WRITE │
│ │ (正常使用) │ │
│ │ ↓ │ │
│ │ 栈增长 │ │
│ ├──────────────┤ │
│ │ 🛡️ GUARD PAGE │ ← PROT_NONE (禁止一切访问) │
│ │ (4KB) │ │
│ └──────────────┘ │
│ │
│ 栈溢出时 → 访问 guard page → SIGSEGV → 立即崩溃 │
│ 问题被立即发现,而不是悄悄损坏数据 │
└─────────────────────────────────────────────────────┘
这是一种 “fail-fast” 的设计哲学:与其让错误悄悄蔓延,不如让程序在第一时间崩溃。
回到最初的问题:mprotect 到底是干什么的?
mprotect() changes the access protections for the calling process’s memory pages.
所以 mprotect 不是用来分配内存的,而是用来修改已有内存的访问权限。
bthread 的实际实现流程是这样的:
// 第一步:用 mmap 分配整块内存(栈 + guard page)
const int memsize = stacksize + guardsize;
void* const mem = mmap(NULL, memsize,
PROT_READ | PROT_WRITE, // 初始权限:可读可写
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
// 第二步:用 mprotect 将 guard page 区域设为不可访问
mprotect(aligned_mem, guardsize, PROT_NONE); // 权限改为:禁止一切访问
为什么必须用 mmap 而不是 malloc?
因为 mprotect 要求地址必须是页对齐的(通常是 4KB 的整数倍):
| 分配方式 | 页对齐 | 能用 mprotect? |
|---|---|---|
malloc | ❌ 不保证 | ❌ 会报 EINVAL |
mmap | ✅ 保证 | ✅ 可以 |
当我第一次理解这个机制时,我的直觉反应是:每个栈都要额外分配 4KB 的 guard page,如果有成千上万个 bthread,岂不是浪费大量内存?
答案是:不浪费!
这里需要理解 Linux 的虚拟内存机制:
┌─────────────────────────────────────────────────────────────────┐
│ mmap 的工作原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ mmap 分配的是「虚拟地址空间」,不是物理内存! │
│ │
│ 虚拟地址空间 物理内存 (RAM) │
│ ┌──────────────┐ │
│ │ 可用栈空间 │ ──── 访问时 ────→ 分配物理页面 │
│ │ │ 才分配 │
│ ├──────────────┤ │
│ │ GUARD PAGE │ ──── PROT_NONE ──→ 永远不访问 = 不分配物理内存!│
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Linux 采用**按需分页(Demand Paging)**策略:
mmap 调用时,只分配虚拟地址空间(几乎没有开销)PROT_NONE,永远不会被访问,所以永远不消耗物理内存假设你有 10000 个 bthread,每个栈 1MB + 4KB guard page:
| 项目 | 虚拟地址空间 | 物理内存 |
|---|---|---|
| 栈空间 (1MB × 10000) | 10 GB | 按实际使用量(可能只有几百 MB) |
| Guard pages (4KB × 10000) | 40 MB | 0 字节! |
在 64 位系统上,虚拟地址空间有 256 TB(2^48),根本不用担心不够用。
文档中提到:
由于 mmap+mprotect 不能超过 max_map_count(默认为 65536)
虽然 guard page 不消耗物理内存,但 mprotect 会分裂 VMA(Virtual Memory Area)。
当你对一块连续映射的内存调用 mprotect 改变部分区域的权限时,内核需要将这块区域分割成多个具有不同属性的 VMA。每个 VMA 都是 /proc/[pid]/maps 中的一个条目,而系统对条目总数有限制。
# 查看当前限制
cat /proc/sys/vm/max_map_count
# 65536
# 如果 bthread 很多,可能需要调大
sudo sysctl -w vm.max_map_count=262144
查看 src/bthread/stack.cpp 中的实际代码:
int allocate_stack_storage(StackStorage* s, int stacksize_in, int guardsize_in) {
// ...
if (guardsize_in <= 0) {
// 没有 guard page,直接用 malloc(性能更好但不安全)
void* mem = malloc(stacksize);
// ...
} else {
// 有 guard page,必须用 mmap
const int memsize = stacksize + guardsize;
void* const mem = mmap(NULL, memsize, (PROT_READ | PROT_WRITE),
(MAP_PRIVATE | MAP_ANONYMOUS), -1, 0);
if (MAP_FAILED == mem) {
// 可能是 max_map_count 限制
PLOG_EVERY_SECOND(ERROR)
<< "Fail to mmap size=" << memsize
<< ", possibly limited by /proc/sys/vm/max_map_count";
return -1;
}
// 将 guard page 设为不可访问
if (mprotect(aligned_mem, guardsize - offset, PROT_NONE) != 0) {
munmap(mem, memsize);
return -1;
}
// ...
}
}
brpc 还提供了配置选项:
DEFINE_int32(guard_page_size, 4096,
"size of guard page, allocate stacks by malloc if it's 0 (not recommended)");
如果设置 guard_page_size=0,会使用 malloc 分配栈(更快但没有溢出保护)。
| 概念 | 说明 |
|---|---|
| Guard Page | 栈边界的”地雷”,一踩就崩溃 |
| mmap | 分配页对齐的虚拟内存 |
| mprotect | 修改内存权限为 PROT_NONE |
| 物理内存消耗 | Guard page 消耗为零 |
| 作用 | 让栈溢出立即暴露,而非悄悄损坏数据 |
Guard Page 是一个非常精妙的设计:
这就是 “fail-fast” 哲学的完美体现:让错误尽早暴露,而不是让它悄悄蔓延。
┌─────────────────────────────────────────────────────────────────┐
│ 栈溢出的常见原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 递归太深 │
│ ┌──────────────────────────────────────────┐ │
│ │ void bad_recursion(int n) { │ │
│ │ char buf[1024]; // 每次调用占 1KB+ │ │
│ │ bad_recursion(n + 1); // 无限递归 │ │
│ │ } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 2. 局部变量太大 │
│ ┌──────────────────────────────────────────┐ │
│ │ void process() { │ │
│ │ char buffer[1024 * 1024]; // 1MB! │ │
│ │ // bthread 小栈只有 32KB... │ │
│ │ } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 3. 调用链太深(正常调用但层次太多) │
│ A() → B() → C() → D() → ... → Z() → ... │
│ │
└─────────────────────────────────────────────────────────────────┘
本质答案:无法完全自动防止,需要程序员注意。
| 策略 | 方法 | 适用场景 |
|---|---|---|
| 分配更大的栈 | 使用更大栈类型 | 知道需要大栈时 |
| 减少栈使用 | 局部大数组改用 heap | 始终推荐 |
| 限制递归深度 | 手动检查或改迭代 | 递归算法 |
| 动态扩展栈 | Go 语言的方案 | 语言级支持 |
// src/bthread/stack.cpp
DEFINE_int32(stack_size_small, 32768, "size of small stacks"); // 32KB
DEFINE_int32(stack_size_normal, 1048576, "size of normal stacks"); // 1MB
DEFINE_int32(stack_size_large, 8388608, "size of large stacks"); // 8MB
// 使用方式
bthread_attr_t attr = BTHREAD_ATTR_SMALL; // 32KB - 简单任务
bthread_attr_t attr = BTHREAD_ATTR_NORMAL; // 1MB - 默认,大多数场景
bthread_attr_t attr = BTHREAD_ATTR_LARGE; // 8MB - 深递归/大缓冲区
pthread 和 bthread 类似,也是固定大小栈 + guard page:
// 查看/设置 pthread 栈大小
pthread_attr_t attr;
pthread_attr_init(&attr);
size_t stacksize;
pthread_attr_getstacksize(&attr, &stacksize);
// 默认通常是 8MB (Linux) 或 512KB (macOS)
// 设置更大的栈
pthread_attr_setstacksize(&attr, 16 * 1024 * 1024); // 16MB
// 设置 guard page 大小
pthread_attr_setguardsize(&attr, 4096); // 4KB
┌─────────────────────────────────────────────────────────────────┐
│ Go 的「连续栈」方案(bthread 没有采用) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 初始分配小栈 (2KB) │
│ ┌────────┐ │
│ │ 栈 │ │
│ └────────┘ │
│ │ │
│ ▼ 快用完了 │
│ │
│ 分配更大的栈,复制内容,更新所有指针 │
│ ┌────────────────────────┐ │
│ │ 栈(2倍大小) │ │
│ └────────────────────────┘ │
│ │
│ 优点:按需增长,节省内存 │
│ 缺点:需要 GC 支持,有性能开销,C++ 难以实现(指针不好更新) │
│ │
└─────────────────────────────────────────────────────────────────┘
// ❌ 不好:大数组放栈上
void process_data() {
char buffer[1024 * 1024]; // 1MB 在栈上,危险!
// ...
}
// ✅ 好:大数组放堆上
void process_data() {
std::vector<char> buffer(1024 * 1024); // 堆分配
// 或者
std::unique_ptr<char[]> buffer(new char[1024 * 1024]);
// ...
}
// ❌ 不好:无限制递归
void traverse(Node* node) {
if (!node) return;
traverse(node->left);
traverse(node->right); // 树太深会爆栈
}
// ✅ 好:改用迭代 + 显式栈
void traverse(Node* root) {
std::stack<Node*> s; // 堆上的栈结构
s.push(root);
while (!s.empty()) {
Node* node = s.top(); s.pop();
if (node->right) s.push(node->right);
if (node->left) s.push(node->left);
}
}
| 系统/语言 | 栈大小 | 溢出处理 | 动态扩展 |
|---|---|---|---|
| pthread | 固定(默认8MB) | Guard page → SIGSEGV | ❌ |
| bthread | 固定(32KB/1MB/8MB可选) | Guard page → SIGSEGV | ❌ |
| Go | 动态(初始2KB) | 自动扩展 | ✅ |
| Rust | 固定(默认2MB) | Guard page → panic | ❌ |
核心原则:C/C++ 中没有自动防止栈溢出的机制,程序员需要:
┌─────────────────────────────────────────────────────────────────┐
│ 栈溢出的常见原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 递归太深 │
│ ┌──────────────────────────────────────────┐ │
│ │ void bad_recursion(int n) { │ │
│ │ char buf[1024]; // 每次调用占 1KB+ │ │
│ │ bad_recursion(n + 1); // 无限递归 │ │
│ │ } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 2. 局部变量太大 │
│ ┌──────────────────────────────────────────┐ │
│ │ void process() { │ │
│ │ char buffer[1024 * 1024]; // 1MB! │ │
│ │ // bthread 小栈只有 32KB... │ │
│ │ } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 3. 调用链太深(正常调用但层次太多) │
│ A() → B() → C() → D() → ... → Z() → ... │
│ │
└─────────────────────────────────────────────────────────────────┘
// src/bthread/stack.cpp
DEFINE_int32(stack_size_small, 32768, "size of small stacks"); // 32KB
DEFINE_int32(stack_size_normal, 1048576, "size of normal stacks"); // 1MB
DEFINE_int32(stack_size_large, 8388608, "size of large stacks"); // 8MB
// 使用方式
bthread_attr_t attr = BTHREAD_ATTR_SMALL; // 32KB - 简单任务
bthread_attr_t attr = BTHREAD_ATTR_NORMAL; // 1MB - 默认,大多数场景
bthread_attr_t attr = BTHREAD_ATTR_LARGE; // 8MB - 深递归/大缓冲区
// 查看/设置 pthread 栈大小
pthread_attr_t attr;
pthread_attr_init(&attr);
size_t stacksize;
pthread_attr_getstacksize(&attr, &stacksize);
// 默认通常是 8MB (Linux) 或 512KB (macOS)
// 设置更大的栈
pthread_attr_setstacksize(&attr, 16 * 1024 * 1024); // 16MB
// 设置 guard page 大小
pthread_attr_setguardsize(&attr, 4096); // 4KB
┌─────────────────────────────────────────────────────────────────┐
│ Go 的「连续栈」方案(bthread 没有采用) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 初始分配小栈 (2KB) │
│ ┌────────┐ │
│ │ 栈 │ │
│ └────────┘ │
│ │ │
│ ▼ 快用完了 │
│ │
│ 分配更大的栈,复制内容,更新所有指针 │
│ ┌────────────────────────┐ │
│ │ 栈(2倍大小) │ │
│ └────────────────────────┘ │
│ │
│ 优点:按需增长,节省内存 │
│ 缺点:需要 GC 支持,有性能开销,C++ 难以实现(指针不好更新) │
│ │
└─────────────────────────────────────────────────────────────────┘
// ❌ 不好:大数组放栈上
void process_data() {
char buffer[1024 * 1024]; // 1MB 在栈上,危险!
// ...
}
// ✅ 好:大数组放堆上
void process_data() {
std::vector<char> buffer(1024 * 1024); // 堆分配
// 或者
std::unique_ptr<char[]> buffer(new char[1024 * 1024]);
// ...
}
// ❌ 不好:无限制递归
void traverse(Node* node) {
if (!node) return;
traverse(node->left);
traverse(node->right); // 树太深会爆栈
}
// ✅ 好:改用迭代 + 显式栈
void traverse(Node* root) {
std::stack<Node*> s; // 堆上的栈结构
s.push(root);
while (!s.empty()) {
Node* node = s.top(); s.pop();
if (node->right) s.push(node->right);
if (node->left) s.push(node->left);
}
}
在编程语言和库中,栈管理是并发和函数调用核心的一部分。固定栈(fixed stack)是指在创建线程、协程或函数帧时预分配固定大小的内存区域,通常通过 OS 或运行时分配,无法在运行中自动扩展。这简化了实现,但要求程序员避免溢出(通常通过 guard page 检测,导致崩溃)。动态栈(dynamic stack)允许栈按需增长,通常通过复制旧栈到更大区域或分段链接,实现更灵活的内存使用,但引入开销(如复制、指针更新)和复杂性(如需要 GC 支持)。
抉择取决于语言目标:
下面,我基于调查(包括官方文档、源代码和可靠来源)详细比较 pthread、bthread (brpc)、Go、Rust 和 Zig 的设计。使用表格总结关键点,然后展开细节。
| 系统/语言 | 栈类型 | 默认/初始大小 | 增长机制 | 溢出处理 | 设计抉择与权衡 |
|---|---|---|---|---|---|
| pthread (Linux) | 固定 | 通常 2-8MB(主线程 ulimit -s 默认 8MB,子线程 2MB;最小 16KB) | 无(固定于创建) | Guard page → SIGSEGV(崩溃) | POSIX 标准:简单、OS 集成;权衡:内存浪费或溢出风险,依赖程序员配置。 |
| bthread (brpc) | 固定 | 可选:小 (32KB)、正常 (1MB)、大 (8MB) | 无(固定于创建) | Guard page → SIGSEGV | 高性能 RPC 协程:轻量、节省内存;权衡:手动选择大小,避免默认大栈浪费。 |
| Go | 动态(连续栈) | 初始 2-8KB(早期 8KB,现 2KB) | 按需复制到双倍大小,GC 更新指针 | 运行时检测 → 自动增长 | Goroutine 轻量并发:自动、内存高效;权衡:增长开销、需 GC 支持。 |
| Rust | 线程:固定;Async:无栈(状态机) | 线程:2-8MB(OS 默认);Async:堆上精确大小 | 线程:无;Async:堆分配分段 | 线程:SIGSEGV;Async:无溢出 | 系统安全:线程如 OS,Async 高效;权衡:Async 需手动 Box 分段,避免栈浪费。 |
| Zig | 固定(所有,包括协程) | 函数/线程:编译时计算;协程:堆 continuation 或固定 opt-in | 无(明确禁止动态) | UB(通常 SIGSEGV);调试模式检测 | 系统编程:可预测、零开销;权衡:程序员责任,无自动处理深调用。 |
pthread 是 OS 级线程的标准实现,栈管理依赖 Linux 内核。
设计:固定栈大小,在 pthread_create 时通过 pthread_attr_setstacksize 设置(最小 PTHREAD_STACK_MIN = 16KB,必须是页面倍数)。默认值平台相关:主线程通常 8MB(ulimit -s),子线程 2MB。 栈通过 mmap 分配私有内存区域,包括 guard page(默认 4KB,可用 pthread_attr_setguardsize 设置为 0 以节省内存)。主线程可动态增长(内核支持),但子线程固定。
溢出处理:访问 guard page 触发 SIGSEGV 信号。无自动扩展;程序可捕获信号但不推荐(复杂,不可靠)。
抉择:固定栈优先简单性和 OS 集成,便于多平台兼容。避免动态增长以减少内核开销,但牺牲灵活性——高并发时大栈浪费内存(e.g., 1000 线程需 GB 级栈),小栈易溢出。适用于需要隔离的任务(如 I/O),但不适合海量协程。glibc 等实现可能向下舍入非对齐大小,违反 POSIX 以确保安全性。
bthread 是 brpc 的 M:N 协程模型(多用户线程到少内核线程),针对高性能 RPC 设计。
设计:固定栈,通过 FLAGS 定义大小:小 (32KB,简单任务)、正常 (1MB,默认)、大 (8MB,深递归/大缓冲)。 使用 mmap 分配栈(包括 guard page),栈池缓存重用以减少开销。每个请求创建新 bthread,结束时销毁,自动调整线程数。 与 pthread 类似,但更轻量(无 OS 开销)。
溢出处理:Guard page → SIGSEGV。无动态扩展;推荐堆分配大对象或迭代替换递归。
抉择:固定栈 + 可选大小平衡内存和性能:小栈支持海量协程(brpc 常用于搜索/存储),大栈处理复杂逻辑。 避免动态栈以简化 C++ 实现(指针更新难),优先高吞吐。权衡:需手动 attr(如 BTHREAD_ATTR_LARGE)选择,否则默认 1MB 可能溢出深调用。brpc 源代码(stack.cpp)强调重用以优化。
Go 的 goroutines 是轻量协程,栈管理是其并发模型的核心。
设计:动态连续栈(contiguous stacks)。初始小栈(2KB,现版本;早期 8KB)。 每个函数 prologue 检查栈使用,若溢出调用 morestack:分配双倍新栈,复制旧栈,GC 元数据更新指针。从早期分段栈(segmented,链接新段)转向连续以避 “hot split”(循环中反复增长/收缩开销)。 无需程序员配置。
溢出处理:运行时检测 → 自动增长。收缩为 no-op(指针回退)。
抉择:动态栈支持海量 goroutines(初始小,节省内存),自动处理深递归。 选择连续 vs 分段:连续减少收缩开销,但需 GC(C++ 难实现)。权衡:增长有 CPU 开销(复制、更新),需重写运行时为 Go 以支持元数据。 优于固定栈(如 pthread)的内存效率,适合云/服务器。
Rust 区分线程和 async:前者 OS 级,后者用户级。
设计:线程:固定栈,基于 pthread/OS,默认 2-8MB,可自定义(std::thread::Builder::stack_size)。 Async:栈less 协程(futures),编译为堆上状态机,精确大小(仅需状态)。无专用栈;用户可 Box 分段以减内存。 早期 green threads 用分段/复制栈,但弃用以避性能坑。
溢出处理:线程:SIGSEGV;Async:无(堆无限)。
抉择:线程固定以匹配 OS、安全;Async 栈less 高效(内存 < 线程),避免 Go 式复制开销。 用户控制分段优于自动(避 hot loop 分配)。权衡:Async 需手动管理大状态,但提升并发(e.g., tokio 运行时)。适合安全系统编程。
Zig 是系统编程语言,无运行时,栈完全显式。
设计:所有固定栈。函数帧:编译时大小(@sizeOf),零大小类型无空间。 线程:固定于创建(std.Thread.StackSize:small/default/large)。协程:栈less(suspend/resume,heap continuation);opt-in stackful 用固定数组。 无 VLA/alloca;async 回归中,未来低级原语。
溢出处理:UB(通常 SIGSEGV);调试检测。
抉择:固定栈确保可预测、零开销、移植(嵌入式)。 弃动态以避非确定性/开销;程序员控制(如 nosuspend)。权衡:无自动深调用支持,但匹配 C 互操作,优先实时/安全。协程栈less 简单,stackful 库(如 Zio)补齐。
固定栈(pthread、bthread、Rust 线程、Zig)强调控制和性能,适合系统级;动态栈(Go)自动灵活,适合应用级。高并发首选动态或栈less(如 Rust async)。实际开发:监控栈使用(e.g., gdb),优先堆/迭代避溢出。
参考资料: