bthread 栈溢出(guard page)

在阅读 brpc 代码,我注意到文档中有这样一段描述:

栈使用 mmap 分配,bthread 还会用 mprotect 分配 4K 的 guard page 以检测栈溢出。看的一头雾水, 做了一番探究, 主要是学习 bthread 如何使用 mmap + mprotect

问题:栈溢出有多危险?

当程序使用的栈空间超过分配的大小时,就会发生栈溢出。如果没有任何保护措施,后果可能是灾难性的:

┌─────────────────────────────────────────────────────┐
│           没有 Guard Page 的危险情况                  │
├─────────────────────────────────────────────────────┤
│                                                     │
│  bthread A 的栈          bthread B 的栈              │
│  ┌──────────────┐       ┌──────────────┐           │
│  │              │       │              │           │
│  │   正常数据    │       │   正常数据    │           │
│  │      ↓       │       │              │           │
│  │   栈增长      │       │              │           │
│  │      ↓       │       │              │           │
│  │  💥溢出!     │←──────│  被悄悄覆盖!  │           │
│  └──────────────┘       └──────────────┘           │
│                                                     │
│  结果:B 的数据被破坏,导致神秘的、难以调试的 bug       │
└─────────────────────────────────────────────────────┘

栈溢出最可怕的地方在于:它可能悄无声息地破坏相邻内存的数据,程序不会立即崩溃,而是在之后的某个时刻表现出诡异的行为,让 debug 变成噩梦。

解决方案:Guard Page

Guard Page 的核心思想很简单:在栈的边界放置一块禁止访问的内存区域。当栈溢出试图访问这个区域时,CPU 会立即触发 SIGSEGV 信号,程序崩溃并报告错误位置。

┌─────────────────────────────────────────────────────┐
│              有 Guard Page 的安全情况                 │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌──────────────┐                                   │
│  │  可用栈空间   │  ← PROT_READ | PROT_WRITE        │
│  │   (正常使用)  │                                   │
│  │      ↓       │                                   │
│  │   栈增长      │                                   │
│  ├──────────────┤                                   │
│  │ 🛡️ GUARD PAGE │  ← PROT_NONE (禁止一切访问)       │
│  │    (4KB)     │                                   │
│  └──────────────┘                                   │
│                                                     │
│  栈溢出时 → 访问 guard page → SIGSEGV → 立即崩溃     │
│  问题被立即发现,而不是悄悄损坏数据                    │
└─────────────────────────────────────────────────────┘

这是一种 “fail-fast” 的设计哲学:与其让错误悄悄蔓延,不如让程序在第一时间崩溃。

三、mmap + mprotect:如何实现 Guard Page

回到最初的问题:mprotect 到底是干什么的?

查看 mprotect(2) 的 man page

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)**策略:

  1. mmap 调用时,只分配虚拟地址空间(几乎没有开销)
  2. 只有当程序真正访问某个页面时,内核才分配物理内存
  3. Guard page 被设置为 PROT_NONE,永远不会被访问,所以永远不消耗物理内存

实际内存消耗示例

假设你有 10000 个 bthread,每个栈 1MB + 4KB guard page:

项目虚拟地址空间物理内存
栈空间 (1MB × 10000)10 GB按实际使用量(可能只有几百 MB)
Guard pages (4KB × 10000)40 MB0 字节!

在 64 位系统上,虚拟地址空间有 256 TB(2^48),根本不用担心不够用。

五、为什么会影响 max_map_count?

文档中提到:

由于 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

六、brpc 的实现细节

查看 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 语言的方案语言级支持

bthread 的解决方案:选择合适的栈大小

// 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 和 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 语言的不同方案:动态扩展栈

┌─────────────────────────────────────────────────────────────────┐
│              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. 选择合适的栈大小
  2. 避免在栈上分配大对象
  3. 控制递归深度
┌─────────────────────────────────────────────────────────────────┐
│                    栈溢出的常见原因                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  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);
    }
}

固定栈 vs 动态栈的设计概述

在编程语言和库中,栈管理是并发和函数调用核心的一部分。固定栈(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);调试模式检测系统编程:可预测、零开销;权衡:程序员责任,无自动处理深调用。

详细设计与抉择

1. Linux 中的 pthread(POSIX 线程)

pthread 是 OS 级线程的标准实现,栈管理依赖 Linux 内核。

2. brpc 中的 bthread(百度 RPC 库的用户级协程)

bthread 是 brpc 的 M:N 协程模型(多用户线程到少内核线程),针对高性能 RPC 设计。

3. Go 语言

Go 的 goroutines 是轻量协程,栈管理是其并发模型的核心。

4. Rust 语言

Rust 区分线程和 async:前者 OS 级,后者用户级。

5. Zig 语言

Zig 是系统编程语言,无运行时,栈完全显式。

总结与建议

固定栈(pthread、bthread、Rust 线程、Zig)强调控制和性能,适合系统级;动态栈(Go)自动灵活,适合应用级。高并发首选动态或栈less(如 Rust async)。实际开发:监控栈使用(e.g., gdb),优先堆/迭代避溢出。


参考资料:

Linux系统性能排查工具表
关于图数据库的一些思考